Commit Graph
13 Commits
Author SHA1 Message Date
wyucand杨慎 28ea48bba9 fix(storage): omit undefined object fields at persistence boundaries (#992)
* fix: canonicalize optional persistence fields

* fix: preserve document store method receivers

* fix: support immutable document stores

* fix: align persistence equality checks

* test: reset document store wrappers

* fix: normalize injected migration stores

---------

Co-authored-by: 杨慎 <117187635+cosarah@users.noreply.github.com>
2026-07-26 23:45:43 +08:00
3eea9dc542 feat(runtime): complete the #869 learner-data cutover — quiz + playback onto RuntimeStore (#955)
* feat(runtime): persist quiz attempts in RuntimeStore

* fix(runtime): coalesce quiz draft snapshots

* fix(runtime): recover concurrent quiz attempts

* fix(runtime): handle quiz completion races

* fix(runtime): commit quiz review atomically

* fix(runtime): dedupe concurrent quiz writes

* fix(runtime): drop stale quiz drafts

* fix: harden quiz runtime review recovery

* fix: serialize quiz attempt identity

* feat: read quiz state from runtime store

* fix: persist quiz retries before resetting

* fix: preserve authoritative quiz outcomes

* fix: preserve legacy quiz retries during cutover

* fix: reconcile legacy quiz snapshots safely

* fix: drain rollover quiz write queues

* fix: drain completed quiz retry queues

* fix: reuse concurrent quiz retries

* fix(quiz): preserve drafts across abrupt reloads

* fix(quiz): recover empty retry sessions

* fix(chat): abort stalled runtime state reads

* fix(quiz): expose queued phases to readers

* fix(quiz): retain concurrent writer tails

* fix(quiz): canonicalize retry branches

* fix(quiz): keep retry rollovers monotonic

* fix(quiz): validate skipped retry siblings

* fix(quiz): close read cutover races

* test(classroom): cover legacy quiz summaries

* fix(quiz): reset async consumers on scene changes

* fix(quiz): close scene transition windows

* test(pbl): cover launch freshness guards

* fix(quiz): close cutover concurrency gaps

* fix(quiz): harden retry and context freshness

* fix(quiz): reject malformed legacy answers

* fix(quiz): validate legacy answer values

* test(quiz): cover legacy multi-answer migration

* fix(merge): retire dead ChatRequestTemplate.storeState after quiz read cutover

Main's three call sites built static storeState blocks that runAgentLoopFn
never consumed (it always rebuilds fresh state via getStoreState); the quiz
read cutover replaced that callback with the async two-phase RuntimeStore
read, leaving the template field with zero consumers. Drop it.

* feat(storage): conform HTTP/PG backends and reference server to RuntimeAppendOptions

The quiz write path's expectedLastSeq / sessionTransition / RuntimeAppendConflictError
semantics existed only in the browser backend; server-backed deployments would
silently accept conflicting appends and leave completed sessions active. Forward
the options over the wire, detect conflicts atomically under the PG transaction,
map them to HTTP 409 RUNTIME_APPEND_CONFLICT, and rematerialize the typed error
client-side so quiz retry logic works across every backend.

Co-authored-by: Codex <codex@openai.com>

* fix(chat): Pi single requests build storeState via the async runtime quiz read

Pi bypasses runAgentLoop's per-iteration getStoreState and serializes the
request template straight to /api/chat/pi, which rejects bodies without
storeState. Extract the fresh-snapshot builder (async RuntimeStore quiz read
with the scene-transition guard) and call it on the Pi path too.

* ci: whitelist the runtime-data-cutover integration trunk for PR checks

* feat(runtime): playback cutover — cursor in KV, discussion facts in RuntimeStore (#956)

* feat(runtime): cut playback over to the runtime layer — cursor in KV, facts in RuntimeStore (#869)

The fourth and last runtime family. Consumed-discussion facts become
append-only 'playback' records folded into a set at read (at-least-once
appends, no conflict machinery); the resume cursor is device-scoped
last-write-wins KV per the amended #779/#869 split. sessionStorage keeps
same-tab priority; KV takes over on fresh tabs/reloads. The dead Dexie
playbackState machinery is retired, with a one-time lazy migration of any
legacy row (cursor half + facts half) before deletion, and stage deletion
now clears both the KV cursor and any unmigrated legacy row.

Co-authored-by: Codex <codex@openai.com>

* test(runtime): include playbackState in the stage-delete db mock

---------

Co-authored-by: Codex <codex@openai.com>

* fix(playback): persist discussion facts on every consumption path (#957)

* fix(playback): persist discussion facts on every consumption path (final-review P0+P1s)

- The engine now publishes a progress snapshot the moment a discussion is
  consumed (join / skip / unselected-agent auto-skip). onProgress otherwise
  fires before the discussion action executes and a discussion is the scene's
  last action, so the fact never reached persistence.
- Reads fold records across ALL playback sessions in the learner partition
  (mergeLearner deliberately preserves same-kind sessions from both keys).
- Legacy migration appends only not-yet-durable facts, so an interrupted
  migration resumes instead of dropping the tail.
- recordConsumedDiscussion reports durability; the component drops failed ids
  from its observed set so a later progress tick retries (at-least-once).

* test(e2e): live verification of the playback persistence chain

Seeds a deterministic stage straight into the Dexie DB, starts the lecture
via the canvas overlay, and asserts the full chain: discussion auto-skip
appends a discussionConsumed record to maic-runtime, the device cursor lands
in KV, and both survive a fresh browsing context (empty sessionStorage).

* refactor(playback): consumed-discussion state is volatile by decision — cursor-only persistence (#959)

Product ruling on #869's fourth family: playback learner state is front-end
ephemeral UX, not learner data. A re-shown proactive card auto-skips, joined
discussions' content already lives in chat runtime records, and no replay
export / analytics consumer exists — so durable facts bought nothing over
in-memory + same-tab sessionStorage. Drop lib/playback/runtime.ts and the
RuntimeStore facts wiring; keep the device-scoped KV resume cursor (the half
with real UX value), the engine's consumption-time progress snapshot (cursor
freshness), and the legacy Dexie retirement (cursor half migrates, row
deletes, consumed ids are dropped).

* fix(review): P3 pair from cross-review — scene-id boundary + sessionTransition 400 (#966)

* fix(review): scene-id boundary for quiz context + 4xx for malformed sessionTransition (P3 pair)

Review findings on #955: didActiveSceneRemainUnchanged compared the active
scene by object identity, so a store update reallocating the scene during
the async quiz read dropped the learner's graded answers from that turn's
request — the scene id is the real boundary. The records route now
classifies a malformed sessionTransition as a validation failure instead of
letting the store's throw surface as a 500.

* fix(playback): superseded-engine cursor guard + migration write-window recheck

Second-vendor review of the #959 shrink (requested after the cross-review
noted it had single-vendor coverage) found: an engine orphaned by a scene
switch during async lecture resume could pass the idle-only recheck, be
resurrected, and publish its old scene's progress over the new scene's
debounced cursor — the resume continuation now requires identity with the
installed engine, and onProgress drops snapshots from superseded engines.
The legacy cursor migration also rechecks KV immediately before its write
so a concurrent tab's newer cursor cannot be overwritten and orphaned by
the legacy-row delete.

Co-authored-by: Codex <codex@openai.com>

---------

Co-authored-by: Codex <codex@openai.com>

* fix(review): approval follow-up P3 nits (#967)

* release: v0.3.1

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(review): approval P3 nits — ISO gate on sessionTransition, dead mocks, corrupt-timestamp guard

- The records route's sessionTransition guard now requires an ISO
  updatedAt (isIsoTimestamp), matching the sibling PATCH /status route
- Dead vi.mock factories for the deleted playback-storage module dropped
- A corrupt legacy playback timestamp falls back to 'now' instead of
  wedging migration into a permanent re-throw that disabled resume

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Codex <codex@openai.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 15:28:04 +08:00
wyuc 65bf20a84b feat(runtime): cut chat sessions over to RuntimeStore (#926)
* feat(runtime): persist chat sessions in RuntimeStore

* fix(runtime): harden chat persistence ordering

* fix(runtime): protect cross-tab chat snapshots

* fix(runtime): compare structured chat values safely

* fix(runtime): preserve chat backup and reset semantics

* fix(runtime): restore imported chat snapshots

* fix(runtime): isolate chat load failures

* fix(runtime): make cache clearing lossless

* fix(runtime): normalize empty chat saves

* fix(runtime): serialize legacy chat migration

* fix(chat): serialize backup restoration

* test(chat): cover multi-stage restore locking

* fix(chat): coordinate fallback migrations across tabs

* fix(chat): harden cross-tab migration locking

* fix(database): retire abandoned chat lease schema

* fix(chat): preserve monotonic local edits

* fix(chat): protect unseen runtime sessions

* fix(chat): order lifecycle transitions monotonically

* fix(chat): serialize runtime maintenance

* fix(chat): order queued writes before maintenance

* fix(chat): preserve cross-version lock compatibility

* fix(chat): preserve streamed and backup data

* fix(chat): isolate empty no-lock saves

* fix(chat): isolate partial runtime snapshots

* fix(runtime): close cross-store cutover races

* fix(runtime): close remaining cutover races

* fix(chat): retain caller-visible conflict baseline

* fix(chat): preserve read-only legacy autosaves

* fix(chat): avoid restore lock inversion

* fix(runtime): enroll writes before maintenance

* fix(runtime): retain maintenance ordering

* fix(runtime): close restore and deletion races

* fix(chat): order partition queues within lock epochs

* fix(chat): preserve recovery and deletion intent

* fix(runtime): recover interrupted chat restores

* fix(backup): scope chat deduplication by stage

* fix(backup): stage chats by runtime partition

* fix(backup): clear staged chats with stages
2026-07-16 18:30:40 +08:00
wyucand杨慎 e601eaa3d5 feat(pbl): cut learner state over to RuntimeStore (#922)
Co-authored-by: 杨慎 <117187635+cosarah@users.noreply.github.com>
2026-07-14 19:37:38 +08:00
dd83ac8669 feat(pbl): read flip — fold learner state from RuntimeStore with snapshot backfill (#869 Part C, step 3) (#902)
* feat(pbl): read flip — fold learner state from RuntimeStore with snapshot backfill (#869 Part C, step 3)

Step 3 of #869 Part C: on stage load, PBL learner state is reconstructed
by folding RuntimeStore records over the design-time template. Records
are made self-sufficient at drain time (id-only outbox events gain
content attachments resolved from the in-hand project), pre-existing and
ring-evicted history is recovered through snapshot records, and the
document stays authoritative whenever the fold disagrees — with a
self-heal snapshot so the next load converges. The #893 dual-write and
document persistence are untouched; removing them is the next step.

- lib/pbl/v2/runtime/learner-state.ts: PBLLearnerState split with
  extract/apply/strip — codifies the document/runtime boundary
- lib/pbl/v2/runtime/record-payloads.ts: enriched event, engagement and
  snapshot record payload shapes (app-owned, versioned)
- lib/pbl/v2/runtime/fold.ts: seq-ordered, id-deduped, epoch-aware fold
  with gap diagnostics; snapshots supersede earlier gaps
- lib/pbl/v2/runtime/hydration.ts: catch-up drain, fold, shadow compare,
  document-authoritative fallback, deduped self-heal snapshots; never
  throws into the stage load path
- thread_compacted runtime event: instructor memory compression was a
  learner-state mutation with no event; deterministic id from the
  post-compaction thread shape
- equivalence property test: randomized reducer runs into a real
  BrowserRuntimeStore must fold back to extractLearnerState(project)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(pbl): harden read-flip fold and hydration after cross-review

- remove thread_compacted: instructor compaction mutates a per-request
  server copy after the last streamed patch, so neither the compacted
  thread nor the event ever reached the persisted client document —
  earlierSummary stays extracted for forward-compat only
- fold: apply the shared engagement ring cap; clear gaps on
  project_reset (matching the snapshot branch); default-branch gap +
  compile-time exhaustiveness for future event kinds
- hydration: preserve document-only transient fields (runtimeEvents,
  pendingOpenTaskPriorQuizResults) on fold-match; lodash isEqual instead
  of JSON.stringify equality; hydrate scenes in parallel; classroom
  server-fallback load path now hydrates too
- stage load: re-check the in-memory guard after async hydration so a
  concurrent setScenes is not clobbered by the older disk snapshot
- drain: serialize overlapping drains per (stage, scene, learner) so
  duplicate records are not appended
- drop write-only anchor.recordSeq and the 'test' reason variant;
  shared attachment-required kind set; single clone helper; strict
  enriched-payload assertion; learner-state field inventory test

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(pbl): close stale-load races and drain head-of-line blocking

- loadFromStorage bails when a different stage appears during the
  now-async hydration window, not just the same stage
- classroom server fallback re-checks stage identity after hydration
  before applying scenes
- each queued drain is individually time-bounded so a hung append
  cannot block the per-key drain chain forever
- unknown forward-version event kinds enrich into a valid payload
  (attachment null + reason) instead of a bare event

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(pbl): address review — no empty-scenes snapshot, truly serialized drains

- classroom fallback: hydrate first, then apply stage + scenes in the
  same synchronous update, so the debounced save can never persist
  { stage, scenes: [] } while hydration is in flight
- drain: the per-key chain now awaits the actual drain work (never the
  timeout race), and the work checks a cooperative deadline before each
  append and before watermark persistence — callers keep the bounded
  wait, overlapping same-key appends are impossible

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(store): only abort a load when navigation happens during hydration

The post-hydration guard compared the resident stage against the load
target, so a load started while another stage was legitimately resident
(direct loadFromStorage with no prior setStage) bailed out before the
legacy generationComplete self-heal ran. Snapshot the stage id at entry
and abort only when it changed to a different stage during the await.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(pbl): release a wedged same-key drain slot after a hard cap

The serialization chain linked on the raw work promise, so a single
appendRecord that never settles held the per-key slot forever. The
chain link now races the work against a hard cap (2x the drain
timeout): the cooperative deadline already prevents an expired drain
from appending further records or touching the watermark, so releasing
the slot lets the successor proceed while the worst case from the
straggler is one id-dedupable in-flight record.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(store): serialize scene-committing loads with a monotonic request token

Guard predicates over resident stage ids cannot order overlapping
loads from the same origin (load C then load D from resident B — the
later navigation lost). Every async scene-committing load now claims a
shared monotonic token at entry and commits only while still the
newest request; setStage and clearStore advance the token so sync
navigation invalidates pending loads. The classroom fallback shares
the same counter, so both entry points order against each other. The
same-stage in-memory scenes recheck stays: generation's setScenes is
not a tokened load.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(classroom): bind the server fallback to its navigation's load token

Claiming a fresh token at apply time laundered an old request into the
newest one: a stale /api/classroom response could overwrite a newer
navigation. The classroom page now claims one token at load start and
threads it through both the IndexedDB attempt and the server fallback;
the fallback helper only validates that origin token at commit.
loadFromStorage accepts the pre-claimed token, claiming internally for
existing callers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(classroom): cancel stale classroom loads across every async phase

The load-token guard only protected the scene commit; the rest of the
page load flow ran to completion for a superseded navigation, so a
stale run could merge the old stage's media tasks back into the shared
map, clear and repopulate the generated-agent registry over the newer
classroom, and drop the newer navigation's loading state. Extract the
orchestration into runClassroomLoad with an isCurrent() gate — the
effect-cleanup cancel flag combined with the load token — checked
after every await and before every shared-store write (restore, agent
save/load, settings, media kickoff, setError/setLoading). A superseded
run now performs zero further side effects.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(classroom): do not invalidate the current load token when committing fallback scenes

The fallback commit went through setStage(), which claims a fresh
scene-load token — so a legitimate fallback load invalidated its own
token, the post-commit isCurrent() check then returned false, and the
guarded finally skipped setLoading(false), leaving the page stuck on
the loading screen (E2E never saw the sidebar/rail). Commit fallback
stage+scenes via a token-free setState (applyClassroomStageAndScenes)
instead. Stale loads are still blocked by the token checks before the
apply; only the load's own commit no longer cancels itself.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(classroom): resume generation for the current classroom regardless of load token

The auto-resume generation effect was gating generateRemaining /
markGenerationCompleteIfDone / generateMediaForOutlines on the
scene-load token (via activeLoadTokenRef). By the time the effect ran
the token had advanced, so generation for the freshly-mounted
classroom was silently skipped — a deck arriving from generation-
preview with pending scenes never finished, leaving the Edit-course
toggle disabled (the two slide E2E specs timed out clicking it).

Resuming generation for the mounted classroom is not a competing
scene-load; navigation away is already handled by the effect's own
cancelled flag (which also stops()). Gate resume on cancellation only.
The load token still guards scene-committing loads (runClassroomLoad,
fallback apply, loadFromStorage), so the review-round race fixes stay.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(classroom): stop cancelling generation resume on benign effect re-runs

The generation-resume effect gained a cancelled-flag guard around
generateRemaining / markGenerationCompleteIfDone / generateMediaForOutlines.
generateRemaining has a new identity every render (useSceneGenerator's
useCallback depends on the options object, and the classroom passes a
fresh { onComplete } literal each render), so the effect re-runs on
every render and its cleanup trips cancelled. The kickoff runs inside an
async loadImageMapping().then(...), which resolves after a re-run has set
cancelled, so generation was silently skipped — the deck never left
'Generating...', the Edit-course toggle stayed disabled, and two slide
E2E specs timed out.

Restore the generation-resume effect to its prior unguarded form
(matches origin/main): navigation-away already stops the generator via
the load effect's stop() cleanup. The scene-load-token guards on the
actual load/commit paths (runClassroomLoad, fallback apply,
loadFromStorage) are untouched, so the review-round races stay fixed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(classroom): make generated-agent replacement atomic

* fix(classroom): clean up stale restored media

* fix(pbl): harden learner-state boundary

* fix(pbl): serialize hydration side effects

* fix(pbl): bound hydration drain barrier

* fix(pbl): bound queued hydration drains

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: 杨慎 <117187635+cosarah@users.noreply.github.com>
2026-07-14 16:31:39 +08:00
wyucandClaude Fable 5 5627b99156 feat(pbl): runtime-event outbox + dual-write into the RuntimeStore (#869 Part C, step 2) (#893)
* feat(pbl): emit typed runtime events from every learner-state mutation (#869)

The outbox half of the runtime split: pure operations append typed
PBLRuntimeEvents to project.runtimeEvents (ring-capped at 500, dedup by
id) for every learner-state mutation — messages, submissions,
evaluations, status changes across project/milestone/microtask/uiPhase,
handover and task-completion gates, and proficiency updates. Emission
stays inside the pure mutators (no I/O), so events cross the stateless
server boundary inside the project; a client-side drainer (next commit)
copies them into the RuntimeStore. Zero read-side changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(pbl): drain the runtime-event outbox into the RuntimeStore (#869)

Client-only drainer: after each persisted project change, copy new
runtimeEvents into the learner's active 'pbl' RuntimeSession (created
lazily), watermarked per stage through the device-scoped KV. Mid-drain
failures persist the last-success watermark and retry next tick; a
watermark evicted from the ring-capped ledger redrains visible events
(documented at-least-once — downstream folds dedup by event id). The
whole drain is bounded and never throws into the save path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(pbl): dual-write engagement events; end-to-end outbox coverage (#869)

The drainer now copies both in-project ledgers — runtimeEvents and
engagementEvents — into the learner's pbl RuntimeSession, with
independent watermarks that advance separately (a failure in one ledger
never loses the other's progress). End-to-end test drives real
operations through the reducers and drains into a real
BrowserRuntimeStore over fake-indexeddb, asserting the full ordered
event trail with correct anchors.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(pbl): harden the outbox drain per cross-review (#869)

- Watermarks scope to (stageId, sceneId, learnerKey): multi-scene stages
  and multi-learner devices no longer cross-contaminate or redrain.
- First-session creation is race-free: deterministic session id
  (pbl-<stageId>-<learnerKey>) with already-exists fallback plus an
  in-flight memo, so concurrent drains cannot split one history.
- A corrupt watermark value degrades to a fresh redrain and is repaired
  on the next persist, instead of permanently blocking the drain.
- Stage deletion clears its drain watermarks alongside the runtime rows.
- resetProjectProgress emits a project_reset epoch marker so folds never
  resurrect pre-reset submissions/evaluations from the preserved ledger.
- Normalization repairs use deterministic event ids, collapsing echoes
  from cloned-copy re-normalization under id-dedup.
- Docs: ring-overflow latent-loss window (dual-write accepts it; the
  read-flip backfills from a projectV2 snapshot) and the fold-baseline
  contract (status 'active' / uiPhase 'hero' from design-time defaults).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(pbl): carry server-minted events across the advance patch (#869)

Two holes on the server-patch path: applyAdvanceProjectPatch computed
status-change 'from' values after Object.assign had already replaced the
milestone snapshot (suppressing every real Instructor-advance event as
from===to), and events minted server-side during advanceMicrotask (e.g.
handover_staged) lived only on the transient server copy — the advance
patch carried no runtimeEvents, so the client ledger never drained them.
Patches now carry the operation's event delta; the client appends them
first, and both sides mint deterministic patch:* ids for the same
transition so echoes collapse under id-dedup. Server-side facts are
authoritative; client emissions remain as a compatibility fallback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(pbl): scope deterministic event ids to the reset epoch (#869)

Deterministic norm:/patch: status-event ids now lead with the count of
project_reset events visible in the ledger, so a learner repeating the
same transition after a reset is recorded instead of deduped against the
pre-reset event. The epoch is derived from the same visible window the
id-dedup scans, so ring-buffer eviction can never desynchronize the two.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(store): drain debounced saves before teardown (#869)

setStageAgents schedules debouncedSave + debouncedSaveAgents; tests that
finished without flushing left timers firing after environment teardown,
lazily importing the settings/providers chain into a torn-down runtime
(deterministic on CI once this branch grew the stage-storage import
graph). Store tests now run fake timers and assert zero pending timers
at teardown; the same latent race in the insert-scene-after and
generation-complete suites is fixed in the same style.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(pbl): address review — durable epoch, id-set deltas, ordered drain, post-save signal (#869)

- runtimeResetEpoch is a monotonic project field incremented on reset,
  never derived from the ring buffer, so evicting an old reset marker
  can no longer collapse post-reset transitions into pre-reset ids.
- Advance patches capture their event delta by id-set difference, not
  array index, so cap eviction mid-operation cannot drop server events.
- The drainer merges both ledgers into one timestamp-ordered stream
  (stable within each ledger) before appending, so RuntimeStore seq
  preserves global chronology instead of ledger-grouped order.
- Draining now subscribes to a post-save signal emitted after
  saveToStorage commits, instead of firing on the in-memory update —
  the dual-write can no longer outrun the source-of-truth persistence.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(pbl): drain the persisted snapshot, decoupled from scene mounting (#869)

The post-save signal now carries the PBL scenes exactly as persisted;
a module-level singleton subscription drains them, replacing the
component-ref hook. Navigating away before the debounced save fires no
longer strands the saved scene's outbox, and an in-flight save can no
longer drain a newer unsaved snapshot.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 00:12:53 +08:00
杨慎andwyuc 0f44e9a46f Add PBL v2 runtime APIs and classroom UI (#799)
* feat: add PBL v2 runtime UI

* fix: ship PBL v2 instructor avatar

* fix: ship PBL v2 workspace logo

* fix: prevent scenario PBL legacy fallback

* chore: restore package lint comments

* test: cover ordinary PBL legacy fallback

* fix: front-load PBL single-call validation

* fix: preserve PBL v2 content when assembling scenes

---------

Co-authored-by: wyuc <wang-yc24@mails.tsinghua.edu.cn>
2026-06-26 11:56:02 +08:00
杨慎 09863af421 Add PBL v2 core schema and generation path (#795)
* feat: add PBL v2 core planner

* chore: restore unrelated lint comments

* fix: document PBL v2 scenario outline flags

* fix: expose PBL scenario subtype in outlines

* chore: clear PBL v2 completion stats lint warning

* chore: address PBL instructor code quality comments

* fix: address PBL v2 review findings

* chore: restore unrelated lint comments
2026-06-25 18:44:46 +08:00
wyucand杨慎 e613b7578f [codex] add per-model thinking config (#494)
* add per-model thinking config

* Refine thinking model controls

---------

Co-authored-by: 杨慎 <117187635+cosarah@users.noreply.github.com>
2026-04-26 19:41:10 +08:00
wyucandClaude Opus 4.7 f40c92f1aa refactor(orchestration): migrate prompt builders to file-based templates (#459)
* refactor(prompts): move loader to lib/prompts/

Move file-based prompt loader from lib/generation/prompts/ to
project-level lib/prompts/ so it can be shared by orchestration
and PBL prompts in subsequent commits.

- Templates and snippets moved alongside the loader
- getPromptsDir() now points at lib/prompts/
- Generation pipeline imports updated to @/lib/prompts
  (outline-generator, scene-generator, search-query-builder,
   scene-outlines-stream route)
- Add tests/prompts/loader.test.ts as sanity gate

Pure file move — no behavior change.

* test(orchestration): add baseline snapshots for prompt builders

Snapshot tests cover variant matrix:
- buildStructuredPrompt: role × scene-type × peer/ledger/discussion/profile
- convertMessagesToOpenAI: mixed message kinds
- summarizeConversation: truncation
- buildDirectorPrompt: Q&A vs discussion, ledger, profile
- buildPBLSystemPrompt: default config

These lock current output so the body refactor in subsequent
commits can be verified byte-equal.

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

* test(orchestration): plug snapshot coverage gaps from review

- Split convertMessagesToOpenAI test: same-agent (existing) +
  cross-agent (new). The original test passed currentAgentId
  matching the message's agentId, so the cross-agent role-conversion
  branch was never exercised.

- Add 'teacher / quiz scene' snapshot to lock the spotlight/laser
  strip path in getEffectiveActions. The previous variant-7 test
  (whiteboard-open) only triggered the mutual-exclusion warning,
  not the strip — renamed it to reflect what it actually tests.

- Add assistant-role variant (was uncovered: ROLE_GUIDELINES,
  buildLengthGuidelines, buildWhiteboardGuidelines all branch on
  role).

* refactor(orchestration): extract summarizers from prompt-builder.ts

Move state-context, virtual whiteboard ledger replay, peer context,
message converter, and conversation summary out of the 890-line
prompt-builder.ts into focused modules under lib/orchestration/summarizers/.

prompt-builder.ts now only owns prompt assembly. director-graph
imports updated to the new locations.

Snapshot tests pass byte-equal — no behavior change.

* refactor(orchestration): move agent-system prompt to template file

The buildStructuredPrompt template literal is now
lib/prompts/templates/agent-system/system.md, assembled by a
thin variable-pass through the shared prompt loader.

Per-variant ternaries (format example, ordering, spotlight
examples, mutual-exclusion note) are kept as module-level
constants in prompt-builder.ts. ROLE_GUIDELINES,
buildLengthGuidelines, and buildWhiteboardGuidelines stay too
(they may further migrate to template partials in Phase 2).

Also: PROMPT_IDS gains a 'satisfies Record<string, PromptId>'
clause to prevent constant/union drift as more IDs are added.

Snapshot tests (12 prompt-builder + 4 director + 1 pbl) pass
byte-equal.

* refactor(prompts): align agent-system template with project conventions

- Rename agent-system placeholders snake_case → camelCase to match
  the convention already used by slide-actions/user.md and other
  generation templates. Settles convention before Task 5 lands two
  more templates that would otherwise inherit drift.

- Extract '## Speech Guidelines (CRITICAL)' into
  lib/prompts/snippets/speech-guidelines.md for reuse by director
  and PBL prompts in Task 5.

Snapshot tests pass byte-equal — placeholder name changes are
invisible in rendered output, and snippet inclusion happens at
load time.

* refactor(orchestration,pbl): move director + PBL prompts to templates

director-prompt.ts and pbl-system-prompt.ts now use the shared
template loader. Bodies collapse to thin variable assembly.

Three orchestration-tier prompts now share one infrastructure:
agent-system, director, pbl-design.

Snapshot tests pass byte-equal.

* refactor(orchestration): extract WhiteboardActionRecord + AgentTurnSummary to types module

These two interfaces are imported by 6+ modules including
summarizers/ — having them live in director-prompt.ts created
an awkward upward import direction (summarizers reaching back
to a sibling prompt builder for types).

Move them to lib/orchestration/types.ts and update all callers.
director-prompt.ts now imports them too.

Pure type-location refactor — snapshots pass byte-equal.

* test: remove snapshot tests in favor of eval-as-integration-test

Per review feedback: byte-equal snapshots were the wrong invariant
for this refactor. The goal was a stable substrate for editing
prompts, not a frozen contract. Snapshots would have generated
maintenance churn (~100-200 line diffs per intentional Phase 2
prompt tweak) without providing value the eval doesn't already.

Test posture going forward:
- tests/prompts/loader.test.ts (3 tests) — bounds the loader
  infrastructure (template load, snippet inclusion, variable
  interpolation, missing-id behavior)
- pnpm eval:whiteboard — bounds end-to-end agent loop, template
  composition, and integration with chat/director/state-manager

Removed: 1879 lines of .snap + 320 lines of test scaffolding +
fixtures. Net PR diff drops by ~2200 lines.

* docs(prompts): add README + structural assertion tests (review feedback)

Addresses two items flagged by PR #459 final review:

1. lib/prompts/README.md — conventions, syntax, gotchas, and local-testing
   recipe for template authors. Previously there was no doc for the stated
   goal of "non-engineers can edit prompts."

2. tests/prompts/templates.test.ts — 8 structural assertions covering:
   - no surviving {{...}} placeholders in any rendered template
   - role dispatch (teacher → LEAD TEACHER, student → not)
   - scene-type action stripping (quiz scene has no spotlight/laser)
   - director prompt output spec mentions next_agent

   These replace the removed byte-equal snapshot suite at much lower
   maintenance cost — they assert behaviors the refactor must preserve,
   not exact bytes.

* chore(prompts): relocate templates added on main during rebase

PR #461 (interactive mode clean) added 7 new templates under
lib/generation/prompts/templates/ (the pre-refactor location).
Rebase onto origin/main landed them at the old path; git's rename
detection didn't forward them through the directory move in db56c40.

Manually move them to lib/prompts/templates/ to match the new
convention, and fix scene-generator.ts's relative import
('./prompts/types' → '@/lib/prompts/types') since the dir no
longer exists as a sibling.

No behavior change — templates read from PROMPT_IDS values via
getPromptsDir() which already points at lib/prompts/.

* refactor(prompts): address review feedback from @cosarah

- loadSnippet now throws on missing file instead of silently returning
  a literal {{snippet:id}} string. A typo like {{snippet:speach-guidelines}}
  now fails at load time instead of reaching the LLM.

- README gains a "Still in TypeScript" section listing the role-conditional
  content that still lives in prompt-builder.ts (ROLE_GUIDELINES,
  buildLengthGuidelines, buildWhiteboardGuidelines) so contributors
  expecting a pure-markdown workflow know where to look.

- Expand tests/prompts/templates.test.ts to cover the conditional branches
  Phase 2 is most likely to touch (9 new assertions):
  - assistant role dispatch
  - peer-context section toggles on agentResponses presence
  - language constraint toggles on stage.languageDirective presence
  - director Q&A vs discussion mode branching
  - pbl-design {{issueCount}} substituted at all 3 occurrences
  - placeholder-naming-convention lint scans every template for
    non-camelCase placeholders (slide-content grandfathered)

- Comment above interpolateVariables regex documents why kebab-case
  placeholders pass through silently (the lint test now catches them).

- New test in loader.test.ts locks the throw-on-missing-snippet behavior.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 13:41:17 +08:00
杨慎andClaude Opus 4.6 470aa5e42e feat: inline language inference for outline and PBL generation (#412)
* feat: inline language inference in outline generation

Replace manual language selection with automatic inference from user
requirement text. The outline generator now produces a languageDirective
that propagates through the entire generation pipeline.

Key changes:
- Rewrite Language Inference section in outline prompt with decision
  rules for foreign language learning, cross-language PDF, proxy
  requests, and terminology handling
- Reorder pipeline: outlines before agents, so agent profiles can
  use the inferred languageDirective
- Pass languageDirective through scene content/actions generation
- Add SSE streaming of languageDirective from outline generation
- Remove manual language selection UI

Eval test suite:
- 50 test cases covering 9 scenario types: single-language, language
  learning, immersive, explicit instruction, code-switching, minimal
  input, user profiles, cross-language PDF, locale mismatch
- LLM-as-judge evaluation with configurable inference/judge models
- 50/50 pass rate with gemini-3-flash-preview + gpt-4o judge
- Excluded from CI (requires LLM API keys)

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

* chore: remove useless attributes in test case

* feat: pass languageDirective to PBL, remove hardcoded zh-CN/en-US

- Remove pblConfig.language field, use languageDirective from outline
  inference instead
- Delete all Chinese prompt duplicates in pbl-system-prompt.ts,
  agent-templates.ts; keep English templates with languageDirective
  injection
- Remove zh-CN/en-US branches in generate-pbl.ts (initial prompt,
  post-process context, welcome message)
- Pipe languageDirective through scene-generator → generatePBLContent
  → IssueboardMCP → agent templates
- Remove i18n template wrapping from PBL welcome messages in
  pbl-renderer.tsx and use-pbl-chat.ts; use LLM-generated content
  directly to avoid language mismatch when UI locale ≠ course language
- Update pblConfig schema in outline prompt (remove language field)

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

* refactor: clean up legacy language fields, pass languageDirective through buildSceneFromOutline

- Add languageDirective param to buildSceneFromOutline, combining with
  outline.languageNote via buildLanguageText
- Remove dead types: AudienceProfile, StylePreferences, LegacyUserRequirements,
  SceneOutline.language
- Remove Stage.language and all its propagation (storage, stageInfo, prompt-builder fallback)
- Remove GenerateClassroomInput.language and job store inputSummary.language
- Remove unused pdfLanguageSample from outline SSE route

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

* fix: persist languageDirective to IndexedDB, fix TTS preview and dead refs

- Add languageDirective to StageRecord and stage-storage write path
- Add DB v9 migration: convert legacy language locale codes to directives
- Use voice.language for TTS preview text instead of dead localStorage key
- Omit empty ## Language section in PBL agent prompts
- Use JSON.parse for extractLanguageDirective unescape (\uXXXX support)
- Remove dead i18n keys: toolbar.languageHint, pbl.chat.welcomeMessage

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 21:49:56 +08:00
wyucandClaude Opus 4.6 843d33e5cc chore: enforce Prettier formatting and fix lint issues
- Add .prettierignore to exclude vendor packages, lock files, markdown, and YAML
- Update .prettierrc: printWidth 100, singleQuote, trailingComma "all"
- Run Prettier across all source files for consistent formatting
- Fix unused imports (UserRequirements, setTTSProvider)
- Fix eslint-disable comment placement after Prettier reformat

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 16:09:45 +08:00
wyucandClaude Opus 4.6 0d20abfed9 Initial commit: OpenMAIC AI classroom platform
Next.js 16 App Router application with:
- AI-powered interactive classroom generation from PDF/text requirements
- Multi-agent discussion system (teacher, students, assistant roles)
- Provider abstraction for LLM, TTS, ASR, image, video, web search
- Local-first data architecture (IndexedDB/Dexie)
- i18n support (zh-CN/en-US)
- Structured logger, SSRF guard, lint/build passing (0 errors)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 16:41:07 +08:00