Skip to content

feat: Live Student Doubts & Confusion Analytics Integration - #95

Open
surajmadhav wants to merge 59 commits into
vicharanashala:mainfrom
surajmadhav:feature/doubts-and-confusion-analytics
Open

feat: Live Student Doubts & Confusion Analytics Integration#95
surajmadhav wants to merge 59 commits into
vicharanashala:mainfrom
surajmadhav:feature/doubts-and-confusion-analytics

Conversation

@surajmadhav

@surajmadhav surajmadhav commented Aug 2, 2026

Copy link
Copy Markdown

🚀 Feature: Comprehensive Live Student Feedback Systems (Doubts + Confusion Analytics)

📋 Overview

This PR merges two complementary, real-time student feedback systems into the core Spandan platform: the Live Doubts System and Confusion Analytics.

By running both features side-by-side in the Teacher Dashboard, educators now have an unprecedented, granular understanding of where students are struggling and what specific nuances they are missing—all completely anonymously and in real-time.

🌟 Key Features & Contributions

1. Live Doubts System (Authored by: Suraj)

A robust, anonymous Q&A system for students to explicitly state what they don't understand.

  • Student Capabilities: Submit anonymous text-based doubts automatically tagged with the current active topic. Students can upvote peers' doubts, and "Re-open" a resolved doubt if a specific nuance was missed.
  • Teacher Dashboard:
    • Dedicated slide-out panel with two distinct views: Chronological List and Topic-Grouped.
    • One-click bulk resolution of all doubts under a specific topic.
    • Distinct UI highlights (e.g., orange alerts) for doubts that students have explicitly re-opened, ensuring critical misunderstandings aren't swept under the rug.

2. Confusion Analytics (Authored by: Rashmi)

A frictionless, low-effort mechanism for students to signal that they are falling behind.

  • Student Capabilities: A smart, rate-limited "I'm Lost" button that instantly pings the teacher without interrupting their flow.
  • Teacher Dashboard:
    • Live "Confusion Alerts" that pop up when multiple students signal they are lost on a specific topic.
    • A dynamic Timeline and Heatmap plotting confusion density over the course of the lecture.
    • The Recovery Loop: A one-click "Ask Students: Did this help?" broadcast that pings only the confused students to see if the teacher's new explanation worked.

🛠️ Technical Implementation & Fixes

Advanced Anonymous Tracking

  • Implemented full client anonymity using HMAC SHA-256 hashing. The backend assigns a unique doubtSalt to every room session. Student IDs are cryptographically hashed before being broadcast, ensuring teachers never see raw student identities, and peers cannot intercept them.

Smart Recovery Loop Accuracy (Bug Fix)

  • Resolved a critical state-tracking bug where the "Ask Students" popup would incorrectly broadcast to all students, leading to inflated and inaccurate recovery statistics (e.g., 5 recoveries in a 2-student room).
  • Solution: The backend now utilizes a memory-backed Map associating unique studentHash values to their specific event. The broadcast is targeted exclusively to students who signaled confusion, and duplicate "Understood" clicks are filtered out, ensuring 100% accurate Recovery metrics.

Local Development Developer Experience (DX)

  • Smart Email Bypass: Added intelligent fallback logic to emailService.js. If the server is running locally (NODE_ENV !== 'production') and the .env lacks real SMTP credentials (or uses the default template your-gmail-app-password), OTP and Welcome emails are automatically bypassed and logged to the console via [DEMO MODE]. This prevents developers from being locked out of their local testing servers while maintaining full email functionality in production.

Documentation

  • Fully updated the HelpPage.jsx manual with comprehensive instructions for both Teachers and Students on how to interact with the new Doubts and Analytics systems.

✅ Verification

  • Tested Socket.io event emissions for both Doubts and Confusion Analytics.
  • Verified Recovery metrics scale correctly with multiple concurrent clients.
  • Confirmed UI panels toggle cleanly without overlapping on standard and mobile viewports.

surajmadhav and others added 30 commits July 2, 2026 13:29
…simple)

- Added tone selector UI with 4 options in TextToQuestionsPopup
- Backend AI prompt adapts language style based on selected tone
- Professional: formal textbook style
- Fun: casual, witty, relatable questions
- Technical: precise, jargon-heavy for advanced learners
- Simple: beginner-friendly, easy language
- Added prepare_poll socket event to backend
- Teacher side now shows a 5-second countdown overlay before sending the actual question
- Student side shows a 'Get Ready' overlay and plays a pleasant generated chime using Web Audio API
- Ensures both teacher and students are perfectly in sync when the timer actually starts
- Student side: Added floating 'Raise Doubt' button and modal to capture real-time questions
- Backend: Added 'raise_doubt' and 'resolve_doubt' socket event handlers
- Teacher side: Added dynamic 'Doubts' counter button and a sliding sidebar panel
- Teacher side: Teachers can view doubts chronologically and click 'Mark as Resolved' to clear them
- Backend: DoubtSignal model, doubtService (HMAC anonymization, anti-spam,
  retract, spike detection), 5 REST endpoints, socket handler
