Merge pull request #2647 from Vacademy-io/feature/tts-speech-cache - #2648
Open
Manshu48 wants to merge 127 commits into
Open
Merge pull request #2647 from Vacademy-io/feature/tts-speech-cache#2648Manshu48 wants to merge 127 commits into
Manshu48 wants to merge 127 commits into
Conversation
"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>
Feature/tts speech cache
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…
Contributor
Not up to standards ⛔🔴 Issues
|
| Category | Results |
|---|---|
| Compatibility | 4 medium |
| UnusedCode | 1 medium |
| BestPractice | 23 medium |
| ErrorProne | 3 medium 54 high |
| CodeStyle | 11 minor |
| Complexity | 4 medium |
🟢 Metrics 2449 complexity
Metric Results Complexity 2449
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…
neerajhariyale
temporarily deployed
to
vet-education
August 24, 2026 12:36 — with
GitHub Actions
Inactive
Deploying frontend-admin-dashboard with
|
| Latest commit: |
faee711
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://1e7fcbcb.frontend-admin-dashboard.pages.dev |
Deploying frontend-learner-dashboard-app with
|
| Latest commit: |
faee711
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://17858224.frontend-learner-dashboard-app.pages.dev |
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>
shreyash-jain
temporarily deployed
to
vet-education
August 24, 2026 14:03 — with
GitHub Actions
Inactive
Feature/tts speech cache
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
…he working domain-routing record
…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
neerajhariyale
temporarily deployed
to
vet-education
August 25, 2026 04:36 — with
GitHub Actions
Inactive
feat(ai-calling): gate the call Queue tab behind Display Settings, of…
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>
…dings Fix/zoom live session recordings
neerajhariyale
temporarily deployed
to
vet-education
August 26, 2026 09:07 — with
GitHub Actions
Inactive
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
…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 …
…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…
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
…p Store Connect key
…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>
Fix/product page cart UI
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Feature/tts speech cache
Summary of Changes
Related Issue
Type of Change
How Has This Been Tested?
Checklist