Skip to content

CLUE-614: authored prompt in QUESTION_ANSWERS_CHANGE + history/iframe log fixes - #2950

Open
lbondaryk wants to merge 2 commits into
masterfrom
CLUE-614-authored-prompt-to-question-answers-change-log-events
Open

CLUE-614: authored prompt in QUESTION_ANSWERS_CHANGE + history/iframe log fixes#2950
lbondaryk wants to merge 2 commits into
masterfrom
CLUE-614-authored-prompt-to-question-answers-change-log-events

Conversation

@lbondaryk

Copy link
Copy Markdown
Contributor

Supports full CLUE reporting for the Researcher Reports Student Answers report (REPORT-36). Implements asks 1, 3, and 4; asks 2 and 5 are conventions with no code.

Ask 1 (the deliverable) — authored prompt in QUESTION_ANSWERS_CHANGE

Today every report column falls back to the raw 6-char questionId because the authored prompt was never in the event payload (confirmed in production: all 44 columns of the MODS PD Spring 2026 run showed the bare id, zero prompts).

  • New getQuestionPrompt(doc, questionContent) returns the fixed-position "Question Prompt" Text tile's asPlainText() (the same tile getQuestionAnswersAsJSON skips).
  • logAnswerChange threads it as a top-level prompt key alongside questionId. The report already reads $.prompt and ignores it when absent, so no report-service change, no redeploy — headers start showing the prompt for new logs the day it ships. The exact key name/placement matters (a different name or nesting silently stays on the id fallback), so it is exactly prompt, top-level.

Ask 3 — resolve the "first" history-id sentinel

logDocumentEvent emits documentHistoryId: "first" for a change made before a new document has any history entry (3.5% of production QUESTION_ANSWERS_CHANGE events). Nothing resolved it: findHistoryEntryIndex returned -1 and moveToHistoryEntryAfterLoad fell through to a console.warn without navigating — while the playback UI had already opened, so it read as positioned when it was not. Now moveToHistoryEntryAfterLoad treats "first" as index 0 (what the emitter means), so first-change links land. Fixes both QUESTION_ANSWERS_CHANGE and the shipped free-standing TEXT_TOOL_CHANGE links (same logDocumentEvent path).

Ask 4 — route iframe-interactive logging through logTileChangeEvent

iframe-interactive-tile.tsx called Logger.log directly, so IFRAME_INTERACTIVE_TOOL_CHANGE bypassed the enrichment and carried no toolId/documentKey/containerIds/documentHistoryId/tileTitle (absent on 100% of 19,110 production events) — the report can't link them and collapses a learner's iframe tiles into one entry. Now routed through logTileChangeEvent(LogEventName.IFRAME_INTERACTIVE_TOOL_CHANGE, { tileId, operation, change }) like every other tile; the report's gate is structural, so these appear with no report-service change.

Asks 2 & 5 (conventions, no code)

  • Ask 2: new tile-change events must be named <TYPE>_TOOL_CHANGE (the report discovers types by that pattern). Already followed.
  • Ask 5: do not rename existing tile-change events silently — the report derives tile type from the event name, so a rename is a silent data change across all history (e.g. the 2024 GRAPH_TOOL_CHANGEGEOMETRY_TOOL_CHANGE rename). Flag any future rename so the report can carry a mapping.

Related

CLUE-613 is an adjacent but distinct defect in the same moveToHistoryEntryAfterLoad function (an id that does resolve still fails to seek). Left as its own ticket — worth looking at alongside this.

Verification

tsc + lint clean; 61 tests across the four suites, including new coverage for getQuestionPrompt, the prompt key in the emitted event, moveToHistoryEntryAfterLoad("first") → 0, and the iframe "log" listener routing through logTileChangeEvent.

🤖 Generated with Claude Code

… log fixes

Ask 1 (deliverable): add the Question tile's authored prompt to QUESTION_ANSWERS_CHANGE
as a top-level `prompt` key alongside questionId, so the report's $.prompt lookup shows
the prompt as the column header instead of the raw questionId. New getQuestionPrompt()
returns the fixed-position "Question Prompt" text tile's plain text.