- Frontend: ImLostButton (squishy top-right), ConfusionSpikePanel
  (bar chart + spike cards), Web Audio synth for tap/send/deny cues
- Tests: 28 unit tests in doubtService.test.js, all passing
- Docs: docs/doubt-anchored-polling.md (design doc, deferred work,
  open questions)
- README updated with feature section, file tree, testing instructions
- Local question generator with auto-fallback when AI provider fails
- Mojibake sweep across Leaderboard.jsx, RoomDetailPage.jsx,
  StudentRoomPage.jsx (fixed critical Vite compile break in
  Leaderboard.jsx ternary)
- StudentRoomPage.jsx: â³ (mojibake of ⏳) -> ⏳ (hourglass for 'Waiting' state)
- RoomDetailPage.jsx: â³ (animation) -> ⏳
- RoomDetailPage.jsx: â±ï¸ (mojibake of ⚠) -> ⚠ (in Answer badge + Time's Up alert)

Verified 0 mojibake bytes across all 46 frontend source files.
- DoubtSignal: add clientSentAt, recordingOffsetMs, utteranceSnapshot fields
- Room: add roomStartedAt field for session clock origin
- doubtService: server computes recordingOffsetMs from roomStartedAt + clientSentAt
- doubtService: new getSpikeDetails() and getSignalsForRoom() time-anchored queries
- doubtService: formatMs() helper for MM:SS / HH:MM:SS UI display
- routes/doubts: POST /room/:id/session/start (teacher), GET /session (anyone)
- routes/doubts: GET /spikes/timeline, GET /signals for teacher dashboard
- All 28 existing tests still passing
…asts

- New teacherPositionStore.js: tracks lastPosition, roomStartedAt,
  sessionActive, plus helpers startSession / broadcastPosition / reset
- socketStore.js: relays teacher:position and teacher:session-start
  events to the position store; new passthroughs emitTeacherPosition +
  emitTeacherSessionStart
- api.js doubtApi: new endpoints startSession, getSession,
  getTimelineSpikes, getSignals, recordWithContext (carries recording
  offset + utterance snapshot)
- Backend index.js: socket handlers for teacher:position and
  teacher:session-start (verified to compile)

This sets up the foundation for Commit 3 (teacher broadcasts every 2s
while recording) + Commit 4 (student ImLostButton anchors doubt to
real position).
…ds show topics

NEW BACKEND
- models/TopicMarker.js: per-room time-anchored labels (startMs..endMs)
- services/topicService.js: setTopic/deleteTopic/listTopics + resolveTopicForOffset
  (marker first, then transcript proxy fallback, then none)
- routes/topics.js: POST /topics/room/:id, DELETE /topics/room/:id/:markerId, GET /topics/room/:id
- doubtService.getSpikeDetails + getSignalsForRoom: annotate every spike/signal
  with its topic (label + source: 'marker' | 'transcript' | 'none')
- index.js: socket handlers teacher:topic-set + teacher:topic-delete (broadcast to room)
- Fixed DoubtSignal aggregation bug (was dropping studentHash before \)

NEW FRONTEND
- components/TopicMarkerBar.jsx: teacher inline editor + student read-only view;
  shows current topic ('Now teaching'), timeline of all markers, click +Mark to
  anchor a label to the current recording offset
- components/ConfusionSpikePanel.jsx: spike cards now show topic prominently
  (📚 label, with auto badge if from transcript); timeline tab shows topic per-tap
- pages/RoomDetailPage.jsx: renders <TopicMarkerBar> below the spike panel
- api.js: topicApi (set/remove/list)

CSS
- .csp-spike-topic chip (purple left-border, white bg, dark mode parity)
- .csp-topic-badge for 'auto' (transcript-detected) markers
- .csp-timeline-topic (smaller variant on timeline tab)
- .tmb-bar / .tmb-editor / .tmb-topic / .tmb-current (TopicMarkerBar styles)

TESTS
- backend/src/__tests__/topicService.test.js: 18 new tests (set/delete/list/resolve/
  annotate). Total backend tests now 118/118 across 7 suites (was 100/100).

VERIFIED
- Spike endpoint returns topic per bucket (Intro to Glycolysis / Investment Phase /
  Payoff Phase + ATP Yield) for the seeded demo room
- Vite serves TopicMarkerBar.jsx (200), teacher room page (200)
- Backend health 200 ok, mongo connected
… attaches it

Without this, every student tap arrives with recordingOffsetMs=0 so the
topic resolver can't tell what we were teaching when a spike happens --
spike cards end up labeled 'No topic marked' even when topics are set.

WIRING
- pages/RoomDetailPage.jsx (teacher):
  - useTeacherPositionStore import
  - startTeacherBroadcast() begins 2s interval on startRecording; calls
    posStore.startSession + .broadcastPosition({offset, segment, utterance})
  - stopTeacherBroadcast() clears interval on stopRecording
  - cleanup on unmount
- components/ImLostButton.jsx (student):
  - useTeacherPositionStore import
  - reads lastPosition + sessionActive from store
  - attaches {recordingOffsetMs, utteranceSnapshot, clientSentAt} to
    socket 'doubt:signal' emit and to REST recordWithContext fallback
  - when no session active, falls back to old behavior (segment-level only)

VERIFIED
- All 4 modified .jsx files compile via Vite (200 OK on fetch)
- backend healthy after restart, /api/doubts/room/:id/spikes/timeline still
  returns topics for room E6N1NE
The old heuristic chose 'Now Calvin' over 'Calvin cycle' when both
candidate bigrams tied on frequency, because the second-word check
in topicGenerator.js didn't require the second token to be lowercase.

Changes:

- topicGenerator.js: Strategy 1 now requires the second word of a
  bigram to be lowercase AND at least 4 chars, so 'Now Calvin' is
  rejected and 'Calvin cycle' wins.
- topicService.js: removed its duplicate re-implementation of
  extractTopicProxy and re-exports topicGenerator.js's version.
  Single source of truth, fewer drift bugs.

All 123/123 tests pass (was 121/123, 2 failures now fixed).
Milestone 1 -- automatic topic detection connected to live transcript.

Covers:
- extractTopicProxy heuristic: stage direction stripping, proper-noun
  bigram detection (Binary Search, Krebs cycle), sentence-opener filtering,
  truncation, edge cases for empty/null inputs
- maybeGenerateAutoTopic state machine: cooldown, text-too-short guard,
  90-second rolling window, heuristic fallback when API unavailable
- detectTopicShift with mocked fetch: valid JSON, invalid JSON, non-2xx
  responses, confidence clamping, missing API key

Also refines Strategy 1 in topicGenerator.js to weight pairs of two
proper nouns higher than pairs of one proper noun + one lowercase content
word, so "Binary Search" beats "Search trees" when both candidates exist.

All 144/144 tests pass (was 123/123, +21 new).
…ive topic

Milestone 2 of the Topic-Aware Confusion plan. Replaces per-tap spike
rows with a single live alert card that opens the first time a student
presses "I'm Lost" for a topic, increments as more students press it for
the same topic, and closes/opens on topic shift.

Backend (new)
- models/ConfusionEvent.js -- room/topic-keyed event doc, dedup by HMAC
  hash, status active|closed, signalIds for drill-down
- services/confusionEventService.js -- attachSignalToEvent() resolver
  (create|merge|noop), closeEvent, getActiveForRoom, getLatestForRoom,
  listForRoom, closeAllActiveForRoom, formatForClient
- routes/confusion.js -- GET /api/confusion/room/:id/active|latest|

Backend (modified)
- routes/doubts.js -- after each successful POST /api/doubts the
  signal is attached to the active ConfusionEvent and the teacher room
  receives a confusion:update (or confusion:closed) socket event
- services/doubtService.js -- ensureRoomSalt() exported for route reuse
- models/index.js -- ConfusionEvent registered
- index.js -- /api/confusion mounted

Frontend (new)
- components/ConfusionAlertCard.jsx -- single live card showing Topic,
  Subtopic, Students Confused (count-up live), Started, Last Update,
  Latest Transcript. Subscribes to confusion:update / confusion:closed.
- index.css -- .cac-* styles (light + dark theme, pulse animation)

Frontend (modified)
- lib/api.js -- confusionApi (getActive/getLatest/getHistory)
- pages/RoomDetailPage.jsx -- ConfusionAlertCard mounted above the
  existing ConfusionSpikePanel (drill-down kept)

Tests
- backend/src/__tests__/confusionEventService.test.js (NEW, 22 cases)
  - create on first signal
  - merge on second student
  - noop on duplicate student (dedup)
  - close prior + open new on topic shift (label and markerId paths)
  - empty-label non-merge (avoid accidental cross-topic bucket)
  - latest snippet not overwritten with empty
  - markerId equality overrides label mismatch
  - room isolation
  - latestTimestamp monotonicity on merge
  - list/getActive/getLatest queries + invalid id safety
  - closeEvent / closeAllActiveForRoom
  - formatForClient null + full event
  - integration: full pipeline with Room + DoubtSignal + TopicMarker +
    doubtService.hashStudent + topicService.resolveTopicForOffset

Test results: 166/166 pass across 9 suites (was 144/144, +22).
… + redesigned alert card

Milestone 3 -- replace legacy ConfusionSpikePanel with topic-aware confusion UX.
…e-start

Bug: every call to POST /api/doubts/room/:roomId/session/start (or the
socket-equivalent teacher:session-start) closed all active ConfusionEvents
in the room, regardless of whether this was a fresh session boundary or
just the teacher hitting "Start Recording" again in the same recording.

Reproducer:
  1. Teacher clicks Start Recording -> session/start fires
  2. Student taps "I'm Lost" -> ConfusionEvent created, /active returns it
  3. Teacher clicks Start Recording AGAIN (page refresh, double-click,
     React effect re-fire, socket reconnect) -> session/start fires again
  4. The active ConfusionEvent is closed by the cleanup query
  5. GET /api/confusion/room/:id/active returns {event: null}
  6. ConfusionAlertCard / TopicHeatmap / ConfusionTimeline all clear

Root cause: doubtService.startRoomSession unconditionally ran
  ConfusionEvent.updateMany({status:'active'}, {$set:{status:'closed',...}})
which destroys the current session's events.

Fix: capture the previous roomStartedAt BEFORE writing the new one. Only
treat the call as a fresh-session boundary if there was no previous
session start, or the previous one was more than SESSION_GAP_MS (5 min)
ago. Same-session re-fires within 5 minutes skip the event cleanup, so
the active ConfusionEvent survives.

Verified:
  - Scenario A (fresh session, > 5 min gap): prior events closed (unchanged)
  - Scenario B (same session, re-fire within 5 min): active event preserved
  - Scenario C (happy path: session/start -> student tap -> /active):
    event returned immediately, score + tier computed
  - backend doubtService.test.js: 28/28 pass

Does NOT touch scoring/heatmap/UI -- only the session-start cleanup
logic, per the user's directive to focus exclusively on the live browser
update path.
The previous buildTopicHeat/buildHeatmap read e.score off the event doc --
but ConfusionEvent never stores score (it's computed at read time), so
the topic-heat and heatmap endpoints always reported 0s.

Compute scoreEvent(...) inline for each event, then aggregate.

Tests: 191/191 backend, 32/32 frontend. Total 223/223 green.

Refactor:
- buildTopicHeat: takes events array, computes scores via scoreEvent, aggregates
  per-topic totals (totalScore, maxScore, avgScore, eventCount, studentCount)
- buildHeatmap: derives anchor window from event timestamps, distributes
  event score across overlapping buckets proportionally; rejects events
  with missing timestamps instead of crashing
- Test fixtures updated to use fresh timestamps so scoreEvent produces
  deterministic values; sort-order assertions match new ranking.
When a student presses 'I'm Lost' AFTER all topic markers have ended
(common case: students joining late, or session clock drifting past
marker endMs), resolveTopicForOffset returned an empty label.
The ConfusionEvent was created with topicLabel='', and isSameTopic
treats both-empty labels as DIFFERENT (avoids accidental merge) so
every empty-topic doubt spawned a fresh event.

Fix: soft fallbacks in resolveTopicForOffset and resolveTopicsForOffsets:
  1b. No marker covers offset? Use the most recent preceding marker.
      (source: 'latest_marker')
  2b. No transcript in ±15s window? Use the most recent transcript.
      (source: 'latest_transcript')

Schema: ConfusionEvent.topicSource enum accepts 'latest_marker' and
'latest_transcript'.

Tests: 194/194 backend, 32/32 frontend (pre-existing 2 vitest-setup
failures unchanged).

Verified end-to-end via POST /api/doubts on room E6N1NE (roomStartedAt
from yesterday) -> event created with topicLabel='Glycolysis -- Payoff
Phase + ATP Yield', source='latest_marker', count=1, score=7.5.
When the student taps 'I'm Lost' BEFORE the teacher has produced any
transcript or marker, the only signal we have is the student's own
utterance. Previously this created a ConfusionEvent with topicLabel=''
and source='none', so the UI displayed '(no topic detected)'.

Fix: attachSignalToEvent now falls back to extractTopicProxy(utteranceSnapshot)
when resolveTopicForOffset returns empty. New source 'student_utterance'
added to ConfusionEvent enum, rendered as a yellow 'Student' badge in
ConfusionAlertCard with matching CSS class.

Verified with 4 utterance cases:
  'I am confused about binary search trees' -> 'Confused binary search'
  'I do not understand how photosynthesis'  -> 'Understand photosynthesis works'
  'lost'                                    -> 'Lost'
  'I do not get the Krebs cycle'            -> 'Krebs cycle'

Tests: 196/196 backend (was 194, added 2). Frontend: vitest class 32/32
(pre-existing 2 setup failures unchanged).
…mpty

If a student taps 'I'm Lost' before the teacher has produced any
transcript, before any topic marker exists, AND the student's
utterance snapshot is empty/undefined, the event was still being
created with topicLabel='' and source='none'. The dashboard then
displayed '(no topic detected)' and a gray 'No topic' badge.

Final hard fallback in attachSignalToEvent: when both marker/transcript
AND utterance paths fail to produce a label, set
  topicLabel = 'General confusion'
  topicSource = 'fallback'

New 'fallback' source added to enum, rendered as red 'General' badge
with cac-source-badge--fallback CSS class.

Verification (cold room, no markers, no transcripts, empty utterance):
  POST /api/doubts -> event with topicLabel='General confusion',
  source='fallback', count=1, score=7.5, tier=green

With utterance 'I am confused about binary trees':
  topicLabel='Confused binary trees', source='student_utterance'

Tests: 196/196 (one test renamed+repurposed; 'empty labels separate'
became 'empty labels merge under fallback label' which is correct UX).

Backend (PID 9040 -> 15800 -> 17892) and Vite (PID 1748 -> 11708 -> 8296)
restarted so the served bundle reflects the new code. HMR appears to
have NOT fired across the user's open browser tabs -- they may need
to hard-refresh (Ctrl+Shift+R) once for the yellow/red badges to
appear.
Auto-topic detection was extracting noise tokens from transcripts like
'(sigh)', '[BLANK_AUDIO]', 'I am locked. I am locked.' and producing
gibberish topic labels such as 'Sigh', 'Locked', 'Love'.

Changes to extractTopicProxy:
1. Strip [bracketed], (parenthesised), *asterisked* tokens -- Whisper artifacts
2. Collapse repeated phrases ('X. X. X.' -> 'X.')
3. Reject any token in NOISE_TOKENS set (Sigh, Laugh, Music, etc.)
4. Require at least 2 distinct content words; single-word labels are too noisy
5. Strategy 2 (single-word freq) now requires >=2 unique words

Verified end-to-end with the lecture transcripts in the DB:
  'Today we are discussing the Krebs cycle'         -> 'Krebs cycle'
  'Photosynthesis is a process used by plants'      -> 'Photosynthesis process'
  '*sigh* [BLANK_AUDIO]'                            -> '' (no topic)
  'I am locked. I am locked. I am locked.'          -> '' (no topic)
  '[BLANK_AUDIO] (laughs) *sigh* [silence]'         -> '' (no topic)

Tests 196/196 still pass (no unit tests were broken since the new behavior
is more restrictive only for noisy inputs).
If the teacher switched to another tab and missed several socket events,
returning to the dashboard would show stale data until the 8s polling
interval fired. Now we re-fetch immediately on visibilitychange so the
card always reflects current state when the teacher looks at it.
Previously, maybeGenerateAutoTopic silently returned createNew=false on
all rejection paths (empty transcripts, text too short, recent auto
topic exists, AI says no shift, heuristic empty, duplicate label).

This made debugging impossible -- a frontend showing empty topic panels
gave no clue WHY the auto-topic pipeline skipped.

Now every skip path logs a clear reason:
  [auto-topic] skip room=... reason="text too short (45 chars)"
  [auto-topic] skip room=... reason="recent auto topic X is less than 60s old"
  [auto-topic] skip room=... reason="heuristic returned empty"

Successful creations also log the label and source so the lifecycle is
fully observable from backend logs.

No behavior change -- only logging added. All 196 tests pass.
…as POST /api/doubts

Both entry points now share broadcastRecordedDoubt() in doubtService.js,
which emits doubt:new, confusion:update, and confusion:closed in the same
order and with the same payloads.

Before this fix the socket handler only emitted doubt:new + a per-socket
doubt:confirmed ack. It never called attachSignalToEvent and never emitted
confusion:update/confusion:closed, so the teacher dashboard's live alert
card (ConfusionAlertCard.jsx, listening for confusion:update) stayed
empty until its 8-second polling interval caught up. The HTTP path was
already emitting the right events; only the socket path was broken.

No business logic was duplicated. recordDoubt, attachSignalToEvent,
getDoubtCountsBySegment, ensureRoomSalt, hashStudent, formatForClient are
all reused via the shared helper.

Caller-specific acks (HTTP 200 with signal id, socket doubt:confirmed
and doubt:ignored) stay in their respective handlers -- only the
broadcast step is shared.
Rashmirisha and others added 29 commits July 13, 2026 14:24
…fire

Manual Stop (toggleRecording) only stopped MediaRecorder — saveTranscript
was called exclusively from the segment-timer path (handleSegmentComplete).
Short interactive tests that stopped recording before segmentTime elapsed
never POSTed a transcript, so topicGenerator.maybeGenerateAutoTopic never
ran and ConfusionEvent.topicLabel fell back to 'General confusion'.

Extract saveCurrentSegmentTranscript() so both paths share the same save
+ 50-char length guard. toggleRecording Stop branch now flushes whatever
has accumulated so /api/transcripts fires and the auto-topic pipeline
extracts a marker before the next student doubt lands.

No changes to doubtService, attachSignalToEvent, confusionEventService,
socket handlers, or topic resolution logic.
When a teacher hits Start Recording, the frontend only updated a local
Zustand store (teacherPositionStore.startSession) but never called the
backend route POST /api/doubts/room/:id/session/start that sets
Room.roomStartedAt.

The auto-topic pipeline in POST /api/transcripts bails out early when
Room.roomStartedAt is null, so brand-new rooms silently skipped topic
extraction and falls back to 'General confusion' on every student doubt.
Rooms reused across teacher sessions kept working because
roomStartedAt was already set from the prior session.

Fix: alongside the local posStore call, fire api.doubts.startSession
which hits the existing backend route and persists roomStartedAt. Fire-
and-forget so a transient network blip never blocks recording.

Backend tests: 196/196 green.
Student Recovery Flow (replaces teacher 'Mark as Resolved'):
- Teacher dashboard now shows 'Ask Students: Did this help?' button
  (replaces 'Mark as Resolved'). Clicking it pushes the recovery prompt
  to all students associated with the active confusion event.
- Students see a 'Did this explanation help?' popup with two buttons:
  'Understood' / 'Still Confused'. One response per student per event.
- Teacher dashboard live tally:
    Understood: X
    Still Confused: Y
    Recovery Score: %
- Auto-close: when every respondent says 'understood', the event
  closes automatically.
- 'Needs More Explanation' banner when any student says 'still_confused'
  -- event stays active and reopenedCount increments.

Backend:
- POST /api/confusion/event/:id/request-feedback
  Teacher-only; emits confusion:resolved to room.
- POST /api/confusion/event/:id/resolve  (legacy alias)
  Same behaviour as /request-feedback (event stays open).
- POST /api/confusion/event/:id/feedback
  Student response; emits confusion:feedback with tally + autoClose +
  needsMoreExplanation flag. Auto-closes when understood >= expected
  respondents and stillConfused == 0.
- ConfusionEvent.reopenedCount tracks reopens.
- Legacy /resolve flow preserved for backwards compatibility.

Topic-Label Sanitization (post-processing only):
- Strip duplicate consecutive words: 'Photosynthesis Photosynthesis'
  -> 'Photosynthesis'.
- Strip leading/trailing filler words (today, about, discuss, ...).
- Skip the stored label's repeated-token glue.
- Skip stale or corrupted stored markers (open-ended endMs AND old,
  OR label contains 'Restakes' / 'which <verb>' / repeated phrase).
  Fall through to fresh transcript heuristic on these cases.
- 'General Confusion' fallback when heuristic returns empty AND no
  markers/transcripts are usable.
Backend was sending 'topic: evt.topic' where evt.topic doesn't exist
on the lean Mongoose document -- field is topicLabel (flat string).
Frontend was reading prompt.topic?.label expecting a nested object.

Fix:
- Backend now emits topic: evt.topicLabel || 'General Confusion'
- Frontend topicLabel accepts both string and {label} forms
Topic markers and transcripts from previous lecture sessions were
leaking into new sessions, producing labels like 'Photosynthesis which
Photosynthesis', 'Restakes Photos', and 'Hello forums' on new lectures.

Two separate fixes:

1) Session-boundary enforcement in topicService.resolveTopicForOffset
   - attachSignalToEvent now ALWAYS fetches Room.roomStartedAt and
     passes it through to the resolver.
   - The primary marker query, transcript window fallback, and
     annotateSpikesWithTopics all filter by createdAt >= roomStartedAt.
   - When roomStartedAt is null the resolver returns 'General Confusion'
     instead of pulling the 50 most-recent transcripts across sessions.
   - transcripts.js lastAuto lookup and close-out branch are also
     session-scoped.

2) Topic-label sanitization tightened.
   - looksCorrupted(): 3 new rules added (non-adjacent duplicate via
     connector, question-word + generic noun, demonstrative lead). The
     GREETING set is split into SALUTATION (pure greetings) and a full
     set that also covers lecture meta, so 'Intro' / 'Lesson 1' no
     longer trigger false-positive corruption flags.
   - extractTopicProxy: 5 layered strategies. Greeting/open words are
     rejected (hello, hi, hey, welcome, ok, today, we're, so, now,
     everyone, guys). For descriptive pairs ('Photosynthesis energy',
     'Climate weather', 'Food nutrients', 'Binary algorithm') the
     earlier single capitalized noun is preferred over the bigram.
     Canonical concept compounds are whitelisted via CONCEPT_SECOND_WORDS
     (search, sort, cycle, chain, change, selection, ...) so 'Binary
     Search', 'Climate Change', 'Food Chain' remain topics. Strategy 2
     reduced from top-3 to top-1 with a quality filter (cap OR
     length>=7 OR freq>=2).
   - Low-confidence transcripts fall back to 'General Confusion'
     instead of inventing a topic.

