The NIETE observe HITL flow

How the AI pre-fill and coach correction work today; hook points; reuse map

04 — NIETE-Rumi /observe HITL stack, documented for the EGRA/EGMA student-assessment step

Read-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).


1. Coach journey for /observe

1.1 Trigger and gates

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.

1.2 What the coach selects

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.

1.3 The recordings (two, not three)

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).

1.4 Transcription and diarisation

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).

1.5 LLM scoring (FICO)

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).

1.6 The pre-filled form

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).


2. The HITL edit step

2.1 Mechanics

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).

2.2 Return path

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).

2.3 Storage — AI value vs coach value

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.

2.4 Debrief (recording 2) and coach-the-coach

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).

2.5 Send to teacher and completion

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).

2.6 State machine

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

3. Audio storage (R2)

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.


4. The completion point — where "Now assess 5 students?" should pop

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).

4.1 Roster tables in the NIETE DB (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.


5. Portal (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.


6. Reuse map

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)

7. Gotchas a build plan must respect

  1. Repo and branch. NIETE-Rumi is its own codebase and Railway project; 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).
  2. Presence-based feature gating. Every observe capability is a Flow id in env: 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.
  3. "Code ported but the dependency wasn't." 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.
  4. 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.
  5. Pre-merge classes O/P/Q (.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.
  6. Flow contract. Never return 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.
  7. Row-derived, never payload-derived. Observe-ness comes from 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.
  8. Never clobber another session's live state. 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.
  9. Language. 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).
  10. Media lifetime. Meta media ids expire ~30 d; the debrief path stores only the id and has a 28-day retry ceiling as a result (debrief-retry-sweep.js:30-31). Upload child audio to R2 at intake.
  11. Testing. Root 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).

8. Main bot vs NIETE — where the feature lands

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.