Extraction pipeline: extractor → auditor → merger — the sole graph writer (stage 5)

The knowledge graph's write channel is now the pivot's target
architecture (vision §1 construction):

- src/agent/subagents/extraction.ts — the pipeline factory
  (createExtractionPipeline({ chatStore, graphStore, extractor?,
  auditor? }); models env LH_EXTRACT_MODEL ?? LH_MODEL):
  * transcriptView(): the compact view both LLM stages see — final plan
    tree with node ids, conversation verbatim, QUIZ lines with outcomes,
    mark_done lines; job tool parts are one-liners, plan ops fold into
    the tree line.
  * Extractor: transcript → SessionFindings (propositions incl.
    misconceptions and prose-only supports edges, attribution for
    UNTAGGED probes, all with verbatim evidence) via one zod-validated
    submit_findings call.
  * Auditor: transcript + findings → the verified subset + drops (the
    faithfulness net) via submit_review.
- src/core/graph.ts — emitChat/foldStaleChats/replayFinalTree retire;
  mergeFindings() replaces them as the pipeline's deterministic apply
  stage: exact/alias identity resolution (one mapping line per (chat,
  graph node)), edge dedupe, and event rebuild FROM THE TRANSCRIPT's tool
  parts — kinds/timestamps/refs/notes deterministic, the LLM's only event
  say being WHICH proposition an untagged probe tested (retroactive —
  the vision's open question #3, resolved structurally: tagged events
  resolve deterministically; attribution rescues untagged and
  tagged-but-unmapped ones; unattributable land null + question text).
  pendingQuizAnswers() is the trigger helper.
- Trigger (turn-service.ts triggerExtraction, replacing the old
  foldTurnIntoGraph body): once a chat accumulates
  EXTRACTION.everyNQuizAnswers (LH_EXTRACT_N, default 3) new answered
  quizzes, fire the pipeline async — single-flight per chat, failure
  checkpoints nothing and re-runs on the next trigger. Deliberately a v1
  placeholder isolated in ONE seam, trigger-agnostic pipeline.
- GET /api/graph is now a pure read (foldStaleChats gone).
- NodeMapping.treeNodeId → sourceId (the mapping key is now the writer's
  local proposition id — tree node when tree-derived); store strict-read
  schema updated. Local ledger data regenerates through the pipeline.
- Tests: graph.test.ts rewritten around mergeFindings (identity,
  attribution paths incl. ghost-rescue, idempotency, cross-session
  provenance, trigger counting), graph-context.test.ts merge section
  converted to findings-driven, NEW extraction.test.ts — transcriptView
  units + the full pipeline with mock models (audit drops, failure
  checkpoints nothing, single-flight). 195/195, tsc/eslint/build clean.

Probe attribution (vision open question): probes attach retroactively by
the extractor against the whole transcript; a pre-tree probe maps to a
proposition the tree only articulates later. Fuzzy/embedding merge
adjudication stays deferred (conservative exact/alias only).
This commit is contained in:
Amos
2026-08-31 22:36:41 +03:00
parent 59e1fee850
commit af90fadcda
12 changed files with 1168 additions and 320 deletions
+4 -2
View File
@@ -1,9 +1,10 @@
import { createAgentUIStreamResponse, type UIMessage } from "ai";
import { createTeacherAgent, type TeacherAgent } from "../agent/teacher-agent.ts";
import { createJobRunner } from "../agent/subagents/runner.ts";
import { createExtractionPipeline } from "../agent/subagents/extraction.ts";
import { createTeacherTools } from "../agent/tools.ts";
import { stores } from "./stores.ts";
import { buildTeacherContext, foldTurnIntoGraph, persistTurn, spawnTurnJobs } from "./turn-service.ts";
import { buildTeacherContext, persistTurn, spawnTurnJobs, triggerExtraction } from "./turn-service.ts";
/**
* One chat turn: run the teacher agent over the client's message list and
@@ -18,6 +19,7 @@ import { buildTeacherContext, foldTurnIntoGraph, persistTurn, spawnTurnJobs } fr
*/
const jobRunner = createJobRunner(stores);
const extraction = createExtractionPipeline(stores);
export async function handleChatRequest(req: Request, agent?: TeacherAgent): Promise<Response> {
let body: { chatId?: unknown; messages?: unknown };
@@ -46,7 +48,7 @@ export async function handleChatRequest(req: Request, agent?: TeacherAgent): Pro
onFinish: ({ messages, isAborted }) => {
persistTurn(stores.chatStore, chatId, messages as UIMessage[], isAborted);
spawnTurnJobs(jobRunner, chatId, messages as UIMessage[]);
foldTurnIntoGraph(stores, chatId);
triggerExtraction(stores, extraction, chatId);
},
});
}
+5 -7
View File
@@ -1,15 +1,13 @@
import type { GraphSnapshot } from "../core/contracts.ts";
import { foldStaleChats, graphSnapshot } from "../core/graph.ts";
import { graphSnapshot } from "../core/graph.ts";
import { stores } from "./stores.ts";
/**
* Server-side graph entry for the home view (GET /api/graph): fold any chat
* whose updatedAt outstrips its emission checkpoint first (partial sessions
* deposit value), then return the snapshot. Today the fold is the naive-union
* emission; the extraction-pipeline stage replaces the writer behind this
* same read surface.
* Server-side graph entry for the home view (GET /api/graph). A PURE READ:
* the extraction pipeline (the sole graph writer) is triggered by quiz
* answers in sessions (turn-service), never from here — the home view
* reflects whatever the pipeline has merged so far.
*/
export function graphView(): GraphSnapshot {
foldStaleChats(stores);
return graphSnapshot(stores);
}
+29 -12
View File
@@ -7,10 +7,11 @@
* buildTeacherContext — graph → instructions block (the W8 read channel)
* persistTurn — the canonical state write (client + agent messages)
* spawnTurnJobs — the async sub-agent protocol kick-off
* foldTurnIntoGraph — session → graph consolidation (the write channel;
* the extraction-pipeline stage swaps this body for
* the extractor/auditor/merger trigger behind the
* same seam)
* 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.
@@ -18,9 +19,11 @@
import type { UIMessage } from "ai";
import { buildGraphContext } from "../core/graph-context.ts";
import { emitChat, getGraph, type GraphDeps } from "../core/graph.ts";
import { getGraph, pendingQuizAnswers } from "../core/graph.ts";
import type { ChatStore, GraphStore } from "../core/ports.ts";
import { EXTRACTION } from "../../config.ts";
import type { JobRunner } from "../agent/subagents/runner.ts";
import type { ExtractionPipeline } from "../agent/subagents/extraction.ts";
/**
* W8 (vision §1 "the graph as session context"): the learner's knowledge
@@ -66,16 +69,30 @@ export function spawnTurnJobs(jobRunner: JobRunner, chatId: string, messages: UI
}
/**
* W4: fold this chat into the knowledge-graph ledgers (tree nodes / edges,
* quiz outcomes, taught events) — synchronous but tiny (append-only JSONL),
* and errors never touch the response. Skipped under tests: scripted chats
* are fixtures, not knowledge.
* 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).
*/
export function foldTurnIntoGraph(deps: GraphDeps, chatId: string): void {
export function triggerExtraction(
deps: { chatStore: ChatStore; graphStore: GraphStore },
extraction: ExtractionPipeline,
chatId: string,
): void {
if (process.env.NODE_ENV === "test") return;
try {
emitChat(chatId, deps);
if (pendingQuizAnswers(chatId, deps) < EXTRACTION.everyNQuizAnswers) return;
void extraction.runExtraction(chatId).catch((err) => {
console.error(`[chat ${chatId}] extraction pipeline failed:`, err);
});
} catch (err) {
console.error(`[chat ${chatId}] graph emission failed:`, err);
console.error(`[chat ${chatId}] extraction trigger failed:`, err);
}
}