Tests: 36/36 topicGenerator + 24/27 topicService (3 pre-existing
failures were updated to enforce session-scope; verified via Jest).
New topicService.test.js cases: empty session returns 'no_session',
primary marker corruption check works as a guard.
Backend:
- confusionEventService.reopenEvent only matched events with
  status='closed', so clicking Still Confused on an event that was
  still active returned 404 'Confusion event not found'. Now accepts
  any event by _id. reopenedCount is only incremented via \ when
  the prior status was 'closed', so the count does not double.

Frontend (ConfusionAlertCard):
- Recovery section now shows three counts on one row (Confused total,
  Understood, Still Confused) and the recovery score
  (U / T (P%)) on a second row.
- Total is the event-wide count from expectedRespondents (the original
  confusedStudentCount, fixed for the event lifetime).
- A warning badge 'Needs More Explanation' is shown whenever any
  student is still confused. A green 'Fully Resolved' badge appears
  when U == T and SC == 0.
- Pill styling in index.css: --total (blue), --yes (green), --no (red);
  new --warn and --ok badge styles. Score pill slightly larger with a
  bar-chart prefix.

Manual smoke (HTTP): 2-student event, both click Understood, totals
render exactly 1/2 (50%) on step 1 and 2/2 (100%) on step 2 with
auto-close. Still-Confused path stays active with needsMoreExplanation
flag set.
Standalone .mjs scripts that exercise the live backend over HTTP/Socket:

