Hindi Daily Word — Voice-Driven Vocabulary Tutoring¶
Status: Shipped on staging (2026-04-29). First-tenant org
Hindi-Tutoringrunning 1 active learner end-to-end. Org: Hindi-Tutoring (9dede1af-c88d-454d-b2a5-d837788a71c8) Feature Name: Hindi Daily Word
What¶
A daily voice tutoring service that:
- At 00:00 IST, picks the next-untaught Hindi word for each active learner using SM-2 spaced repetition.
- Schedules an outbound call to the learner at their preferred time (default 09:00 IST).
- The Hindi-tutor bot greets the learner, teaches one word + transliteration + meaning + example, listens for the learner to repeat it, praises with "Shabaash!" + a brief farewell, and hangs up.
- Sends a WhatsApp recap message with today's word — either "you learned X" or "you missed X" depending on call outcome — including for calls that never connected (no answer, busy).
Designed as a minimal voice-tutoring template that can be extended to other subjects (multiplication tables, vocabulary in any language, dietary reminders, medication adherence, etc.) by swapping the bot module's _lesson shape and the WhatsApp template.
Why¶
- Real-life daily-habit reinforcement. A scheduled outbound call + WhatsApp summary is a stickier learning loop than an app the learner has to open.
- Validates Astradial's full bot stack end-to-end. Hindi Daily Word exercises every layer of the platform — workflow scheduler, AstraPBX originate-to-ai, NUC SIP forwarding, pipecat-flow gateway, Gemini Live, WhatsApp via MSG91 — in a self-contained vertical.
- Tenant template. The org-as-tenant pattern (Hindi-Tutoring is one org out of many) means future verticals reuse the same scheduling + voice + WhatsApp infrastructure with org-scoped data.
Architecture¶
┌────────────────────────────────────────────────────────────────────┐
│ Cloud VPS (94.136.188.221, staging) │
│ │
│ ┌─────────────────┐ ┌──────────────────┐ ┌────────────────┐ │
│ │ workflow-engine │ │ astradial-editor │ │ pipecat-flow │ │
│ │ :3002 / Bull │ │ :3001 / Next.js │ │ :7860 / FastAPI│ │
│ └────────┬─────────┘ └─────────┬─────────┘ └────────┬───────┘ │
│ │ │ │ │
│ │ fires workflow │ bot webhooks │ loads │
│ │ trigger │ (today-lesson, │ bots/ │
│ ▼ │ mark-progress, │ hindi_ │
│ ┌─────────────────┐ │ call-log, │ teacher/ │
│ │ AstraPBX │◀──────────────┘ whatsapp) │ │
│ │ :8000 (Node) │ │ │
│ └────────┬─────────┘ │ │
│ │ originate-to-ai │ │
│ ▼ │ │
│ ┌─────────────────┐ WireGuard │ │
│ │ Asterisk │ ─────────────────────────────────────▶│ │
│ │ PJSIP/AMI/ARI │ 10.10.10.3 → NUC at 10.10.10.1 │ │
│ └─────────────────┘ │ │
│ │ │
│ shared MySQL (pbx_api_db) + Firebase Firestore │ │
│ ───────────────────────────────────────────── │ │
│ organizations, sip_trunks, did_numbers, │ │
│ pipecat_bots, pipecat_org_config (MySQL) │ │
│ hindi_learners, hindi_words, hindi_word_progress│ │
│ hindi_lesson_queue, hindi_call_logs (Firestore) │ │
└─────────────────────────────────────────────────────────────────────────┘
External services:
┌──────────────────┐ ┌────────────────────────┐ ┌────────────┐
│ NUC (on-prem) │───▶│ Tata Communications NNI │───▶│ Learner's │
│ Asterisk + │ │ SBC 10.79.215.102:5060 │ │ phone │
│ WireGuard tunnel │ │ │ │ │
└──────────────────┘ └────────────────────────┘ └────────────┘
┌──────────────────┐
│ MSG91 WhatsApp │ POST control.msg91.com/api/v5/whatsapp/...
│ Business API │ per-org authkey + integrated number
└──────────────────┘
Daily call lifecycle¶
00:00 IST workflow-engine Firestore
───────── ────────────────── ──────────
Bull cron fires ───────▶ runDailyQueueForOrgs(...)
│
├─ For each active learner:
│ ├─ Pick word via buildLessonForUser
│ │ (lowest unseen frequency_rank,
│ │ plus due review words)
│ ├─ Insert hindi_lesson_queue row ──▶ pending
│ └─ POST /trigger/<workflow_id>
│ with {name, phone, learner_id,
│ lesson_id, scheduled_date,
│ scheduled_time}
│
▼
workflow-engine schedules
place_call at scheduled_call_time
(per learner's preferred_call_time)
At scheduled time workflow-engine AstraPBX
───────────────── ────────────────── ─────────
scheduled job fires ───────▶ place_call executor
│
├─ POST /api/v1/calls/originate-to-ai
│ {to: 9042321214, caller_id: 918065978021,
│ wss_url: ws://localhost:7860/...,
│ variables: {WORKFLOW_BOT_ID, learner_id,
│ org_id, lesson_id, ...}}
│
▼
AstraPBX → AMI Originate
│
└─ PJSIP/<phone>@<trunk>
▼ (Asterisk)
PJSIP INVITE to NUC (10.10.10.1)
→ NUC dialplan adds 0-prefix → Tata
→ Tata routes to PSTN
→ Learner's phone rings
Phone answers AstraPBX ariClient pipecat-flow
───────────── ──────────────────── ────────────
Stasis Start ──────────────▶ handleAiAgentCall(channel)
│
├─ Create mixing bridge
├─ Open WebSocket to wss_url
│ ws://localhost:7860/ws/<org>/<bot>?...
│
├─ Send Twilio-format handshake ────▶ router_ws
│ │
│ ├─ load_bot_module("hindi_teacher")
│ ├─ bot.set_call_metadata(...)
│ │ (loads today's lesson sync via
│ │ today-lesson webhook)
│ ├─ build LLM with full Settings
│ │ (system_instruction, VAD HIGH/HIGH,
│ │ temperature 0.7, voice "Puck")
│ └─ flow_manager.initialize +
│ queue LLMRunFrame (eager greet)
│
├─ Bridge external media channel ◀────▶ Gemini Live audio loop
│ (UnicastRTP) for audio relay
│
└─ Bot teaches the word, listens for repeat
Bot ends pipecat-flow / bot module Editor + Firestore
──────── ───────────────────────── ──────────────────
end_lesson(success | user_requested) tool fires
│
├─ POST /api/hindi/bot/mark-progress ──▶ hindi_word_progress
│ (mastery 5 if success, 2 if user_requested;
│ next_review_at via SM-2 schedule)
│
├─ POST /api/hindi/bot/call-log ──▶ hindi_call_logs
│ (status flips to "completed" or "early_hangup")
│
├─ POST /api/hindi/bot/whatsapp ──▶ MSG91
│ (success template / missed template)
│
└─ asyncio.create_task(_force_end):
├─ sleep 5.5s (success) or 2.5s (user_requested)
│ so bot's last line plays out
├─ queue EndFrame
├─ flow_manager.transport._client.disconnect()
│ ↳ underlying WebSocket force-closed
│
▼ (back to AstraPBX)
ws.on('close') ────▶ bridge.destroy()
hangupChannel(channel.id)
hangupChannel(extChannelId)
↓
SIP BYE → NUC → Tata → caller's phone drops
Calls that never connected workflow-engine Editor + MSG91
────────────────────────── ────────────────────────── ───────────────
every 5 min IST ────────▶ missed-sweeper Bull cron
│
├─ Find queue rows:
│ status=pending AND
│ scheduled_call_time < (now - 5 min)
│
├─ For each: POST /api/hindi/bot/whatsapp
│ outcome=missed ──▶ MSG91
│
└─ Flip queue row status to "missed"
(idempotent — won't re-attempt next tick)
Components¶
Editor (editor/)¶
| File | Purpose |
|---|---|
app/dashboard/[orgId]/learn/page.tsx | Learners list + dropdown actions: Edit, Call now, Deactivate/Reactivate |
app/dashboard/[orgId]/learn/{new,[id]/edit,lessons}/page.tsx | Learner CRUD + today's lesson queue view |
components/layout/app-sidebar.tsx | "Learn" sidebar section gated to the Hindi-Tutoring org id |
app/api/hindi/learners/route.ts + [id]/route.ts | POST/PATCH learner via firebase-admin (Firestore client-SDK writes blocked by security rules) |
app/api/hindi/bot/today-lesson/route.ts | Returns today's primary + review words for a learner. Read-only. |
app/api/hindi/bot/mark-progress/route.ts | Updates hindi_word_progress with SM-2 next_review_at schedule |
app/api/hindi/bot/call-log/route.ts | Inserts hindi_call_logs row, transitions queue row to terminal status |
app/api/hindi/bot/whatsapp/route.ts | Sends recap via MSG91 with success/missed template |
lib/hindi/{repos,types,admin}.ts | Client-SDK reads + admin-SDK writes |
lib/workflow/client.ts | hindi.runDailyQueue and hindi.triggerForLearner workflow-engine clients |
All bot webhooks use X-Internal-Key matching INTERNAL_API_KEY env. The learners CRUD routes are gated by Cloudflare Access on stageeditor.astradial.com.
Workflow-engine (workflow-engine/src/hindi/)¶
| File | Purpose |
|---|---|
admin.js | firebase-admin client targeting the misssellerai project. Reads FIREBASE_ADMIN_KEY_PATH env. |
lessonBuilder.js | Pure-logic SM-2 helpers. buildLessonForUser(learner, allWords, progress, today) returns the lesson row to insert. |
runDailyQueue.js | runDailyQueueForOrg: builds lessons + fires workflow trigger. triggerForLearner: one-off "Call now" path that always builds fresh + supersedes any pending row. Also normalises Indian phone numbers to 10-digit local format before sending to the NUC dialplan. |
missedSweeper.js | sweepMissedForOrg: finds pending rows past scheduled_call_time + 5 min grace, sends missed WhatsApp via the editor route, flips status to missed. |
index.js | wireHindi(app, queue) + registerHindiCron(queue). Two Bull repeatables: daily-builder (00:00 IST) and missed-sweeper (every 5 min IST). HTTP routes: POST /hindi/run-daily-queue, POST /hindi/trigger-for-learner, POST /hindi/run-missed-sweeper. |
Workflow template (workflow-engine/templates/hindi_daily_lesson.json)¶
Webhook-triggered workflow with one place_call node. bot_id and wss_url are patched into the workflow row after instantiation (see setup guide). place_call uses timing: "scheduled" so the workflow engine delays the dial to {trigger.scheduled_time}.
Pipecat-flow gateway (pipecat-flow/)¶
| File | Purpose |
|---|---|
bots/hindi_teacher/__init__.py | Single-node freeform Gemini Live tutor. Mirrors ~/pipecat-quickstart/bots/hindi_teacher_bot.py (the local reference): same prompt, same end_lesson(reason) schema, same voice "Puck". Loads today's lesson synchronously in set_call_metadata (before LLM is built). Force-closes the WebSocket from the handler to bypass pipecat's 30-second EndFrame deferral. |
gateway/pipeline.py | Two opt-in hooks for module bots: (1) calls bot_module.set_call_metadata(call_metadata) before LLM construction so the bot can load per-call data, (2) reads bot_module.get_system_instruction() and passes it as Gemini Live's first-class system_instruction along with VAD HIGH/HIGH 200/400 ms + temperature 0.7. Also queues LLMRunFrame() on on_client_connected for opted-in bots so the bot starts speaking immediately rather than waiting for VAD. Existing JSON-flow bots / hotel_concierge are unaffected. |
gateway/router_ws.py | Already-existing WS handler. We injected the path-level org_id into extra_metadata so it's available in call_metadata (purely additive). |
AstraPBX (api/src/services/asterisk/ariClient.js)¶
handleAiAgentCall.ws.on('close') now does three parallel teardowns when the bot's WebSocket closes: bridge.destroy(), hangupChannel(channel.id), hangupChannel(extChannelId). Each is independently .catch'd. Without this, the SIP channel stayed up 22+ seconds after the bot ended and the caller heard silence until they hung up themselves.
Data model¶
Firestore (per-org under astrapbx_stage/{org_id}/ on staging, astrapbx/{org_id}/ on prod)¶
| Collection | Schema |
|---|---|
hindi_learners | { name, phone_number, preferred_call_time (HH:MM), timezone, active, current_streak, longest_streak, last_successful_call_at, consent_recorded, created_at } |
hindi_words | { hindi_word, transliteration, english_meaning, frequency_rank, difficulty_tier, category, example_sentence_hindi, example_transliteration, example_english, pronunciation_audio_url, part_of_speech }. Word ids are stable strings like rank_0001, rank_0002. |
hindi_word_progress | { user_id, word_id, mastery_score (0–5 SM-2), times_taught, first_taught_at, last_reviewed_at, next_review_at } |
hindi_lesson_queue | { user_id, scheduled_date, scheduled_call_time, primary_word_id, review_word_ids[], status, attempt_count, created_at, missed_at?, whatsapp_missed_sent? }. Status: pending → dialing → in_progress → completed / early_hangup / missed / failed. |
hindi_call_logs | { user_id, lesson_id, started_at, ended_at, duration_seconds, outcome, transcript_ref, pipecat_session_id, words_actually_covered[], user_performance_json } |
hindi_user_memory | (optional, future) { user_id, rolling_summary, last_call_summary, total_calls_completed, updated_at } |
MySQL (pbx_api_db)¶
The Hindi feature reuses the existing platform tables. Per-org rows for the Hindi-Tutoring tenant:
organizations.id = 9dede1af-c88d-454d-b2a5-d837788a71c8organizations.settings.msg91_authkey(JSON) — MSG91 account authkeyorganizations.settings.ticket_whatsapp.sender_number— registered MSG91 integrated numberpipecat_org_config— Google Gemini API key for this org's botspipecat_bots— one row for the Hindi Teacher bot,module_path="hindi_teacher", voice"Puck"did_numbers— at least one DID assigned withis_default=1(currently+918065978021)sip_trunks— one row per org, host pointing at NUC's WireGuard IP
workflow-engine postgres¶
workflows— one row per org instantiated from thehindi_daily_lessontemplatescheduled_jobs— Bull-backed scheduledplace_calljobs for each lesson queue row
SM-2 spaced repetition¶
buildLessonForUser (in workflow-engine/src/hindi/lessonBuilder.js) is pure logic, no I/O:
- Primary word: lowest
frequency_rankamong words the learner hasn't been taught yet (hindi_word_progressdoesn't contain the word_id). - Review words: up to 3, drawn from
hindi_word_progressrows whosenext_review_at <= today, ordered by weakest mastery first. - Schedule:
scheduled_call_time = learner.preferred_call_timeinlearner.timezone, that day's date.
mark-progress schedules next_review_at based on SM-2 buckets:
| mastery_score | next_review_at |
|---|---|
| 0 (didn't try) | +1 day |
| 1 | +1 day |
| 2 (hesitant / needed help) | +3 days |
| 3 | +7 days |
| 4 (clear / remembered cleanly) | +14 days |
| 5 (perfect first try / instant recall) | +30 days |
The bot maps end_lesson(reason) → mastery: success→5, user_requested→2.
Known limitations¶
- Initial speech latency — caller hears ~2–3 s of silence after pickup before the bot greets. Most of that is Gemini Live session setup + first-TTS-token roundtrip to Google. Pre-warming the LLM session before the call answers would shave most of it but requires a pipecat-flow gateway architecture change.
Pipeline completed normallymasks failed executions — the workflow_executions table recordsfailedfor the immediate-execution path ontiming: "scheduled"nodes (the immediate runner sees the node deferred and propagates failure). Cosmetic only; the deferred run executes fine.- Sidebar "Learn" entry is hardcoded to the Hindi-Tutoring org id in
app-sidebar.tsx. If a second tutoring-style org onboards, generalise to a per-org feature flag. - Missed template was PENDING Meta approval at ship time — the missed path API call returns ok but Meta gates final delivery until approval. Approved once status flips in the MSG91 dashboard; no code change needed.
- Phone normalisation is India-specific —
runDailyQueue.fireWorkflowstrips+91/91/ leading0. Generalise if non-Indian learners onboard. - MSG91 authkey is per-org but template name is global — env vars
MSG91_HINDI_SUCCESS_TEMPLATE/MSG91_HINDI_MISSED_TEMPLATEapply to all orgs. Move to per-org settings if templates diverge across tenants.
Related guides¶
- Setup runbook: Hindi Daily Word — Setup Guide
- Bot architecture: AI Bot Setup
- Underlying gateway: Pipecat Bot Gateway
- WhatsApp infra: WhatsApp Bridge
- DID + outbound CID: DID Management, Outbound Caller ID