Skip to content

Merge pull request #2647 from Vacademy-io/feature/tts-speech-cache - #2648

Open
Manshu48 wants to merge 127 commits into
feature/tts-speech-cachefrom
main
Open

Merge pull request #2647 from Vacademy-io/feature/tts-speech-cache#2648
Manshu48 wants to merge 127 commits into
feature/tts-speech-cachefrom
main

Conversation

@Manshu48

Copy link
Copy Markdown
Contributor

Feature/tts speech cache

Summary of Changes

Related Issue

Type of Change

  • Bug fix (non-breaking change that fixes an issue)
  • New feature (non-breaking change that adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

How Has This Been Tested?

Checklist

  • My code follows the project's style guidelines
  • I have performed a self-review of my code
  • I have added tests that prove my fix is effective or that my feature works
  • New and existing tests pass locally with my changes
  • I have updated the documentation accordingly

Manshu48 and others added 6 commits August 21, 2026 11:44
"When a counsellor manually marks a lead DNP, have the AI bot call it back"
could not be built before: the lead is assigned to the counsellor who just
called it, and CALL_AI dials as CallTrigger.AUTOMATION, the only trigger that
enforces the already-assigned guard. The node stopped with reason="assigned"
and never dialled.

- CallTrigger.WORKFLOW_EXPLICIT: skips the already-assigned guard but keeps the
  daily cap and duplicate window, since this path is still automation and can
  loop. Distinct from AUTOMATION so the skip is visible in logs and can never
  become the default.
- AiCallNodeDispatcher.enqueue(req, trigger): the trigger is chosen by the
  calling code from authored node config, never read off the request body.
- CALL_AI node config "ignoreAssignedGuard" (default false) drives both the
  node's own plan() gate and the trigger it dials with, so the two can't
  disagree and burn a retry on a call placeCall would refuse.
- LEAD_STATUS_CHANGED now carries statusChangeSource + statusChangedByUserId.
  The event has no idempotency dedup (strategy UUID) and the AI's own outcome
  writes a status back through changeLeadStatus, so without the source a graph
  reacting to a status re-enters off its own write.
- Builder: a checkbox for the flag, and a one-click template "AI re-calls leads
  on a status" wiring the status gate, source allow-list, delay and outcome
  branch. Also corrects a stale comment claiming the Quartz resume job is
  disabled - it runs every 2 min and is what drives the CALL_AI retry loop.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Client on Shiksha Nation: too much "hmm" / "huh", and - the detail that
changes the diagnosis - it happens WHILE THE CALLER IS TALKING, not at the
start of a reply.

Every guard on the filler was about the BOT's state (not speaking, not ducked,
has spoken once). Nothing asked whether the CALLER had finished. Two faults
compounded:

  * saaras splits one halting utterance into fragment finals (see
    diagnostics.reconcile_answers - "नहीं जान।" + "सकते हैं आप।") and EVERY
    fragment was its own roll. A sentence splitting five ways carried a ~41%
    chance of a filler at p=0.10, spoken into the caller's own speech.
  * nothing stopped a second filler covering the same silence.

Now: never while the VAD reports voice live - acoustic truth via
CallState.voice_tick_t, not the turn frames, which lag by seconds - and at most
one filler per gap between the bot's turns.

The latency mask it exists for is untouched. A real end-of-turn final trails
the last voice tick by ~0.65s, well past the 0.4s window, so the filler still
covers the ~1.5s pipeline floor it was added for. FILLER_VOICE_LIVE_SECS is a
knob so this can be tuned without a deploy.

NOT a probability change. p=0.10 has already been cut twice on this same
complaint (0.7 -> 0.25 -> 0.10) and cutting it again would have masked a real
bug while removing the mask from calls whose top fault is DEAD_AIR (24 of 53).
"huh" is not even in FILLER_PHRASES ("Hmm…" only), so part of that report is the
model, handled separately in the prompt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The TTS cost line was duration x 779 chars/call-min - a fleet average measured
over 13 production calls. It is right ACROSS calls and wrong on any one: an
agent that monologues exceeds 779 and was under-costed, one whose caller does
most of the talking was over-costed. Shiksha Nation's Shreya, with measured
20-33s monologues, sits on the wrong side of that.

The bot already meters the exact quantity the vendor bills - characters - and
ships it as diagnostics.tts.chars. The savings line has always used it
(cacheCharsSaved); the cost line ignored it. Now it uses it, with duration x
the average as the fallback for any call whose blob is absent - cache off, an
older row, a bot that died before reporting.

Same divisor as the savings line, so the two still cannot disagree.