- api_check.mjs: basic auth + /me + rooms list + active confusion +
  topic-heat. Quick smoke after any backend restart.
- fresh_room_topic.mjs: topic-leak regression test. Creates a fresh
  room, inserts an old transcript BEFORE session start as the leak
  source, fires a doubt signal, asserts topicLabel resolves to the
  current-session topic and not the stale one.
- recovery_flow.mjs: full teacher -> student feedback HTTP test
  (request-feedback, understood auto-close, still_confused stays open).
- recovery_totals.mjs: all-understood path. Prints the exact values
  the Recovery UI section will render (Confused total, Understood,
  Still Confused, Recovery percentage).
- recovery_still_confused.mjs: still_confused path. Verifies event
  stays active and needsMoreExplanation flag is set.

Run from project root: node backend/scripts/smoke/<name>.mjs
Backend must be running on :3001 and MongoDB on :27017.
PROBLEM
Two related bugs surfaced during smoke testing the recovery flow:

1. Student 'Confused' button became unclickable after Poll vicharanashala#1 -> Poll vicharanashala#2.
   Root cause: recordDoubt() in doubtService rejects any new signal from
   a student within 30s of their previous signal for that room, regardless
   of poll boundary. After Poll vicharanashala#1, students are blocked from Poll vicharanashala#2.

2. Poll counters did not reset between polls. After starting a new poll,
   the dashboard still showed the prior event's tally (Confused: 2,
   Understood: 0, Still Confused: 0). Root cause: question:start /
   new_question socket handlers only re-emit the question; they did not
   close the active ConfusionEvent or clear the in-memory feedbackTallies
   Map. The teacher's UI kept showing stale state.

3. Bonus: 'submitting' state in ConfusionResolvedPrompt stuck after a
   successful click, leaving the buttons on the next 'Are you clear now?'
   popup disabled (disabled={submitting}={true}). Fixed defensively.

FIX
- backend/src/services/confusionEventService.js: add resetPollStateForRoom()
  that closes active ConfusionEvents + clears feedbackTallies Map entries
  for the given room. Exported for use by socket handlers.
- backend/src/services/doubtService.js: add markPollStarted() + Map of
  lastPollStartedAtByRoom. recordDoubt() now scopes anti-spam window to
  max(now - 30s, pollStartedAtMs) so prior signals only block if they
  landed AFTER the most recent poll-start for the room.
- backend/src/index.js: extract a resetPollState(roomCode) helper that
  resets confusion state + updates anti-spam marker + emits a new
  'poll:reset' socket event. Called from both question:start and
  new_question handlers. Refactored to remove duplicated logic.
- frontend/src/components/ImLostButton.jsx: listen for new_question /
  question:started / poll:reset to clear local cooldown state.
- frontend/src/components/ConfusionAlertCard.jsx: listen for the same
  events to clear event, latest, feedbackTally, displayCount.
- frontend/src/components/ConfusionResolvedPrompt.jsx: reset
  submitting after a successful submit (fixes 2nd-recovery-poll
  buttons-stay-disabled bug). Defensive reset also added in the
  onResolved socket handler.

REGRESSION COVERAGE
- backend/scripts/smoke/poll_lifecycle_proof.mjs (new): 5 consecutive
  polls, asserts anti-spam is per-poll, fresh event ids, count starts
  from 0. 40/40 PASS.
- backend/scripts/smoke/poll_recovery_reset_proof.mjs (new): Poll vicharanashala#1
  recovery tally wiped on Poll vicharanashala#2 boundary.
- backend/scripts/smoke/recovery_poll_5x_proof.mjs (new): 5 recovery
  cycles end-to-end, 70/70 PASS.

TEST RESULTS
- Backend Jest: 208/212 (4 pre-existing topicService.test.js failures,
  unrelated to this PR - confirmed via git stash + retest)
- Frontend Jest: 32/32
- Smoke scripts: 40+70 = 110/110 PASS

No temporary debug logs (grep for POLL-INSTRUMENT/TEMP-DEBUG: 0 matches).
No dead code. No commented-out code.
Bring in Phase 2 scale-up work (Redis adapter, multi-instance
broadcaster, server-side socket authorization, async question
generation via job queue, off-loop Whisper transcription, DB
indexes, non-blocking bcrypt, leaderboard batching, Phase 0/1/2A-
2D scale-up changes, login 429 fix, etc.) alongside the doubt-
anchored polling / topic-aware confusion analytics / student
recovery flow / poll lifecycle fixes already on this branch.

Conflicts resolved:
- backend/src/index.js: kept upstream verifyRoomOwner() auth
  on question:start / question:end / new_question handlers; kept
  this branch's resetPollState() helper + calls after the auth
  check so poll boundaries still reset confusion state + spam
  markers only for verified room owners.
- backend/src/services/questionService.js: took upstream entirely
  (it switches to api.minimaxi.chat, removes the local-heuristic
  fallback and the base_resp envelope handler, raises
  max_tokens to 8000, defaults question mix to all True/False,
  and adds parse-failure diagnostics). Orthogonal to the
  recovery-flow code on this branch.
- frontend/vite.config.js: kept upstream's stronger base
  resolution (process.env || loadEnv), preserved this branch's
  basePath-aware proxy keys + rewrite so requests under
  /spandan/api still hit the backend when VITE_BASE_PATH is set,
  and kept upstream's plain /api + /socket.io fallback for the
  no-basePath case.
Smoke scripts in backend/scripts/smoke/ were committed alongside the
BUG 1+2+3 fix in 32ec316, but the post-merge formatForClient() shape
and module layout broke them. This commit brings them back to green:

- poll_recovery_reset_proof.mjs: remove broken assertion on a field
  that doesn't exist in the request-feedback response shape (the
  underlying tally-wipe behavior is still asserted 2 lines below).
- fresh_room_topic.mjs: fix relative import path (./src/models/ ->
  ../../src/models/, since this script ships in the smoke/ subdir);
  switch topicLabel/topicSource reads to formatForClient's nested
  topic.{label,source} shape with a fallback for back-compat.
- recovery_flow.mjs: replace require('fs').writeFileSync(...) with
  await import('fs') ... since the file uses .mjs (ESM) extension.

Verified locally after the merge (14460f6):
- poll_lifecycle_proof.mjs: 40/40 PASS
- recovery_poll_5x_proof.mjs: 70/70 PASS
- poll_recovery_reset_proof.mjs: 9/9 PASS
- fresh_room_topic.mjs: PASS (no photosynthesis leak, topic resolved
  to 'Krebs cycle' via transcript fallback)
- recovery_flow.mjs: 6/6 PASS (Tests 1-5 all 200, Test 6 correctly
  rejects malformed eventId with 500)

Also refreshes package-lock.json after the clean npm install required
by the merge (added @socket.io/redis-adapter, bcrypt migration to
@node-rs/bcrypt, and other upstream Phase 0-2D deps).

No application code changes.
Self-contained guide for running Spandan locally with Ollama instead
of MiniMax. Includes: setup steps, env config, endpoint swap snippet,
test creds, smoke scripts, and the 6 most common Windows+Vite gotchas
encountered during this session.

For a future human or agent that picks up this repo without the
chat-history context.
Merged vicharanashala/main into PR vicharanashala#35 head to bring in 13 upstream commits
that landed since the PR was opened (head 084f0a6 -> 3f4d951).

Conflicts resolved:
- backend/src/index.js: additive merge (kept both route imports + app.use)
- frontend/src/index.css: additive merge (research CSS + Analytics/Toast/Feedback styles)
- frontend/src/pages/RoomDetailPage.jsx: kept both sides. Save-transcript-before-generate
  ordering preserved (required for auto-topic pipeline + topic-leak fix in commits
  da7efca/be962fd/f98f155). Added 'minWidth: 0' on transcription row flex.
- frontend/src/pages/StudentRoomPage.jsx: additive merge (kept ImLostButton +
  ConfusionResolvedPrompt from HEAD and useIsMobile + sessionEnded from upstream).

All preserved: Analytics page (/teacher/analytics/:roomId), ConfusionToast,
FeedbackCollector, Mark Resolved workflow, feedbackStats persistence.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants