How the AI pre-fill and coach correction work today; hook points; reuse map
/observe HITL stack, documented for the EGRA/EGMA student-assessment stepRead-only code survey, 2026-09-15. All file:line refs are relative to the fresh main checkout at
/Users/haroonyasin/Documents/Projects/April 2026/Rumi 10 April 2026/.claude/worktrees/dcsweep3-niete-main
(remote Orenda-Project/NIETE-Rumi, HEAD 74720c27). Main-bot refs are prefixed MAIN: and point into
/Users/haroonyasin/Documents/Projects/April 2026/Rumi 10 April 2026/02_Main Rumi Bot.
Headline correction before anything else: there is no "three recordings" structure in the code. A NIETE observation has exactly two coach recordings — one classroom lesson recording (awaiting_audio) and one later coach↔teacher debrief recording (awaiting_debrief_audio) — plus an optional photo gate (up to 3 photos) and an optional lesson-plan link. /remark is a separate principal-evaluation feature (bot/shared/handlers/remark-command.handler.js:1-12), not a third observe recording. A grep for "three recording", "segments", "pre/post-lesson" across bot/shared/services/observe and docs/ returns nothing. The build plan should say "after the classroom recording is received" or "after the observation form is submitted" — those are two different moments with different context available (§4).
/observe| Step | Where | Detail |
|---|---|---|
| Trigger | bot/shared/services/observe/observe-gate.js:15 |
Regex ^\/observe\b on trimmed inbound text. Also a menu row ("HITL") for the leader family: docs/features/role-based-menu.md:15, :137-138. |
| Capability gate | observe-gate.js:62 |
Feature is ON iff OBSERVE_MEWAKA_FLOW_ID is set (capability, not geography — bd-48). Unset → message falls through as chat. |
| Role gate | observe-gate.js:25-27, :34-36 |
LEADER_ROLES = ['school_leader','supervisor','coach','principal','aeo']. Anything else → deny_role. |
| Onboarding A/B | observe-gate.js:45-48, bot/shared/handlers/observe-command.handler.js:231-247 |
First /observe persists users.preferences.observe_onboarded then launches the picker. Arm read from preferences.observe_onboarding_arm. |
| Pending work interception | observe-command.handler.js:264-278 |
Before any new capture, pending debriefs + unsent reports + unfinished (pre-form) sessions surface as a WhatsApp list. |
| Visit picker | observe-command.handler.js:31-33, :130-152, :62-71 |
If OBSERVE_VISIT_FLOW_ID is set, a coach gets the school→teacher→brief Flow (flowToken = user.id, no colons — bd-215). Three outcomes: unavailable / declined / launched (:113-152); only unavailable keeps bare capture. |
The picker is bot/shared/handlers/observe-visit-flow.handler.js (1,662 lines, endpoint-driven Flow served at /api/flows/observe-visit, bot/shared/routes/flow-endpoint.routes.js:163-188). Screens: MENU (:366-454), SELECT_SCHOOL (:572-583), SELECT_TEACHER (:586-603), BRIEF / BRIEF_SCHEDULE (:609-645), plus scheduling (SCHEDULE, SCHEDULE_PICKER, CONFIRM_SCHEDULED :558-707), roster admin (ADD_SEARCH, MANAGE_SCHOOLS, TEACHER_* :837-1600) and work-stage lists (WORK_FORM/DEBRIEFS/WORK_SEND :511-547).
The coach selects a school and a teacher — nothing else. No subject, grade, or section is chosen. The "grade" shown on the picker is the roster's free-text level (teacherLevel() :147-148, from leader_teachers.level). On "Start observation", bindAndStart (:729-763) resolves the teacher via LeaderSource.resolveTeacher and arms Redis awaiting_audio with:
boundTeacher = { teacher_ext_id, teacher_name, phone_e164, user_id|null, preferred_language, school_ext_id }
(bot/shared/services/observe/assignment/leader-source.js:441-447; school_ext_id appended at observe-visit-flow.handler.js:743). The Flow's completion arrives as an nfm_reply with observe_visit_action → flow-type-detector.js:119-126 → whatsapp-bot.js:1911-1920 → flow-response.handler.js:717-790.
Recording 1 — the classroom lesson. Any audio from a leader-family user goes through the single router bot/shared/services/observe/observe-audio-router.js:101-190. Both entry points feed it: voice notes (whatsapp-bot.js:2568-2569) and audio documents (:2490-2491, :2517-2518) — a phone-recorder app delivers a 40-minute lesson as a file, and Meta's limits differ (voice 16 MB, document 100 MB: whatsapp-bot.js:2426-2427, bot/shared/services/whatsapp.service.js:885-886). The webhook never carries a duration, so the router probes it itself (getMediaInfo → ffprobe, :41-60). "Classroom-length" = ≥900 s, or unresolvable-but-≥500 KB (:34-35, :114-116).
Decision table (:137-189): state awaiting_audio → ObserveCapture.startFromAudio; state awaiting_debrief_audio → startDebriefFromAudio; nothing armed + declared self-coaching intent → falls through to DC (:169-174, row-122 fix); nothing armed + classroom-length → park and ask "whose observation is this?" (observe-binding.service.js:59, FIFO cap 3, TTL 6 h, sha-dedupe 24 h: :29-32). Invariant: a leader's classroom-length audio never starts teacher coaching.
startFromAudio (bot/shared/services/observe/observe-capture.service.js:56-142) inserts the coaching_sessions row directly at status='confirmed' (no Yes/No confirm), observation_type='leader_observation', observer_user_id=coach, user_id= bound teacher's uuid when bound else the coach (:73-87), queues transcription (:100-101), retires the matching observation_schedules row (:106-113), clears the Redis slot so the next recording is bindable (:120), acks (:121), and if unbound fires the non-blocking "who did you observe?" list (:129-136, observe-who.service.js, which writes an observation_schedules row with session_id rather than a new table :17-25, :111, :225).
Recording 2 — the debrief (coach talking with the teacher after the form). Armed by startDebrief (observe-debrief.service.js:412-497), captured by startDebriefFromAudio (:532-580), processed by the observe_debrief job (:740-875). Detail in §2.4.
Optional gates between recording 1 and analysis: when OBSERVE_CAPTURE_GATES_ENABLED=true the coach gets the photo gate (≤3 photos, classroom-photo/capture.service.js:52) and the LP-link gate, addressed in the coach's language (transcription-processor.service.js:373-404); flag off → analysis queued directly (:377-383).
Worker case transcription (bot/workers/sqs-worker.js:340-347, visibility extended to 20 min for >10-min audio) → TranscriptionProcessorService.processTranscription: download from WhatsApp (transcription-processor.service.js:68-78), upload to R2 (:82-94, §3), Soniox async STT with speaker diarisation (:97, :414-422; primary stt-async-v5, backup stt-async-v4: bot/shared/utils/constants.js:115-116; request body audio.service.js:105-140), Whisper as last resort chunked under 24 MB (bot/shared/services/whisper-chunk-planner.js:1-17). Persists transcript_text, transcript_language, diarization_data, diarization_confidence, tokens_raw, silence_markers, audio_url, audio_size_bytes, backfilled audio_duration_seconds (:226-260). Classroom audio never rewrites the teacher's language (:120-145). The observe branch then calls observePostTranscription (:277-281).
Worker case analysis (sqs-worker.js:349-351) → bot/shared/services/coaching/analysis-processor.service.js. Framework for a leader observation is pinned by pickObservationFramework (observe-gate.js:83-94) to the market pack: OBSERVE_FRAMEWORK=fico → getObservePack() (bot/shared/services/observe/observe-framework.js:203-218, keys ['mewaka','hots','fico'] :31). The call is GPT5MiniService.analyzePedagogy(transcript, metadata, lesson_plan_structured, framework) (analysis-processor.service.js:214-219), model gpt-5-mini-2025-08-07 via OpenRouter (bot/shared/services/gpt5-mini.service.js:380; gateway bot/shared/services/llm-client.js:8-16). Prompt built by fico-framework.js:600-651: 4 scored sections B/C/D/F, 26 indicators, scale 1–4, max 104 (:1-22), returning STRICT JSON:
{ executive_summary, domains: { lesson_plan_fidelity|high_leverage_practices|student_engagement|teacher_subject_knowledge:
{ indicators: [ { id:'C1', score:1-4|null, applicable, evidence, evidence_summary(≤500), improvement } ] } },
strengths[], growth_opportunities[], focus_area{domain,indicator,title,rationale,try_this_tomorrow,lever_question}, recommendations[] }
computeScores (:657-720) derives domain_score and scores.overall_marks/percentage; subject-inapplicable F rows leave the denominator. LP-fidelity runs in parallel and can overwrite Section B (analysis-processor.service.js:205-213, :232-252). Result persisted to coaching_sessions.analysis_data + cost/token columns, status='analysis_complete' (:287-312). Leader observations then branch to ObserveDraft.onAnalysisReady instead of the reflective conversation/report (:318-325).
onAnalysisReady (bot/shared/services/observe/observe-draft.service.js:343-410): flips status='awaiting_observer_review', freezes v1 into autofill_analysis_data exactly once (:349-353), arms Redis awaiting_form unless the coach is mid-debrief elsewhere (:356-366), derives the recipient from the session row not the job's from (:368-391, bd-wwcgf), and sends the Flow with flowToken = "${observerId}:${sessionId}" (:396-403).
The form is a WhatsApp Flow (JSON v6.3, data_api_version 3.0), tracked at bot/docs/flows/observe-fico-flow.json, generated from the framework module by bot/scripts/generate-observe-flow-json.js:1-30. Screens DOMAIN_B ("FICO Review 1/4"), DOMAIN_C, DOMAIN_D, DOMAIN_F, SUCCESS. Per indicator: RadioButtonsGroup r_<id> (options bound to ${data.scale}), TextArea ev_<id> (Evidence), TextArea imp_<id> (To improve). Prefill per screen from buildScreenPrefill (observe-draft.service.js:268-337): s_<id> clamped to the pack's scale (:56-64), e_<id> = evidence_summary word-clipped to 600 code points (:29, :137-143), i_<id>. Section B under OBSERVE_FICO_FLOW_HAS_FIDELITY=editable swaps the B indicators for per-move fidelity slots mv_k / fr_k / fe_k (≤12, :118, :304-322).
Endpoint POST /api/flows/observe-mewaka (bot/shared/routes/flow-endpoint.routes.js:139-158; legacy name, path generic) → handleObserveMewakaRequest (bot/shared/routes/observe-mewaka-endpoint.js:84-145). Token → session ownership check (:46-58). INIT serves the pack's first screen prefill (:93-102); BACK re-serves prefill (:104-112); each data_exchange buffers that screen's r_/ev_/imp_/fid_ fields in Redis observe:edits:<sessionId> (TTL 2 h, :26-27, :61-78); the last screen's submit calls applyObserverEdits and returns SUCCESS with extension_message_response.params.observe_action='submitted' (:119-131). Redis loss degrades to "no edit recorded" for lost screens (:11-14). Contract: never return version; every key must be declared on the screen (:4-6).
The nfm_reply webhook carries observe_action → bot/shared/utils/flow-type-detector.js:110-111 → 'observe' → whatsapp-bot.js:1887-1910: send submitted_ack, clearStateAfterSubmit (guarded so a live debrief for another session is not clobbered, observe-debrief.service.js:360-373), then the Debrief now / later buttons (buildDebriefChoiceButtons :71-81).
| What | Where | Ref |
|---|---|---|
| AI v1 (frozen once) | coaching_sessions.autofill_analysis_data (JSONB, whole analysis) |
observe-draft.service.js:349-353 |
| Coach v2 (edited, rescored) | coaching_sessions.analysis_data — same shape, scores recomputed by the pack, Section B re-applied |
:417-489, :462-463 |
| Edit audit | analysis_data.observer_edit_summary = { indicators_rescored, text_fields_changed, fidelity_verdicts_changed, fidelity_evidence_changed, edited_at } |
:465-472 |
| Who edited | implied by coaching_sessions.observer_user_id (only the token owner can submit, observe-mewaka-endpoint.js:56-57) |
— |
| Status | status='observer_review_complete' |
:482-484 |
| Race guard | re-reads observer_debrief at write time so a resubmission can't drop worker-merged debrief data |
:474-480 |
There is no per-field diff table and no timestamp per field — the diff is recomputable (v1 vs v2) but only the counts are stamped. Note also that observation_type, observer_user_id, debrief_status, autofill_analysis_data are live columns that are absent from the repo schema and sit as known-red offenders in tests/baseline.snapshot.json:116-120 (column-completeness guard). Any new column for the assessment must not repeat this: put it in infrastructure/supabase/00_complete-schema.sql and a V1.x migration together.
startDebrief (observe-debrief.service.js:412-497): builds a 6-step guide from v2 with GPT5MiniService.completeJson (:455-458; gpt5-mini.service.js:646-661, the helper whose absence once produced "completeJson is not a function" on 0/48 debriefs — :650), validates, falls back deterministically, arms awaiting_debrief_audio {sessionId, guide_snapshot} (:489-491). startDebriefFromAudio (:532-580) merge-writes analysis_data.observer_debrief = { audio_id, audio_mime, guide_snapshot, recorded_at, transcript:null, … attempts:0 } (:555-568) and queues observe_debrief — never queueTranscription, whose processor would overwrite the lesson transcript (:526-530). processDebriefRecording (:740-875): downloads the WhatsApp media, transcribes with DEBRIEF_ROLES (coach/teacher, :797-812), persists the transcript before the LLM pass (:829-834), generates feedback (:843-857), delivers praise + rendered card (_deliverCoachFeedback :582-620) and flips debrief_status='done' (:612). Transcription failure is recorded on the row and retried by the sweep, not by SQS (:813-821).
observe-send.service.js: delivery state is analysis_data.teacher_delivery { teacher_name, teacher_phone, status, report_key, report_url, companion_text, notes, sent_at, template_sent_at, tapped_at } (:12-16); worker job observe_teacher_report (sqs-worker.js:362-369) renders the hero PNG from v2, uploads observe-reports/<sessionId>.png (:695-698), then direct-sends inside the 24-h window or sends the approved observation_report UTILITY template and waits for the teacher's tap (:752-785). On status='sent' it calls maybeCompleteObservation (:793-794).
Redis slot observe:state:<userId> → {state, …extra, updatedAt}, TTL 2 h (bot/shared/services/observe/observe-state.service.js:4-14). DB truth lives on coaching_sessions.status, debrief_status, analysis_data.teacher_delivery.status.
stateDiagram-v2
direction LR
state "Redis slot (per coach, TTL 2h)" as R {
[*] --> awaiting_pick : /observe + picker sent
awaiting_pick --> brief_shown : BRIEF screen
brief_shown --> awaiting_audio : Start observation (bindAndStart)
[*] --> awaiting_audio : bare capture (no picker)
awaiting_audio --> [*] : startFromAudio clears slot
[*] --> awaiting_debrief_pick : parked audio + list
[*] --> awaiting_form : onAnalysisReady (informational)
awaiting_form --> [*] : form submitted
[*] --> awaiting_debrief_audio : Debrief now
awaiting_debrief_audio --> [*] : debrief audio queued
}
state "coaching_sessions.status" as S {
confirmed --> transcribing
transcribing --> transcription_complete
transcription_complete --> awaiting_photo : gates on
awaiting_photo --> awaiting_lesson_plan
transcription_complete --> analysis_complete : gates off
awaiting_lesson_plan --> analysis_complete
analysis_complete --> awaiting_observer_review : v1 frozen, Flow sent
awaiting_observer_review --> observer_review_complete : applyObserverEdits (v2)
observer_review_complete --> completed : debrief done AND report sent
transcribing --> failed
analysis_complete --> failed
}
Refs: awaiting_pick observe-command.handler.js:70; brief_shown observe-visit-flow.handler.js:627; awaiting_audio :740; awaiting_debrief_pick observe-binding.service.js:171; awaiting_form observe-draft.service.js:365; awaiting_debrief_audio observe-debrief.service.js:489; DB statuses observe-capture.service.js:81, transcription-processor.service.js:60, :237, :400-401, analysis-processor.service.js:305, observe-draft.service.js:349, :483, observe-completion.js:34-40, :57-61.
"Observation complete" is emitted in bot/shared/services/observe/observe-completion.js:48-70 — CAS-guarded flip to completed when status='observer_review_complete' AND debrief_status='done' AND teacher_delivery.status='sent' (:34-40), fired from observe-send.service.js:793-794 and from the debrief done-flip. It is bookkeeping, may happen days after the visit, and often on a worker whose from is the teacher's tap — not a good place to talk to the coach.
Sweepers and timeouts (all on the always-on sqs-worker, NIETE has no Railway Cron):
| Job | Interval / bounds | Ref |
|---|---|---|
| Resume kinds: gate / form / retry / wait | form older than 14 d → retry; retries ≤2; 30-min-silent pipeline = stuck | observe-debrief.service.js:193-199, observe-resume.service.js:27-28, :41-64 |
| Debrief transcription retry sweep | every 15 min; row ≥30 min old; ≤6 attempts; ≤28 d (Meta media ids die ~30 d); per-row setNX single-flight; OBSERVE_DEBRIEF_RETRY_OFF=1 kill switch |
sqs-worker.js:1285, :1291-1340, debrief-retry-sweep.js:34-36 |
| Stale-session recovery (confirm gate, reflection) | every 15 min; 30 min grace, 25 d ceiling | sqs-worker.js:1450-1474, coaching-stale-recovery.js:15-19 |
| Photo/LP gate auto-advance | 60 min stuck → queue analysis report-only | photo-gate-sweep.js:12-22 |
| Interrupted-task resume offers | every 30 min | sqs-worker.js:1486-1502 |
| Untapped teacher report nudge | one nudge after 24 h, then stop and tell the coach | observe-untapped.service.js:1-22 |
| Observe job failure handling | observe_debrief / observe_teacher_report failures never mark the session failed |
sqs-worker.js:648-654 |
| Artefact | Bucket / key | Metadata | Ref |
|---|---|---|---|
| Classroom recording | R2_BUCKET_NAME (bot/shared/storage/r2.js:25); key classroom_audio/{userId}/{YYYY-MM}/{sessionId}_{timestamp}{ext} |
userId, sessionId, duration, language('unknown'), format('ogg'), uploadedAt |
r2.js:167-195; called with session.user_id — i.e. the observed teacher's uuid when bound, the coach's when not — transcription-processor.service.js:83-92 |
| Row pointer | coaching_sessions.audio_url (public URL ${R2_ENDPOINT}/${bucket}/${key}), audio_format hard-coded 'ogg' even for AAC documents, audio_size_bytes, audio_id (WhatsApp media id) |
transcription-processor.service.js:228-231, r2.js:198 |
|
| Debrief recording | not uploaded to R2. Only the WhatsApp media id is kept in analysis_data.observer_debrief.audio_id; the worker downloads from Meta each time and unlinks the temp file |
observe-debrief.service.js:555-560, :807-809, :873 |
|
| Photos | images/{userId}/{imageId}_{timestamp}.{ext} |
r2.js:860, classroom-photo/capture.service.js:58 |
|
| Teacher report PNG | observe-reports/<sessionId>.png |
observe-send.service.js:695-698 |
|
| Retention / cleanup | none. deleteAudio exists (r2.js:136-151) with zero callers; no lifecycle rule in code; the only cleanup sweep is Soniox-side (sqs-worker.js:1437-1446) |
Implication for EGRA: per-child audio must be uploaded to R2 at intake (the debrief path's "keep only the media id" is the wrong precedent — Meta ids expire ~30 d, coaching-stale-recovery.js:17-19), with a key that carries the child identity, e.g. student_assessments/{school_id}/{session_id}/{student_seq}_{subtask}.ogg.
There are three real hook points; the "third recording is scored" moment the brief imagines does not exist, so pick by where the coach physically is:
| Hook | Where | Coach location | Context on hand |
|---|---|---|---|
| A. Classroom recording received (recommended) | observe-capture.service.js:120-121, right after audio_received ack — analysis takes 2–5 min anyway (observe-strings.js:228) |
Still in the classroom, children present | user (coach row), from, session.id, session.user_id (teacher uuid or coach), boundTeacher {teacher_ext_id, teacher_name, phone_e164, user_id, preferred_language, school_ext_id} or null (unbound → identity arrives later via the who-ask, :129-136), arm |
| B. Form submitted | whatsapp-bot.js:1887-1910 (after submitted_ack, before debrief buttons) |
Back at the office or still at school, minutes to hours later | observerId, observeSessionId, coach language; session row has v1/v2, observation_schedules row (teacher, school, EMIS) |
C. maybeCompleteObservation |
observe-completion.js:48-70 |
Unknown; may be a worker on the teacher's tap | sessionId only |
What is NOT available at any hook: grade code, section, shift, subject. The observe stack never reads the classes model. The only "grade" is leader_teachers.level (free text) surfaced through the brief (observe-visit-flow.handler.js:147-148). A student-assessment step therefore needs its own one-tap grade pick (or a classes lookup by class_teachers.teacher_user_id).
infrastructure/supabase/00_complete-schema.sql unless noted)| Table | Key columns | Used by observe? | Ref |
|---|---|---|---|
schools |
id, name, region, principal_user_id (+ prod-only emis, source_school_id, source_system, is_active, is_probable_test) |
via leader_schools.school_id |
:4365-4374, :5036 |
users |
id, phone_number, name, role, preferred_language, school_id, teacher_uuid, preferences, conversation_state |
yes (coach + bound teacher) | :38-76, :71, :4037 |
leader_schools |
leader_user_id, source('niete_ict'), school_ext_id('niete:<EMIS>'), school_id, school_name, emis |
the assignment gate | :4879-4893; observe-command.handler.js:48-60 |
leader_teachers |
leader_user_id, school_ext_id, school_id, teacher_ext_id, teacher_name, teacher_phone_e164, level, deleted_at |
picker + resolveTeacher |
:4917-4940 |
observation_schedules |
leader_user_id, school_ext_id, school_id, teacher_ext_id, teacher_name, school_name, scheduled_for, scheduled_slot, status(upcoming/done/cancelled), session_id, calendar_event_id |
scheduling; also the identity record for ad-hoc captures | :4984-5006; observe-who.service.js:17-25 |
classes |
school_id, grade_code→grade_levels, section→sections, shift_code, session_code, is_active |
no | :4541-4566 |
class_teachers |
class_id, teacher_user_id, is_class_teacher, assigned_on, ended_on |
no | infrastructure/supabase/migrations/V1.1.3__classes_model.sql:268-290 |
class_teacher_subjects |
class_teacher_id, subject_code→subjects, class_id |
no | :4573-4590 |
student_lists |
user_id (teacher), class_name, section, academic_year, student_count |
no | :527-539 |
students |
list_id→student_lists, roll_number, student_name(+urdu), father_name(+urdu), parent_phone, is_active |
no | :541-556 |
reading_assessments |
per-child oral-reading rows: user_id, student_identifier, student_number, grade_level, language, passage_*, audio_url, transcript_text, word_timestamps, wcpm, accuracy_percentage, errors, … |
no — but it is the closest existing per-child assessment shape | :409-497 |
So: a student roster exists only as teacher-owned student_lists/students (built for attendance and quizzes), keyed by user_id, not by classes.id or school. It is reachable from a bound observation only when boundTeacher.user_id resolves and that teacher has built a list. The classes model is the correct anchor for grade/section but is not yet joined to students.
portal.niete.edu.pk)portal/src/portal/pages/LeaderObservations.tsx (route /portal/leader/observations, portal/src/App.tsx:104; nav PortalNavigation.tsx) is the coach's read-only /observe page: Upcoming schedule (overdue-flagged), Pending debriefs, Completed observations (:13-16, :164-250); scheduling from the portal is the one write (portal/src/portal/services/api.ts:394-399). Data comes from GET /api/portal/leader/observations (dashboard/routes/portal.routes.js:1042-1056) → dashboard/services/leader-observations.service.js: one SQL over coaching_sessions + lateral observation_schedules (:38-56), identity in priority schedule → teacher_delivery.teacher_name → bound user (:97-111), score = analysis_data.scores.percentage (:116) — i.e. the coach-edited v2 only. No surface anywhere shows AI vs coach (grep for autofill_analysis_data / observer_edit_summary in portal/ and dashboard/ returns nothing). isCompleted = completed or review-complete with debrief not pending (:125-128).
A student-assessment view would sit as a fourth leader route (/portal/leader/assessments) beside :102-105, backed by a sibling dashboard/services/leader-assessments.service.js following the same shape (SQL + shapeSession-style mapper + role gate requireLeaderRole). Because the stack already keeps v1 and v2, an "AI said / coach said" column is a pure read of autofill_analysis_data vs analysis_data — worth adding to the observations view at the same time so both HITL products display the human correction.
| Module | What it does today | For a 5-min per-child EGRA/EGMA battery | Verdict |
|---|---|---|---|
Audio intake — observe-audio-router.js, observe-binding.service.js |
Routes leader audio by Redis state; parks unarmed classroom-length audio | Child clips are short (<900 s) — today the router treats resolved-short audio as chat (:188-189). Needs a new armed state (awaiting_student_audio {sessionId, studentSeq, subtask}) checked before the length test |
Needs generalisation (add a branch; do not touch the classroom invariant) |
WhatsApp document/voice handling — whatsapp-bot.js:2426-2569, getMediaInfo/ffprobe |
Works for both entry points | As-is | Reusable |
Transcription — transcription-processor.service.js:414-422, audio.service.js Soniox + Whisper fallback |
Diarised long-form STT, roles param | Diarisation is wrong for single-child oral reading; the reading_assessments path already stores word_timestamps (schema :435). Reuse AudioService.transcribe with diarisation off and a per-child language hint |
Needs generalisation (call the lower-level service, not the classroom wrapper) |
R2 storage — r2.js |
Key patterns per artefact, metadata | New uploadStudentAssessmentAudio with child-identifying key; copy uploadClassroomAudio's metadata discipline |
Reusable pattern, new function |
LLM scoring — analysis-processor.service.js + fico-framework.js + gpt5-mini.service.js:324-380 |
One 26-indicator rubric per lesson transcript | A child battery is deterministic scoring (letters/min, words correct, wcpm) plus a small judgement layer; completeJson (gpt5-mini.service.js:657-661) is the right primitive; analyzePedagogy is not |
Not applicable as-is; reuse completeJson + the reading_assessments scoring columns |
Framework pack — observe-framework.js:203-251 |
Drives screens, scale, domain order from one config | An "EGRA pack" with domainOrder=subtasks, scaleOptions=per-subtask scale would let the generator and prefill work unchanged |
Reusable with a new pack |
Flow pre-fill — observe-draft.service.js:268-337 + generate-observe-flow-json.js |
analysis JSON → per-screen ${data.*} with caps and scale clamp |
Same shape: one screen per child (or per subtask), radio + textarea per item. Keep PREFILL_TEXT_CAP and code-point clipping |
Reusable (parameterise the domain→screen mapping) |
Correction capture — observe-mewaka-endpoint.js:61-131 |
Redis-buffered per-screen edits, merge on last screen, observe_action on SUCCESS |
Identical mechanics; new endpoint path + student_assessment_action in the detector (flow-type-detector.js:110) |
Reusable (copy, rename token/prefix) |
v1/v2 storage + edit summary — observe-draft.service.js:343-489 |
autofill_analysis_data (AI) vs analysis_data (coach), counts in observer_edit_summary |
The unit is a child, not a session — a student_assessments table with ai_result JSONB, coach_result JSONB, edit_summary JSONB, audio_key, assessed_by, session_id FK coaching_sessions (Rule 15: check whether reading_assessments can hold it first) |
Needs generalisation (new table, same two-blob discipline) |
State + resume — observe-state.service.js, observe-resume.service.js, observe-debrief.service.js:193-199 |
One Redis slot per coach, DB-derived resume kinds | Per-child loop needs {sessionId, studentSeq} in the slot and a resumeKindFor branch; the "unfinished" list (listUnfinished :201-215) is the re-entry surface |
Needs generalisation |
Completion — observe-completion.js |
Three-condition CAS flip | Add "assessment done or declined" as a non-blocking flag, never a fourth completion condition | Not applicable (keep observation completion independent) |
Portal — LeaderObservations.tsx, leader-observations.service.js |
Read-only lists + score | Sibling page + service; add AI-vs-coach column | Reusable pattern, new page |
Sweepers — debrief-retry-sweep.js + runDebriefRetrySweep |
Pure planner + worker glue, setNX single-flight, kill switch | Same pattern for per-child transcription retries | Reusable pattern (Class P/R rules apply) |
develop is staging and nothing reaches main without passing through it (root CLAUDE.md Rule 7; NIETE-Rumi/CLAUDE.md:12). Worktree per bead (Rule 21).OBSERVE_MEWAKA_FLOW_ID (form; also the master switch observe-gate.js:62), OBSERVE_VISIT_FLOW_ID (picker), OBSERVE_FRAMEWORK=fico, OBSERVE_FICO_FLOW_HAS_FIDELITY (value-aware because published Flows are immutable, observe-draft.service.js:278-285), OBSERVE_CAPTURE_GATES_ENABLED, OBSERVE_SCHEDULING_UI, OBSERVE_STAGE_SCREENS. Read at call time, not import (observe-command.handler.js:26-33). A new STUDENT_ASSESSMENT_FLOW_ID follows the same rule.completeJson was missing in this fork and killed 0/48 debriefs (gpt5-mini.service.js:646-651); leader_schools/observation_schedules shipped as migrations but were absent from 00_complete-schema.sql, so a fresh bootstrap silently had no visit planning (schema :4592-4598). The observe columns are still missing from the schema file (tests/baseline.snapshot.json:116-120). Ship schema + migration + code in one PR; the column-completeness guard must not grow.WORKER_QUEUES. Per-service queue subsets (sqs-worker.js:134-181, :1366-1381); main worker main,quiz, video worker video (root change log, Aug 1–3). A new job type must be routed to a queue a live service polls, and the boot log must show it..claude/skills/pre-merge-checklist/SKILL.md:49-51): O — a test that jest.mock()s the module whose return value is under audit proves nothing; P — setInterval in a service that redeploys faster than the interval (every sweep here is on the always-on worker; add a boot-time run like runDebriefRetrySweep :1282-1285); Q — a capability with no caller (new exported function or table-driven flow entry must be wired, e.g. every list-row prefix needs a dispatch branch in whatsapp-bot.js or taps die "Unknown list item ID", observe-debrief.service.js:6-8). Class R (DB load) for any worker query.version; every returned key must be declared on the screen; ~10 s Meta timeout so the endpoint only reads pre-computed drafts (observe-mewaka-endpoint.js:4-9); a Meta Flow is a DAG with one Footer per screen — loops are close-and-reopen (observe-command.handler.js:87-95); WhatsApp caps: list ≤10 rows, button title ≤20 chars, TextArea 600 default, TextBody 4096 (observe-debrief.service.js:36, :66-69, observe-draft.service.js:23-28, :91). Token formats: picker user.id (no colons), form observerId:sessionId.observation_type on the row; the recipient comes from the row not the job's from (bd-wwcgf, observe-draft.service.js:368-391); SQS payload loss is a known class (bd-1525). Every new job must be re-runnable from the row alone.armDebriefAudio/clearStateAfterSubmit/onAnalysisReady all refuse to overwrite an awaiting_debrief_audio for a different session (observe-debrief.service.js:360-398, observe-draft.service.js:356-366). A per-child loop adds a third contender for the one Redis slot — design the key so it cannot race the debrief.observeLang(user) + per-language string packs (observe-strings.js), one writer for preferred_language, code-point caps (root Rule 20). The sw ? sw : en collapse that turned Urdu coaches English is the documented failure (observe-debrief.service.js:760-765).debrief-retry-sweep.js:30-31). Upload child audio to R2 at intake.npm test is a delta gate against tests/baseline.snapshot.json (CLAUDE.md:41-52); bot-only deps must be stubbed in tests/__mocks__/; 10 observe suites at tests/observe/, 110 more at bot/tests/observe/. Red-first (Rule 6).The main bot (hyasin270/whatsapp-ai-bot, HEAD 2dfd5d77) does have the original observe/HITL stack (FEAT-053, Tanzania MEWAKA + Pakistan HOTS): MAIN:shared/services/observe/ has 16 files and MAIN:shared/services/observe/observe-framework.js:30 knows only ['mewaka','hots']. NIETE's fork adds 'fico' (observe-framework.js:31) and 19 modules the main bot lacks: assignment/ (roster source), observe-binding.service.js, observe-who.service.js, observe-schedule.service.js, observe-calendar.service.js + google-calendar.client.js, observe-resume.service.js, observe-completion.js, patch-resolver.service.js, observe-school-admin.service.js, observe-teacher-admin.service.js, teacher-edit.service.js, teacher-fk-binding.js, observe-untapped.service.js, debrief-retry-sweep.js, observe-brief-card.js, observe-support-moves.js, observe-language.js, report-tap-routing.js, coach-directory.js — plus the 1,662-line visit-picker handler (MAIN:shared/handlers/ has no observe-visit-flow.handler.js) and the leader portal pages. The FICO rubric, the roster (leader_schools/leader_teachers/classes), the coaches, and the portal are all NIETE-only.
Conclusion: the EGRA/EGMA student-assessment step lands in Orenda-Project/NIETE-Rumi (branch off develop), reusing its observe stack; nothing needs to touch 02_Main Rumi Bot. If it is ever wanted in Tanzania, it is a port, and the porting checklist in §7.3 applies.