The summary endpoint recomputes the same breakdown in its own SQL, so it had to
move together or the totals would stop matching the rows they sum. It now
aggregates measured characters per engine and the duration of only those calls
that lack them, applying each the same way.

New ttsCharsMeasured on the DTO: non-null means this call's TTS line is the
vendor's metered quantity rather than the average. costIsModelled stays true -
plivo, stt and llm are still duration-modelled, and llm is the one that matters
now (measured at 4569 input tokens/turn, while Shreya's system prompt alone is
21,176 chars and is re-sent every turn).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
feat(ai-calling): teach every cached agent the wording rules, not jus…
@codacy-production

codacy-production Bot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Not up to standards ⛔

🔴 Issues 54 high · 35 medium · 11 minor

Alerts:
⚠ 100 issues (≤ 0 issues of at least minor severity)

Results:
100 new issues

Category Results
Compatibility 4 medium
UnusedCode 1 medium
BestPractice 23 medium
ErrorProne 3 medium
54 high
CodeStyle 11 minor
Complexity 4 medium

View in Codacy

🟢 Metrics 2449 complexity

Metric Results
Complexity 2449

View in Codacy

NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.

… sync

- community_service: link app_registration rows to an institute, add an
  internal (HMAC) read endpoint for admin_core_service to call
- admin_core_service: institute-scoped, read-only /app-registry/v1/status
  endpoint reusing the existing WhiteLabel institute-access check
- frontend-admin-dashboard: new read-only "App Status" settings tab
- community_service: replace the getAppStatus/... 501 stubs with a real
  App Store Connect integration for iOS/macOS (Android/Windows still 501,
  no credential exists for either yet)
…isibility

feat: institute-scoped app-status visibility + live App Store Connect…
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 24, 2026

Copy link
Copy Markdown

Deploying frontend-admin-dashboard with  Cloudflare Pages  Cloudflare Pages

Latest commit: faee711
Status: ✅  Deploy successful!
Preview URL: https://1e7fcbcb.frontend-admin-dashboard.pages.dev

View logs

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 24, 2026

Copy link
Copy Markdown

Deploying frontend-learner-dashboard-app with  Cloudflare Pages  Cloudflare Pages

Latest commit: faee711
Status: ✅  Deploy successful!
Preview URL: https://17858224.frontend-learner-dashboard-app.pages.dev

View logs

Shreyash Jain and others added 2 commits August 24, 2026 19:20
A median tells you THAT it was slow. Acting on it needs three more things, so
the tooltip now carries them.

Which call. The single worst server timing in the window, with its templated
route. In the reported case the median was healthy while one request took
2053ms -- exactly the situation where a median alone tells you nothing useful.

When. Wall-clock start and end of the window, plus the time of the slowest call.
Without exact times a report reaches us as "it was slow this afternoon" and
cannot be lined up against server logs, a deploy, or a spike. That is precisely
how the 2026-08-22 incident ended up being reconstructed from container logs
that had already rotated.

What else was going on. Request and ping counts, plus errors and untimed
responses when there are any. Slow-with-errors is a different problem from
slow-and-fine, and a server figure resting on 2 samples deserves less trust
than one resting on 20.

Clicking the rail icon copies a diagnostics block with ISO timestamps, the
verdict, the slowest call, counts, page and user agent -- so "it's slow" can
arrive as something greppable instead of a sentence.

The slowest call is derived only from real Server-Timing values, never inferred
from total round trip: on an untimed response the total includes transfer, and
reporting that as our processing time would invent a number.

15 probes: worst call identified and route templated while the median stays
healthy, untimed responses cannot masquerade as the slowest, empty state gives
null timestamps rather than epoch 1970, errors counted, and the blob carries ISO
timestamps.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Manshu48 and others added 10 commits August 25, 2026 01:33
test(ttscache): assert the English branch with a language the code re…
…just Rumik

Shiksha Nation's name comes out "shiksha-NAI-shan" on smallest_pro. Spelling it
in Latin in the prompt did NOT fix it: the model composes Hindi and
transliterates the name straight back. That is the third time in this codebase a
prompt rule has lost to the model on exactly this problem - normalize_for_rumik
exists because "prompt rules failed to stop the transliteration twice".

That fix was gated to Rumik, so an agent on any other engine got nothing.
normalize_for_speech now applies to ALL of them, at the text handed to the
vendor, where the model cannot reach it. Transcripts, LLM context and the report
keep the written form, so the report still shows what the model actually
composed.

Hooked at _tag_engine - the one point every engine already passes through -
rather than per vendor: five services with five different internals, and a sixth
added later would silently miss out. run_tts receives whole SENTENCES (pipecat
aggregates before calling it), so multi-word keys match; hooking the token
stream further upstream would split them.

EMPTY by default and inert when empty, so no existing agent changes. Tuned by
env (SPEECH_TERM_MAP="from=to;from=to"), longest-key-first, so a spelling can be
corrected on the box without a deploy - which matters because finding the
spelling a vendor pronounces correctly is a listening exercise, not a code one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…odel

Both the call path and the preview resolve the Smallest model like this:

    sm_model = s.smallest_model          # lightning_v3.1
    if ":" in model:                     # "smallest:<model>"

The engine key actually stored on an agent is "smallest_pro" - an underscore,
not a colon - so that branch never fired and a _pro agent silently got the
STANDARD model. Smallest hard-rejects a cross-model voice, and every _pro voice
(mrunal, manasi, ketaki, meher) exists ONLY on _pro.

Proven through the live preview endpoint:
    smallest / devansh       -> 200, 38 KB of audio
    smallest_pro / mrunal    -> 502
    sarvam, google           -> 200
Same code resolves the model on a call, so Shiksha Nation's agent - tts_model
smallest_pro, voice mrunal - has been handing the vendor a voice that does not
exist on the model it was asked for. TtsVoiceCatalog already warned this "on a
live call means silence".

Now the _pro suffix selects the _pro model, in both paths. The colon form still
wins where it is used, so nothing that relied on it changes.

This also makes the voice tester usable for that agent, which is the only way to
audition a spelling the vendor pronounces correctly - the open half of the
"Shikshanation" mispronunciation, which prompt changes have now failed to fix
twice.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
fix(white-label): serve the institute favicon, not the Vacademy mark
…t value

/preview.mp3 caps model at 24 characters. The one form that needs the cap
raised is "smallest:lightning_v3.1_pro" - 27 characters - which is the exact
escape hatch the code documents for this case: "pass the model through: an
admin auditioning a _pro voice must hit _pro, or the preview lies."

So it 422'd before reaching any vendor, and the only supported way to audition a
_pro voice could never be used. Raised to 48. Still capped, because this is a
public endpoint that spends vendor credits; just no longer shorter than a value
it is meant to accept.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…TY_SERVICE_BASE_URL env var

CommunityAppRegistryClient's @value("${community.server.baseurl}") had no matching
property anywhere, so it silently fell back to its localhost default in every
deployed environment — every institute's app-status lookup was failing closed
(empty list, never an error) because admin_core_service could never actually
reach community_service. Bridges it the same way every sibling client
(assessment/auth/media/notification) already does per environment.
…rvice-baseurl

fix(app-status): wire community.server.baseurl for the app-status feature
feat(ai-calling): gate the call Queue tab behind Display Settings, of…
neerajhariyale and others added 4 commits August 26, 2026 14:03
HCCA reported a missing recording. The recording was never missing: the class
had never started. Several faults stacked up between the teacher pressing
"Start as Host" and a file reaching the platform.

1. link_type case. The admin dashboard matches schedule linkType against
   StreamingPlatform literals case-sensitively ("zoom", "google meet").
   getLinkTypeFromUrl returned the ENUM names, "ZOOM" and "GMEET", which match
   nothing — so Start-as-Host degraded to a plain <a href> on the LEARNER url.
   The teacher joined as an attendee, never claimed host role, the meeting never
   began and Zoom recorded nothing. Now emits the canonical frontend literals.
   The backend is unaffected: it reads this field through
   MeetingProvider.fromString(), which upper-cases and accepts both spellings.

2. Dead host link. provider_host_url stores a Zoom start url whose ZAK lives
   ~2 hours but is written once, at scheduling time. Every occurrence of a
   recurring session shipped a link that expired the same morning it was made.
   Only the SDK path re-minted it; the redirect path served the stale value.
   getSessionLinks now re-mints per read and falls back to the stored url.

3. Recordings addressed by numeric meeting id return only the LATEST instance,
   so a day the hourly poll missed was lost once Zoom's ~30-day retention
   lapsed. The sweep now walks /past_meetings/{id}/instances and fetches each by
   UUID (double-encoded — verified against the live API: a UUID containing "/"
   404s single-encoded, one without special characters resolves identically
   either way), newest 30, logging when capped.

   Two consequences of reaching back over old instances had to be handled:

   - One meeting is reused for a whole recurring series, so its instances span
     several days while a schedule row is ONE occurrence. Unscoped, the sweep
     stacks the entire series onto whichever row holds the meeting id and
     duplicates files their own rows already hold. Recordings are now kept only
     when they fall on that row's date, compared in the session's timezone
     (meeting_date is local, startTime is UTC — a 09:30 IST class stamps 04:00Z).
     A row whose date has no instance takes nothing rather than inheriting a
     sibling's class.

   - expiresAt was stamped now+30d, right only for a recording made moments ago.
     Zoom deletes 30 days after the recording was MADE and the near-expiry S3
     rescue only mirrors what falls due within days, so an old instance would
     have looked weeks from expiry, the rescue would never have fired, and the
     file would have been deleted before it was ever copied to our storage.
     Expiry now counts from the recording's own start time.

