2026-08-31 22:16:03 +03:00
|
|
|
/**
|
|
|
|
|
* 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)
|
2026-08-31 22:36:41 +03:00
|
|
|
* 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.
|
2026-08-31 22:16:03 +03:00
|
|
|
*
|
|
|
|
|
* 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";
|
2026-09-11 04:07:12 +03:00
|
|
|
import { withPlanBlocks } from "../core/plan-blocks.ts";
|
2026-09-11 06:44:20 +03:00
|
|
|
import { pendingQuizAnswers } from "../core/graph.ts";
|
2026-08-31 22:16:03 +03:00
|
|
|
import type { ChatStore, GraphStore } from "../core/ports.ts";
|
2026-08-31 22:36:41 +03:00
|
|
|
import { EXTRACTION } from "../../config.ts";
|
|
|
|
|
import type { ExtractionPipeline } from "../agent/subagents/extraction.ts";
|
2026-08-31 22:16:03 +03:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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.
|
|
|
|
|
*/
|
2026-09-02 02:34:02 +03:00
|
|
|
/**
|
|
|
|
|
* 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[] {
|
2026-09-09 05:43:28 +03:00
|
|
|
const filled = messages.map((m) =>
|
2026-09-02 02:34:02 +03:00
|
|
|
m.id ? m : { ...m, id: `msg_${Date.now().toString(36)}_${crypto.randomUUID().slice(0, 8)}` },
|
|
|
|
|
);
|
2026-09-09 05:43:28 +03:00
|
|
|
// 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;
|
2026-09-02 02:34:02 +03:00
|
|
|
}
|
|
|
|
|
|
2026-08-31 22:16:03 +03:00
|
|
|
export function persistTurn(chatStore: ChatStore, chatId: string, messages: UIMessage[], isAborted: boolean): void {
|
|
|
|
|
try {
|
2026-09-11 04:07:12 +03:00
|
|
|
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));
|
2026-08-31 22:16:03 +03:00
|
|
|
} catch (err) {
|
|
|
|
|
console.error(`[chat ${chatId}] persist failed:`, err, { isAborted });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-08-31 22:36:41 +03:00
|
|
|
* The graph write channel (vision §1 construction): the extraction pipeline
|
|
|
|
|
* (extractor → auditor → merger — the sole graph writer) runs ASYNC once a
|
|
|
|
|
* chat accumulates EXTRACTION.everyNQuizAnswers new answered quizzes since
|
|
|
|
|
* its last checkpoint. Fire-and-forget — never blocks the turn; failures
|
|
|
|
|
* are logged by the pipeline and leave the checkpoint untouched, so the
|
|
|
|
|
* next trigger re-runs the whole thing.
|
|
|
|
|
*
|
|
|
|
|
* v1 placeholder trigger, deliberately isolated in this one seam: swap
|
|
|
|
|
* here when a better mechanism lands — the pipeline is trigger-agnostic.
|
|
|
|
|
* Skipped under tests: scripted chats are fixtures, not knowledge (the
|
|
|
|
|
* pipeline itself is tested directly with mock models).
|
2026-08-31 22:16:03 +03:00
|
|
|
*/
|
2026-08-31 22:36:41 +03:00
|
|
|
export function triggerExtraction(
|
|
|
|
|
deps: { chatStore: ChatStore; graphStore: GraphStore },
|
|
|
|
|
extraction: ExtractionPipeline,
|
|
|
|
|
chatId: string,
|
|
|
|
|
): void {
|
2026-08-31 22:16:03 +03:00
|
|
|
if (process.env.NODE_ENV === "test") return;
|
|
|
|
|
try {
|
2026-08-31 22:36:41 +03:00
|
|
|
if (pendingQuizAnswers(chatId, deps) < EXTRACTION.everyNQuizAnswers) return;
|
|
|
|
|
void extraction.runExtraction(chatId).catch((err) => {
|
|
|
|
|
console.error(`[chat ${chatId}] extraction pipeline failed:`, err);
|
|
|
|
|
});
|
2026-08-31 22:16:03 +03:00
|
|
|
} catch (err) {
|
2026-08-31 22:36:41 +03:00
|
|
|
console.error(`[chat ${chatId}] extraction trigger failed:`, err);
|
2026-08-31 22:16:03 +03:00
|
|
|
}
|
|
|
|
|
}
|