THE MESSAGE IS THE UNIT OF COVERAGE (vision-extraction-coverage.md): a per-chat append-only ledger (coverage.jsonl) records which messages the pipeline has extracted, by content fingerprint over exactly what the extraction view sees (one shared line builder — core/coverage.ts); the uncovered remainder is DERIVED at read time, never dual-tracked. A quiz answer merging into the asking message re-opens only that message; plan-block data-part attach never re-opens anything. - The trigger (turn-service) replaces every-3-quiz-answers with uncovered-tokens >= EXTRACTION.chunkTokens (4096) — quiz-less sessions extract too, content size is the honest signal. - runExtraction is now chunked: token-budgeted batches of the oldest uncovered consecutive messages (a tool call + result never split), each merged batch checkpointing its messages' coverage atomically — an abort or error loses at most the in-flight batch; merged batches stay covered. - Observation refs are batch-tagged (obs:<batchTag>:<sourceId>): re-runs of the same batch dedupe; a re-opened message's fresh understanding lands as a new event. Fixes the old obs:p1 position-collision. - New API: GET /api/graph/coverage (exact per-session derived coverage + running flags, most-uncovered first) and POST /api/graph/coverage/extract (manual run for one session, 409 while in flight). - Graph view chrome: the coverage button opens a panel with every session's covered/uncovered tokens and a per-session Extract button — the manual answer to aborted runs, errors, and dormant tails. Renders even when the graph is empty: an empty graph with uncovered sessions is exactly when it matters. Polls only while open. - Notes imports keep the whole-file checkpoint path (coverage undefined). - Tests: pure-core coverage suite (11), chunked-pipeline suite (3: multi-batch + no-op, abort-keeps-earlier-batches + remainder re-run, re-open alone + data-part invisibility), panel render suite (5); pendingQuizAnswers and its tests deleted as dead code. 486/486. Live-smoked on real GLM: manual extract of "teach me modal realism" ran one batch, wrote 4 coverage entries, panel flipped to "1.3k covered · complete" with the button disabled, graph refetched (nodes 11), usage persisted with real stats.
126 lines
5.8 KiB
TypeScript
126 lines
5.8 KiB
TypeScript
/**
|
|
* The chat-turn services — one named, error-contained seam per turn
|
|
* responsibility, extracted from the chat-handler so it composes instead of
|
|
* implementing. Each takes its dependencies explicitly (storage ports, the
|
|
* job runner) — no module here imports an adapter.
|
|
*
|
|
* persistTurn — the canonical state write (client + agent messages)
|
|
* triggerExtraction — the graph write channel: fires the extraction
|
|
* pipeline (the sole graph writer) once a chat
|
|
* crosses the v1 quiz-answer threshold. The trigger
|
|
* mechanism is a v1 placeholder — this seam is the
|
|
* ONLY thing to swap when a better one lands.
|
|
*
|
|
* Logs are the minimal-observability baseline: one line per failure, tagged
|
|
* [chat <id>] — structured logging lands behind these seams later.
|
|
*/
|
|
|
|
import type { UIMessage } from "ai";
|
|
import { withPlanBlocks } from "../core/plan-blocks.ts";
|
|
import { coverageOf } from "../core/coverage.ts";
|
|
import type { ChatStore, GraphStore } from "../core/ports.ts";
|
|
import { EXTRACTION } from "../../config.ts";
|
|
import type { ExtractionPipeline } from "../agent/subagents/extraction.ts";
|
|
import { startGraphContextInjection, type GraphContextDeps } from "../agent/subagents/graph-context.ts";
|
|
|
|
/**
|
|
* The canonical state write: the client owns the message list between turns
|
|
* (useChat); this persists client messages + the agent response. Persists
|
|
* even when aborted — the partial transcript (including any pending
|
|
* question part) is what makes resume work.
|
|
*/
|
|
/**
|
|
* The SDK assigns assistant-message ids CLIENT-side (useChat); the server's
|
|
* onFinish synthesis leaves them EMPTY. A message persisted only server-side
|
|
* (the last turn's, or a wake turn's — nobody re-POSTs it with a client id)
|
|
* would otherwise land in the record with id "" — and two of those in one
|
|
* transcript break React keys (the chat map) and steer-dedup lookups. Fill any
|
|
* missing id here, at the single canonical write: client-authored ids are
|
|
* NEVER touched (the client's next POST still carries them verbatim).
|
|
*/
|
|
export function withMessageIds(messages: UIMessage[]): UIMessage[] {
|
|
const filled = messages.map((m) =>
|
|
m.id ? m : { ...m, id: `msg_${Date.now().toString(36)}_${crypto.randomUUID().slice(0, 8)}` },
|
|
);
|
|
// Same id twice = the SDK's continuation merge couldn't happen in place
|
|
// (continuation coalescing appends a user-role steer AFTER the assistant
|
|
// message being continued, so onFinish carries the input copy AND the
|
|
// extended response copy under one id). Keep the LAST occurrence — it is
|
|
// the SDK's final merge state (a superset of the earlier copy).
|
|
const seen = new Set<string>();
|
|
const deduped: UIMessage[] = [];
|
|
for (let i = filled.length - 1; i >= 0; i--) {
|
|
const m = filled[i]!;
|
|
if (seen.has(m.id)) continue;
|
|
seen.add(m.id);
|
|
deduped.unshift(m);
|
|
}
|
|
return deduped;
|
|
}
|
|
|
|
export function persistTurn(chatStore: ChatStore, chatId: string, messages: UIMessage[], isAborted: boolean): void {
|
|
try {
|
|
const deduped = withMessageIds(messages);
|
|
// Plan-block emission (wave 4): blocks are frozen transcript content —
|
|
// attached here, once, at the canonical state write. The tree was
|
|
// saved by the plan tool during the turn; snapshots derive now.
|
|
const tree = chatStore.getChat(chatId)?.tree;
|
|
chatStore.saveMessages(chatId, withPlanBlocks(deduped, tree));
|
|
} catch (err) {
|
|
console.error(`[chat ${chatId}] persist failed:`, err, { isAborted });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The graph write channel (vision §1 construction): the extraction pipeline
|
|
* (extractor → auditor → merger — the sole graph writer) runs ASYNC once a
|
|
* chat's UNCOVERED transcript tokens reach EXTRACTION.chunkTokens
|
|
* (vision-extraction-coverage.md, 2026-09-15 — replacing the old
|
|
* every-3-quiz-answers count: content size is the honest signal, and
|
|
* quiz-less sessions extract too). The pipeline itself extracts only the
|
|
* uncovered batches. Fire-and-forget — never blocks the turn; failures are
|
|
* logged by the pipeline and leave coverage untouched, so the next trigger
|
|
* (or the manual extract button) re-runs what's left.
|
|
*
|
|
* Deliberately isolated in this one seam — the pipeline is
|
|
* trigger-agnostic. Skipped under tests: scripted chats are fixtures, not
|
|
* knowledge (the pipeline itself is tested directly with mock models).
|
|
*/
|
|
export function triggerExtraction(
|
|
deps: { chatStore: ChatStore; graphStore: GraphStore },
|
|
extraction: ExtractionPipeline,
|
|
chatId: string,
|
|
): void {
|
|
if (process.env.NODE_ENV === "test") return;
|
|
try {
|
|
const record = deps.chatStore.getChat(chatId);
|
|
if (!record) return;
|
|
const covered = coverageOf(record.messages as UIMessage[], deps.graphStore.readCoverage(), chatId);
|
|
if (covered.uncoveredTokens < EXTRACTION.chunkTokens) return;
|
|
void extraction.runExtraction(chatId).catch((err) => {
|
|
console.error(`[chat ${chatId}] extraction pipeline failed:`, err);
|
|
});
|
|
} catch (err) {
|
|
console.error(`[chat ${chatId}] extraction trigger failed:`, err);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The graph READ channel (vision-context-injection.md): one shot per chat,
|
|
* at session start — the first typed message is the session's topic; the
|
|
* semantically closest propositions are selected under the session's token
|
|
* budget and steered in through the wake pipeline. Fire-and-forget like
|
|
* triggerExtraction; the one-shot gates (topic, budget, prior injection)
|
|
* live in the runner module, not here — this seam only decides WHEN to ask.
|
|
* Skipped under tests, same doctrine: the runner is tested directly with a
|
|
* scripted embedder.
|
|
*/
|
|
export function triggerGraphContext(deps: GraphContextDeps, chatId: string): void {
|
|
if (process.env.NODE_ENV === "test") return;
|
|
try {
|
|
startGraphContextInjection(deps, chatId);
|
|
} catch (err) {
|
|
console.error(`[chat ${chatId}] graph-context trigger failed:`, err);
|
|
}
|
|
}
|