4. No @SchedulerLock on the five live-session jobs, so all four replicas hit
   Zoom at once and earned 429s that silently zeroed a sync round. ShedLock was
   already wired and used by 22 other jobs; these were simply never annotated.

Also stop the edit path re-pointing a provider-managed occurrence at a DIFFERENT
meeting of the same provider. The DTO carries one link per weekday, so applying
it blindly sent every later occurrence of that weekday to the FIRST occurrence's
meeting while provider_meeting_id kept its own — host and learners in different
rooms, recording where nobody was. Deliberately narrow: switching provider,
clearing the link, or passing this row's own meeting all still apply, because a
guard that silently swallows a legitimate edit is worse than the bug.

Two pre-existing test classes were failing before any of this: each was missing
a @mock for a dependency added to the class after the test was written.

Tests: 111 pass across live_session. The remaining 13 failures + 1 error in the
module (BulkCourseServiceTest, MediaServiceApplicationTests) reproduce
identically on a pristine main worktree — pre-existing, untouched by this work.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ccurrences

A schedule row is created before its meeting link exists — the link is minted
during provisioning — so URL sniffing stamps linkType "UNKNOWN". Provisioning
then only replaced a null or blank value, and "UNKNOWN" is neither, so it was
mistaken for a deliberate choice and left in place permanently.

Rows stuck on it are broken at both ends. The dashboards match linkType against
StreamingPlatform literals, so "Start as Host" falls through to a plain <a href>
on the LEARNER url and the teacher joins as an attendee without host role. And
on the server MeetingProvider.fromString("UNKNOWN") throws, so strategy
resolution fails outright. 30 upcoming Google Meet occurrences were in exactly
this state across Spark Education, The Learning Bridge and Topmate.

Provisioning now treats "UNKNOWN" as unset and writes the literal the dashboards
actually match, rather than the enum name — storing "GOOGLE_MEET"/"ZOOM_MEETING"
is the same case-sensitivity trap, accepted by some frontend branches and
silently ignored by others. The backend is unaffected either way, since
MeetingProvider.fromString normalises.

A genuine choice the wizard already made ("bbb", "zoom", "youtube") is still
preserved and never overwritten.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Manshu48 and others added 2 commits August 26, 2026 14:49
Every enqueue failed on staging:

  column "id" is of type uuid but expression is of type character varying

V472 declared ai_call_queue.id and ai_voice_box.id as UUID, but both entities
map that id as a Java String annotated @uuidgenerator -- the same shape every
other telephony entity uses -- so Hibernate binds varchar, and Postgres will not
implicitly cast varchar to uuid in an INSERT. The drain job's claimForDispatch
(WHERE id = :id) would have failed identically once anything reached the queue.

Only these two columns deviated from the convention; telephony_call_log (V319),
ai_calling_config (V344) and the rest all use VARCHAR(36) PRIMARY KEY. The rest
of the V472 schema binds correctly -- the timestamps and varchars in the failing
statement all went through fine.

Fixed forward rather than by editing V472, which has already run: rewriting an
applied script breaks Flyway checksum validation everywhere it landed. The
DEFAULT is dropped with the type -- it only ever served V472's own seed row, the
application always supplies its own id, and gen_random_uuid() cannot default a
varchar column.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
fix(ai-calling): queue primary keys are VARCHAR, not UUID
Manshu48 and others added 2 commits August 26, 2026 15:47
…actually dialled

Two things made the page look broken while it was working.

The default filter was QUEUED, which is empty exactly when calling is healthy.
A manual click dials the moment a line is free, so it goes straight to DIALED
and never "waits" -- a call only sits in QUEUED when every line is busy. So the
table was blank while the stat card correctly said one call was in progress.
Adds an ACTIVE filter (waiting + already dialling), now the default and what
"the queue" means to a person; ALL is still there for the finished history.

The LEFT JOIN in that query is load-bearing: a QUEUED row has no call_log_id
yet, and an inner join would drop precisely the rows the queue is named after.