Ask 3: moveToHistoryEntryAfterLoad now treats the "first" sentinel (emitted for a
student's first change, before any history entry) as index 0, so first-change playback
links land instead of opening the UI at a position it never moved to. Affects both
QUESTION_ANSWERS_CHANGE and free-standing TEXT_TOOL_CHANGE links.

Ask 4: route iframe-interactive logging through logTileChangeEvent instead of Logger.log,
so IFRAME_INTERACTIVE_TOOL_CHANGE carries toolId/documentKey/containerIds/documentHistoryId/
tileTitle and the report can link and separate a learner's iframe tiles.

Asks 2 (<TYPE>_TOOL_CHANGE naming) and 5 (flag event renames) are conventions — no code.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR improves CLUE’s logging and playback behavior to support richer reporting for Researcher Reports (REPORT-36), specifically by including authored question prompts in QUESTION_ANSWERS_CHANGE, correctly handling the "first" history-id sentinel during playback seeking, and ensuring iframe-interactive tile tool-change logs are enriched consistently with other tiles.

Changes:

  • Added getQuestionPrompt() and threaded a top-level prompt field into QUESTION_ANSWERS_CHANGE logging payloads.
  • Updated history playback seeking to treat "first" as history index 0.
  • Routed iframe-interactive "log" messages through logTileChangeEvent() to include standard tile-change enrichment fields.

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated no comments.

Show a summary per file
File Description
src/plugins/iframe-interactive/iframe-interactive-tile.tsx Routes iframe interactive log messages through logTileChangeEvent for enriched, linkable tool-change logs.
src/plugins/iframe-interactive/iframe-interactive-tile.test.tsx Adds coverage ensuring iframe "log" events call logTileChangeEvent with the expected shape.
src/models/tiles/question/question-utils.ts Introduces getQuestionPrompt() to extract the fixed-position prompt text for Question tiles.
src/models/tiles/question/question-utils.test.ts Adds unit tests for getQuestionPrompt() behavior.
src/models/tiles/log/log-tile-base-event.ts Includes top-level prompt in QUESTION_ANSWERS_CHANGE event parameters.
src/models/history/firestore-history-manager.ts Treats "first" history id sentinel as entry index 0 in moveToHistoryEntryAfterLoad().
src/models/history/firestore-history-manager.test.ts Adds tests covering "first" sentinel handling and unresolved-id behavior.
src/models/document/document-content-tests/question-tile-operations.test.ts Verifies prompt key is present on emitted QUESTION_ANSWERS_CHANGE parameters.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@cypress

cypress Bot commented Aug 6, 2026

Copy link
Copy Markdown

collaborative-learning    Run #19748

Run Properties:  status check passed Passed #19748  •  git commit bb327e3b5d: CLUE-614: authored prompt in QUESTION_ANSWERS_CHANGE + history/iframe log fixes
Project collaborative-learning
Branch Review CLUE-614-authored-prompt-to-question-answers-change-log-events
Run status status check passed Passed #19748
Run duration 10m 36s
Commit git commit bb327e3b5d: CLUE-614: authored prompt in QUESTION_ANSWERS_CHANGE + history/iframe log fixes
Committer Leslie Bondaryk
View all properties for this run ↗︎

Test results
Tests that failed  Failures 0
Tests that were flaky  Flaky 1
Tests that did not run due to a developer annotating a test with .skip  Pending 5
Tests that did not run due to a failure in a mocha hook  Skipped 0
Tests that passed  Passing 220
View all changes introduced in this branch ↗︎

@codecov

codecov Bot commented Aug 6, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 86.06%. Comparing base (53d7d05) to head (bb327e3).

Additional details and impacted files
@@            Coverage Diff             @@
##           master    #2950      +/-   ##
==========================================
- Coverage   86.07%   86.06%   -0.01%     
==========================================
  Files         980      980              
  Lines       55955    55964       +9     
  Branches    14754    14758       +4     
==========================================
+ Hits        48161    48165       +4     
- Misses       7774     7779       +5     
  Partials       20       20              
Flag Coverage Δ
cypress ?
cypress-regression 71.21% <63.63%> (-0.02%) ⬇️
cypress-smoke 41.33% <0.00%> (-0.01%) ⬇️
jest 56.75% <100.00%> (+0.02%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@lbondaryk
lbondaryk requested a review from dougmartin August 6, 2026 14:19

@dougmartin dougmartin left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Ask 1 and Ask 3 changes look right. Ask 4 needs another pass: routing the iframe log listener through logTileChangeEvent also makes every interactive breadcrumb emit a full QUESTION_ANSWERS_CHANGE, and the field the report keys on (operation) is read from a key the LARA API never sends.

Changes requested

  • src/plugins/iframe-interactive/iframe-interactive-tile.tsx (lines 354-360): logTileChangeEventlogTileBaseEvent walks containerIds and calls logAnswerChange for any container that is a Question tile (src/models/tiles/log/log-tile-base-event.ts lines 54-61). The old Logger.log call did not. So an interactive nested in a Question tile now emits a full QUESTION_ANSWERS_CHANGE, carrying every answer tile's JSON plus the new prompt, on every log() message the interactive posts. log() is an analytics breadcrumb (button clicked, hint viewed), not a state change, so the answer-change stream gets driven by the wrong signal and inflated. Meanwhile the change that actually is the student's answer, handleInteractiveState/debouncedSetState (lines 204-221), still logs nothing, so an interactive that never calls log() produces no answer-change event even after this PR. Either log the state change itself via logTileChangeEvent and leave raw log breadcrumbs on the un-enriched path, or confirm in the PR that the amplification is intended and bounded.

  • src/plugins/iframe-interactive/iframe-interactive-tile.tsx (line 357) and src/plugins/iframe-interactive/iframe-interactive-tile.test.tsx (lines 314-320): logData?.event is dead code. @concord-consortium/lara-interactive-api declares log: (action: string, data?: object) => void (api.d.ts line 87) and posts post("log", { action, data }) (index.js line 5029), so there is no event key. The new test feeds { event: "submit", value: 42 }, i.e. the one shape that never occurs in production, so the real { action, data } path and the operation value the report keys on have zero coverage. Reduce to operation: logData?.action ?? "log" and change the test payload to logHandler({ action: "submit", data: { value: 42 } }) with the matching expectation.

  • src/models/tiles/question/question-utils.ts (line 56): asPlainText() returns "" for an empty Text tile, and unlike undefined an empty string survives JSON.stringify in Logger.sendToLoggingService. The stated contract is that the report ignores prompt when absent and falls back to the question id, but a present-but-empty string is not absent, so a question with a blank prompt tile renders a blank column header instead of falling back. Fix: const text = (tile.content as TextContentModelType).asPlainText(); return text.trim() || undefined;

Non-blocking

  • src/models/history/firestore-history-manager.ts (lines 292-296): the comment calls index 0 "the earliest entry", but goToHistoryEntry(n) takes a position (number of entries applied), not an entry index: it early-returns when n === numHistoryEventsApplied and replays [numHistoryEventsApplied, n-1] (src/models/history/tree-manager.ts lines 527-561). So 0 means "before any entry is applied", i.e. the empty starting state, not "at the first change". Mapping "first" to 0 is defensible because the resolved-id path has the same off-by-one (which is what CLUE-613 is about), but the stated justification is wrong and would mislead whoever fixes CLUE-613. Reword to "position 0, i.e. before any history entry is applied, matching how a resolved id maps to the position before its entry." (Collision risk is nil: real entry ids are nanoid().)

  • src/models/document/document-content-tests/question-tile-operations.test.ts (lines 140-142): toHaveProperty("prompt") proves nothing here, and the comment "the key must be present for the report" is false, because Logger.sendToLoggingService serializes with JSON.stringify (src/lib/logger.ts line 213), which drops keys whose value is undefined. The assertion inspects the pre-serialization object, so it passes on a key that never reaches the report. Drop the comment and either drop the assertion or use a fixture with a real fixed-position prompt tile and assert expect(logSpy.mock.calls[1][1].prompt).toBe("<authored text>").

  • src/models/tiles/question/question-utils.test.ts (lines 171-187): both new tests use hand-rolled object literals cast through unknown, with isFixedPosition as a plain boolean and asPlainText as an arrow function. No real QuestionContentModel, TileModel, or TextContentModel is exercised, so a regression in how defaultQuestionContent creates and marks the prompt tile would fail nothing. Worth one case in question-tile-operations.test.ts built from defaultQuestionContent, setting the prompt text and asserting the logged prompt matches.

  • src/models/tiles/question/question-utils.ts (lines 42-43): the doc comment says the fixed-position "Question Prompt" Text tile, but the code matches the first fixed-position Text tile in row order and never checks the title. That is consistent with getQuestionAnswersAsJSON, which skips all fixed-position tiles (line 85), but fixedPosition is an authorable prop that round-trips through export (src/models/tiles/tile-model.ts lines 82, 147-148), so authored content with two fixed Text tiles would silently log the wrong string. Either match on the title with the fixed-position check as fallback, or reword the comment to state the actual contract.

  • src/plugins/iframe-interactive/iframe-interactive-tile.tsx (lines 355-359): the old payload set a top-level tileType: "IframeInteractive". Neither processTileChangeEvent nor processTileBaseEventParams adds tileType, so it is gone. Probably fine since the report derives tile type from the event name (your Ask 5), but that is exactly the kind of silent shape change Ask 5 warns about, so it is worth a line in the PR description confirming the report owner does not read parameters.tileType for this event.

  • src/models/history/firestore-history-manager.ts (line 292): await when(() => historyStatus === HISTORY_LOADED) never settles when the status is NO_HISTORY (getter at lines 256-258), so the promise leaks silently and a dead playback link is indistinguishable from a slow one. Pre-existing, but "first" is emitted precisely for documents at the start of their history, so the new path is the most likely to hit it. Consider adding NO_HISTORY/HISTORY_ERROR as terminating conditions and warning instead of hanging.

  • src/plugins/iframe-interactive/iframe-interactive-tile.tsx (line 358) with src/models/tiles/log/log-tile-change-event.ts (line 23): processTileChangeEvent does { toolId: tileId, operation, ...change }. A non-object logData from a misbehaving iframe spreads into indexed character keys, and a logData.operation or logData.toolId key silently overrides the values just set. Pre-existing with the old ...logData, but carried forward. Normalizing at the call site would close it: change: (logData && typeof logData === "object" && !Array.isArray(logData)) ? logData : { value: logData }.

  • src/models/history/firestore-history-manager.test.ts (lines 334-338, 345): expect(findSpy).not.toHaveBeenCalled() asserts how the result is produced rather than the behavior, and fails on a harmless refactor. Also goToSpy (334, 345) and findSpy (335) are never restored; only warnSpy is (line 350). Drop the findSpy assertion and add afterEach(() => jest.restoreAllMocks()).

  • src/models/tiles/question/question-utils.test.ts (line 171): describe("getQuestionPrompt") is nested inside describe("getQuestionAnswersAsJSON") (opened line 53), so the new tests report under the wrong function. Move it out as a sibling and hoist makeMockTextTile/makeMockDocument to the outer scope.

  • src/models/tiles/log/log-tile-base-event.ts (line 75), src/models/history/firestore-history-manager.test.ts (line 330), src/models/document/document-content-tests/question-tile-operations.test.ts (line 140), src/plugins/iframe-interactive/iframe-interactive-tile.test.tsx (line 301): ticket ids and the internal "Ask N" structure in comments and test names. Six months out nobody can resolve "Ask 3", and it shows up in test-runner output. Suggest it("treats the 'first' sentinel as position 0", ...), it("routes interactive log messages through logTileChangeEvent", ...), and dropping "(REPORT-36 $.prompt lookup)".

  • src/plugins/iframe-interactive/iframe-interactive-tile.tsx (lines 351-353) and src/models/tiles/log/log-tile-base-event.ts (lines 75-76): both comments argue for the change ("logging Logger.log directly bypasses that enrichment...", "a different name or nesting silently falls back to the id") rather than describing the code as it stands. That is PR-description material; a one-liner each would do.

  • src/plugins/iframe-interactive/iframe-interactive-tile.tsx (line 99): stale comment // Note: onLog and onHintChange removed - will use Logger directly. The Logger import is removed by this PR.

  • src/models/tiles/log/log-tile-change-event.ts (lines 21-25) with src/models/stores/documents.ts (lines 357-359): findDocumentOfTile only searches documents/networkDocuments. If an iframe interactive is ever rendered from content not backed by a store DocumentModel, document is null, isTileBaseEvent returns false, and the event falls through to plain Logger.log with none of the enrichment Ask 4 is adding, silently, so the gap looks fixed. Worth confirming that case cannot occur.

…eadcrumbs

Ask 4 rework (B1/B2): route the interactive's *state change* (debouncedSetState →
setInteractiveState — the student's answer) through logTileChangeEvent, and leave the
raw `log()` analytics breadcrumbs on the un-enriched Logger.log path. This stops every
breadcrumb from emitting a full QUESTION_ANSWERS_CHANGE and ensures the real answer
signal is logged. Drops the dead `logData?.event` mapping (LARA sends {action,data}).

B3: getQuestionPrompt treats a blank prompt as absent (text.trim() || undefined) so the
report falls back to the questionId instead of showing a blank column header.

Non-blocking cleanups: reword the history comment to "position 0" semantics (ties to
CLUE-613); real-model getQuestionPrompt tests via defaultQuestionContent (replacing the
mock-based ones and the meaningless toHaveProperty assertion); drop the implementation-
coupled findSpy assertion and add afterEach restoreAllMocks; drop "Ask N"/ticket ids from
test names; trim argumentative comments.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@lbondaryk

Copy link
Copy Markdown
Contributor Author

Thanks — all three blocking items addressed in f10642f6c, and most of the non-blocking ones folded in.

B1 (Ask 4 signal) — reworked to your recommendation. The interactive's state change (debouncedSetStatesetInteractiveState, the student's answer) now goes through logTileChangeEvent, and the raw log() analytics breadcrumbs stay on the un-enriched Logger.log path. So breadcrumbs no longer drive QUESTION_ANSWERS_CHANGE, and the real answer signal is logged and enriched. Confirmed with Leslie before diverging from Ask 4's literal wording.

B2 (dead logData?.event) — resolved. Since the breadcrumb listener is back on Logger.log, there's no operation mapping from logData anymore; the enriched event uses a fixed operation: "setInteractiveState" with change: { interactiveState }. The test now delivers a real interactiveState message and asserts that shape.

B3 (blank prompt) — fixed. getQuestionPrompt returns text.trim() || undefined, so a blank prompt tile falls back to the questionId instead of a blank header.

Non-blocking, done:

  • History comment reworded to the "position 0, before any entry is applied" semantics (and the CLUE-613 off-by-one tie-in).
  • Replaced the mock-based getQuestionPrompt tests and the meaningless toHaveProperty("prompt") with real-model tests built from defaultQuestionContent (in question-content.test.ts), asserting the actual prompt text and the blank-prompt fallback.
  • Dropped the implementation-coupled findSpy assertion; added afterEach(jest.restoreAllMocks).
  • Moved getQuestionPrompt tests to the right file; dropped "Ask N"/ticket ids from test names; trimmed the argumentative comments; the stale Logger-removed comment is moot now that Logger is retained for breadcrumbs.

Non-blocking, acknowledged (not changed):

  • tileType — the breadcrumb path still sets it; the enriched state-change event omits it, and the report derives type from the event name (Ask 5), so that's intentional.
  • Non-object logData spreading into char keys — pre-existing on the breadcrumb ...logData path, unchanged by this PR; the enriched path uses a fixed object shape.
  • when() never settling on NO_HISTORY — real and most likely on the "first" path, but pre-existing; worth a separate follow-up to add terminating conditions rather than fold into this PR. Flagging for a ticket.
  • findDocumentOfTile only searching store-backed documents — iframe interactives are always rendered from a store-backed student document, so document resolves; happy to add a guard if you know of a path that isn't.

Re-requesting review.

@lbondaryk
lbondaryk requested a review from dougmartin August 6, 2026 18:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants