feat: Live Student Doubts & Confusion Analytics Integration - #95
Open
surajmadhav wants to merge 59 commits into
Open
feat: Live Student Doubts & Confusion Analytics Integration#95surajmadhav wants to merge 59 commits into
surajmadhav wants to merge 59 commits into
Conversation
…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
…her Reply and Persistent History
- 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)
…eference, and roadmap
- 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.
…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.
…exact acknowledgement percentages
…exact percentage tracking architecture
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
🚀 Feature: 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.
2. Confusion Analytics (Authored by: Rashmi)
A frictionless, low-effort mechanism for students to signal that they are falling behind.
🛠️ Technical Implementation & Fixes
Advanced Anonymous Tracking
doubtSaltto 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)
Mapassociating uniquestudentHashvalues 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)
emailService.js. If the server is running locally (NODE_ENV !== 'production') and the.envlacks real SMTP credentials (or uses the default templateyour-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
HelpPage.jsxmanual with comprehensive instructions for both Teachers and Students on how to interact with the new Doubts and Analytics systems.✅ Verification