The Lead column showed a raw user id. A queued row usually has no phone of its
own -- the manual click and the CALL_AI node pass only a lead id, and the number
is resolved downstream at dial time -- so the row's phone_number is NULL. The
call-log lookup that already resolves live state now carries to_number too, and
it fills that column when the queue row has nothing.

All four queries were executed against the real database before committing, not
just compiled.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
fix(ai-calling): queue page defaults to Active, and shows the number …
Manshu48 and others added 2 commits August 26, 2026 17:32
…lth dashboard from super-admin

/internal/ai-queue/snapshot returned exactly the payload
/super-admin/v1/ai-queue/overview already returns, from exactly the same
assembler. All the separate route bought was a second authentication path to
maintain and a client_secret_key row to insert in every environment -- which is
what it actually cost: a 401 on first use.

The justification for it was that the health dashboard is a daemon and would
otherwise need a login/refresh loop and an access path tied to a person's
account. Real, but thin next to the downside, and the mechanism was weaker than
it looked: despite the header being called Signature, InternalAuthFilter does a
verbatim equality check against a static secret that never expires and never
rotates. A short-lived root JWT is the stronger credential, not the weaker one.

Adds instituteId to /overview so nothing is lost -- it was the only parameter
the internal route had that the super-admin one did not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
refactor(ai-calling): drop the internal queue endpoint, serve the hea…
neerajhariyale and others added 13 commits August 26, 2026 20:20
Basket pricing prices a basket as a whole ("any 3 for 799") instead of
summing item prices, for catalogues whose courses carry no individual
price. Offers add "99 off above 500" rules, applied automatically with
the best rule winning and never stacked. Coupons gain a minimum-courses
condition. All three are recomputed server-side, because the enrol call
overwrites whatever amount the client sends; each is a separate payment
line item so the log explains the total. With none of them configured
the total is unchanged.

Any form field can now require a WhatsApp OTP before submit, authored in
the shared custom-field dialog so campaigns, live sessions, enquiries and
invites all get it, and honoured by the product-page checkout and the
catalogue lead form. It reuses the existing generic OTP endpoints and is
off unless a field asks for it.

Also fixes, all found while building the above:

- a WhatsApp send that Meta refused still reported "OTP sent", so a code
  that never arrived looked exactly like success
- the learner's own name/email/phone were matched by field label, so
  "School Name" could become the learner's name, and the first match won
  over the first answer, which could enrol someone with an empty email
- duplicate field keys drew several inputs bound to one value
- campaign fields dropped their config on save, so help text and file
  limits never survived; and once they did persist, clearing the last
  setting fell through to the stale copy instead of clearing
- the catalogue basket bar summed item prices while checkout used the
  ladder, quoting two different totals one screen apart

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ffers-and-otp-verification

Feat/product page pricing offers and otp verification
…i-provider

feat: per-institute store credentials, replacing the single shared Ap…
50dcaea landed MobileActionBar's import of
`@/hooks/use-signup-availability` without the hook itself, so the module
was resolvable only on the authoring machine. Every build from a clean
checkout died at the vite load step with ENOENT.

The hook in turn needs a pre-login read of the institute's display
settings: `getStudentDisplaySettings()` resolves the institute via
`getInstituteId()`, which requires an access token, so on the public
catalogue it silently returned DEFAULTS and `signup.enabled: false`
never took effect. `getPublicStudentDisplaySettings()` takes the
institute explicitly and goes through the `/open/**` endpoint, memoised
per institute and deduped in flight, falling back to defaults on any
error so a public page cannot break on a settings read.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
That PR committed OrderSummaryPanel and everything it shows — the item
list, the basket-pricing breakdown, the offers strip and the totals — but
not CheckoutLayout, which is the only thing that renders it. The result
on the live cart step is a heading and a coupon box with no items, no
prices and no total.

Brings across the rest of the checkout frame it depends on: the step
progress bar, the catalog step, the page renderer and the shell.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
PlanTiles only renders when a page sells the same thing several ways —
two or more distinct plans across the selected courses — which most pages
do not. The subtitle told every visitor to "pick the plan that suits you"
regardless, pointing at nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
wholeGroupPrices keys the pack price by how many subjects are in the
basket, which cannot express iThinkers' card: it prices Class 3-4 at
1,199 for "6 subjects" and Class 5-8 at 1,499 for "8", while the
catalogue actually carries 5 and 6 because Financial Literacy does not
exist yet. Keying by count also breaks silently the moment a class gains
a subject and lands on another class's price.

A group can now carry its own packPrice, which is exact and survives the
catalogue changing shape. The count map stays as the fallback.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@neerajhariyale
neerajhariyale deployed to vet-education August 26, 2026 16:59 — with GitHub Actions Active
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants