teacher-prose-only extraction view: coverage, fingerprints, tokens, and batches derive from the teacher's text blocks alone (owner 2026-09-28)
This commit is contained in:
@@ -26,7 +26,8 @@ import { getGraph, mergeFindings, type EmissionStats, type GraphDeps } from "../
|
||||
import type { GraphStore } from "../../core/ports.ts";
|
||||
import { settleGraphLayout } from "../../core/layout-settle.ts";
|
||||
import { batchRuns, coverageOf, messageViewLines } from "../../core/coverage.ts";
|
||||
import { sessionMarkdown } from "../../core/session-markdown.ts";
|
||||
import { teacherProseBlocks } from "../../core/coverage.ts";
|
||||
import type { LhMessage } from "../../core/native-messages.ts";
|
||||
import { auditorGraphTools } from "../tools/graph.ts";
|
||||
|
||||
/**
|
||||
@@ -83,22 +84,29 @@ import { auditorGraphTools } from "../tools/graph.ts";
|
||||
// ─── the source view (what the extractor sees) ──────────────────────────────
|
||||
|
||||
/**
|
||||
* A session rendered as readable markdown — the EXTRACTOR's view (owner
|
||||
* ruling 2026-09-27): the whole session goes in, formatted by the ONE
|
||||
* shared session-markdown engine (core/session-markdown.ts — the same
|
||||
* engine the user-facing session export will use), with the message-id
|
||||
* anchors preserved (the extractor reports them back per
|
||||
* proposition/edge — they become provenance). TREE-BLIND by design:
|
||||
* no plan tree, no mark_done.
|
||||
*
|
||||
* Presentation-only: coverage fingerprints hash core/coverage.ts's line
|
||||
* format, which is SEPARATE and unchanged — re-formatting the view never
|
||||
* re-opens covered history (the deliberate 2026-09-27 split).
|
||||
* The extraction view: the session as TEACHER PROSE ONLY (owner ruling,
|
||||
* 2026-09-28 — supersedes the 2026-09-27 whole-session markdown view):
|
||||
* plain prose paragraphs under the session title. The shared markdown
|
||||
* engine (core/session-markdown.ts) keeps the full session format for
|
||||
* the future user-facing export; the extractor no longer uses it.
|
||||
*/
|
||||
|
||||
/** The whole-record view (used by callers that render a full session;
|
||||
* the pipeline itself batches). TEACHER PROSE ONLY (owner, 2026-09-28).
|
||||
*/
|
||||
export function transcriptView(record: ChatRecord): string {
|
||||
// anchors OFF (submit-schema slim, 2026-09-28): propositions no longer
|
||||
// carry message ids, so the [id] anchors are noise for the extractor.
|
||||
return sessionMarkdown(record.messages, { title: record.title, anchors: false });
|
||||
return proseView(record.messages, record.title);
|
||||
}
|
||||
|
||||
/** The extraction view under the teacher-prose ruling (owner, 2026-09-28):
|
||||
* ONLY the teacher agent's text blocks — no learner messages, no
|
||||
* thinking, no tool calls or results, no quiz blocks. Plain paragraphs
|
||||
* under the session title; the shared markdown engine stays reserved for
|
||||
* the future user-facing export. */
|
||||
function proseView(messages: ReadonlyArray<LhMessage>, title?: string): string {
|
||||
const prose = teacherProseBlocks(messages);
|
||||
const head = title && title.length > 0 ? `# ${title}\n\n` : "";
|
||||
return head + prose.join("\n\n");
|
||||
}
|
||||
|
||||
// ─── the extractor ──────────────────────────────────────────────────────────
|
||||
@@ -866,15 +874,12 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti
|
||||
`${covered.uncoveredCount} messages, ${batches.length} batch(es) of ≤${chunkTokens} tok`,
|
||||
);
|
||||
for (const batch of batches) {
|
||||
// The batch's source view: the ONE shared markdown engine
|
||||
// (owner 2026-09-27) over the batch's messages. Anchors OFF
|
||||
// (2026-09-28): propositions report no message ids, so the
|
||||
// [id] anchors are noise — same as transcriptView. The
|
||||
// fingerprint's line format is separate and unchanged (no
|
||||
// mass re-extraction).
|
||||
const view = sessionMarkdown(
|
||||
// The batch's source view: TEACHER PROSE ONLY (owner,
|
||||
// 2026-09-28) — plain paragraphs, same selection as the
|
||||
// coverage fingerprints (messageViewLines parity).
|
||||
const view = proseView(
|
||||
batch.map((slot) => slot.message),
|
||||
{ title: record.title, anchors: false },
|
||||
record.title,
|
||||
);
|
||||
setPhase(chatId, "extracting", "chat");
|
||||
const findings = await extract(chatId, view);
|
||||
|
||||
+59
-151
@@ -1,45 +1,31 @@
|
||||
/**
|
||||
* Extraction coverage (vision-extraction-coverage.md, settled 2026-09-15) —
|
||||
* the pure core. THE MESSAGE IS THE UNIT OF COVERAGE (Wave D, owner ruling
|
||||
* 2026-09-26: the native message, not the old projected group): a per-chat
|
||||
* append-only ledger records which messages have been extracted, by CONTENT
|
||||
* FINGERPRINT over exactly what the extraction view sees; "uncovered" is
|
||||
* DERIVED at read time (fingerprint the current transcript, diff against the
|
||||
* ledger) — never dual-tracked. Runs are token-budgeted batches of the oldest
|
||||
* uncovered messages, and THE ONE BATCH RULE (owner): a tool-call message and
|
||||
* its tool-result message(s) are INSEPARABLE — they always appear in the same
|
||||
* chunk (a covered partner rides along as context; checkpoints are
|
||||
* idempotent, so re-marking it is a no-op).
|
||||
* Extraction coverage — the pure core. THE TEACHER'S PROSE IS THE UNIT
|
||||
* (owner ruling, 2026-09-28): the extractor sees ONLY the teacher agent's
|
||||
* text blocks — no learner messages, no thinking, no tool calls or
|
||||
* results, no quiz blocks. Everything else is INVISIBLE to coverage: it
|
||||
* neither opens nor joins a run, so answering a quiz or editing a learner
|
||||
* message triggers nothing. "Covered" is by construction exactly "what
|
||||
* the extractor saw": the fingerprint (sha256 of a message's view lines)
|
||||
* and the extractor's prompt share ONE selection — the teacher prose.
|
||||
*
|
||||
* The fingerprint and the LLM prompt share ONE line builder —
|
||||
* messageViewLines — so "covered" and "what the model saw" cannot drift
|
||||
* apart. The line logic is tree-blind, identical to the extractor's view:
|
||||
* user/teacher text verbatim, quiz outcomes as one-line summaries, other
|
||||
* clean tool results as one generic line each, markers invisible (a
|
||||
* plan-block marker does not re-open a covered message).
|
||||
* A per-chat append-only ledger records which messages have been
|
||||
* extracted by CONTENT FINGERPRINT; "uncovered" is DERIVED at read time
|
||||
* (fingerprint the current transcript, diff against the ledger) — never
|
||||
* dual-tracked. Runs are token-budgeted batches of the oldest uncovered
|
||||
* teacher-prose messages: greedy consecutive fill, a message is never
|
||||
* split, a single over-budget message rides alone. (The old
|
||||
* inseparability bond — call + results in one chunk — died with the
|
||||
* ruling: tool results are invisible now, so nothing needs keeping
|
||||
* together.)
|
||||
*/
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import type { CoverageEntry } from "./contracts/graph.ts";
|
||||
import type { LhMessage } from "./native-messages.ts";
|
||||
import type { ToolResultMessage } from "@earendil-works/pi-ai";
|
||||
|
||||
export type { CoverageEntry };
|
||||
|
||||
/** The quiz outcome line — both the fingerprint and the LLM view see the
|
||||
* quiz answer, so an answered quiz re-opens (the answer is its own native
|
||||
* message now; its fingerprint is new the moment it lands). */
|
||||
function quizLine(result: ToolResultMessage): string {
|
||||
const out = result.details as
|
||||
| Partial<{ questionText: string; correct: boolean; dontKnow: boolean; note: string }>
|
||||
| undefined;
|
||||
const question = typeof out?.questionText === "string" ? out.questionText : "(no question)";
|
||||
const outcome = out?.dontKnow === true ? "did not know" : out?.correct === true ? "answered correctly" : "answered incorrectly";
|
||||
const note = typeof out?.note === "string" && out.note ? ` (note: ${out.note})` : "";
|
||||
return `QUIZ [${result.toolCallId}] ${question} — the learner ${outcome}${note}`;
|
||||
}
|
||||
|
||||
/** The user-facing text of a native message (string or text blocks). */
|
||||
function textOf(content: unknown): string[] {
|
||||
if (typeof content === "string") return content.trim() ? [content] : [];
|
||||
@@ -52,47 +38,46 @@ function textOf(content: unknown): string[] {
|
||||
|
||||
/**
|
||||
* The extraction-relevant lines of ONE native message — the shared seam
|
||||
* between transcriptView (the LLM's prompt) and messageFingerprint
|
||||
* (coverage). Same logic, same order, so a covered message is by
|
||||
* construction exactly what the extractor saw. `mid` is the provenance tag
|
||||
* (the message id; the transcript view passes an index fallback for legacy
|
||||
* id-less messages, coverage passes the id — persisted messages always have
|
||||
* ids).
|
||||
* between the extractor's prompt and messageFingerprint (coverage).
|
||||
* TEACHER PROSE ONLY (owner, 2026-09-28): an assistant message yields one
|
||||
* line per text block; everything else (user messages, tool results,
|
||||
* markers) yields NOTHING and is invisible to coverage. `mid` is the
|
||||
* message id — part of the hash input, so the same prose in two messages
|
||||
* is two slots (per-message coverage).
|
||||
*/
|
||||
export function messageViewLines(message: LhMessage, mid: string): string[] {
|
||||
const lines: string[] = [];
|
||||
const role = (message as { role?: string }).role;
|
||||
if (role === "user") {
|
||||
for (const text of textOf((message as { content: unknown }).content)) {
|
||||
lines.push(`LEARNER [${mid}] ${text.trim()}`);
|
||||
}
|
||||
} else if (role === "assistant") {
|
||||
for (const text of textOf((message as { content: unknown }).content)) {
|
||||
lines.push(`TEACHER [${mid}] ${text.trim()}`);
|
||||
}
|
||||
} else if (role === "toolResult") {
|
||||
const result = message as ToolResultMessage;
|
||||
if (result.isError) return lines; // a failed call is retry context, not learning evidence
|
||||
lines.push(
|
||||
result.toolName === "quiz" ? quizLine(result) : `(${result.toolName} tool result delivered)`,
|
||||
);
|
||||
if ((message as { role?: string }).role !== "assistant") return lines;
|
||||
for (const text of textOf((message as { content: unknown }).content)) {
|
||||
lines.push(`TEACHER [${mid}] ${text.trim()}`);
|
||||
}
|
||||
// custom markers are invisible to the extraction view.
|
||||
return lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* The teacher's prose blocks of a transcript, in order — the extractor's
|
||||
* prompt material. SAME selection as messageViewLines (the parity
|
||||
* doctrine survives the ruling in this one function pair), rendered
|
||||
* without the provenance prefix: plain paragraphs.
|
||||
*/
|
||||
export function teacherProseBlocks(messages: ReadonlyArray<LhMessage>): string[] {
|
||||
const out: string[] = [];
|
||||
for (const message of messages) {
|
||||
if ((message as { role?: string }).role !== "assistant") continue;
|
||||
for (const text of textOf((message as { content: unknown }).content)) out.push(text.trim());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Tokens of a message's view — chars/4, ceil, the app-wide estimate. */
|
||||
export function viewTokens(lines: string[]): number {
|
||||
return Math.ceil(lines.join("\n").length / 4);
|
||||
}
|
||||
|
||||
/**
|
||||
* A message's coverage fingerprint: sha256 of role + its view lines (with
|
||||
* the message's real id as the provenance tag). Changes exactly when the
|
||||
* extraction view of the message changes. (Wave D: the unit is the native
|
||||
* message — an answered quiz is the answer's OWN toolResult message, so
|
||||
* answering opens exactly that new message; the asking message is pulled
|
||||
* into the batch by the inseparability rule, not re-opened.)
|
||||
* A message's coverage fingerprint: sha256 of role + its view lines (the
|
||||
* teacher prose, with the message id as the tag). Changes exactly when
|
||||
* the teacher prose of the message changes — nothing else can re-open it.
|
||||
*/
|
||||
export function messageFingerprint(message: LhMessage): string {
|
||||
const h = createHash("sha256");
|
||||
@@ -122,11 +107,11 @@ export function coveredSet(entries: ReadonlyArray<CoverageEntry>, chatId: string
|
||||
|
||||
/** The derived state: which slots are uncovered, plus the token totals.
|
||||
* "Uncovered" needs no ledger of its own — this diff IS the coverage view.
|
||||
* `uncovered` is INDEXES into `slots` (the batcher needs positions for the
|
||||
* inseparability rule). Messages with NO view lines (custom markers, an
|
||||
* error result) are invisible to coverage entirely: the extractor can
|
||||
* never see them, so they are neither covered nor uncovered — a marker
|
||||
* attach never opens (or joins) an extraction run. */
|
||||
* `uncovered` is INDEXES into `slots` (the batcher batches by position).
|
||||
* Messages with NO view lines — every learner message, every tool result,
|
||||
* markers, prose-less assistant turns — are invisible to coverage
|
||||
* entirely: the extractor can never see them, so they are neither covered
|
||||
* nor uncovered. */
|
||||
export function coverageOf(
|
||||
messages: ReadonlyArray<LhMessage>,
|
||||
entries: ReadonlyArray<CoverageEntry>,
|
||||
@@ -166,86 +151,15 @@ export function coverageOf(
|
||||
};
|
||||
}
|
||||
|
||||
// ─── the inseparability rule (owner, Wave D fork 3) ─────────────────────────
|
||||
//
|
||||
// A tool-call message and its tool-result message(s) always appear in the
|
||||
// same chunk. Bonding is adjacency-honest: results follow their call's
|
||||
// assistant message contiguously in the native record (the agent loop
|
||||
// appends the results batch; the question gate guarantees nothing can land
|
||||
// between a quiz call and its learner answer). A result whose call's
|
||||
// assistant message has NO other bond partners still pulls that message in
|
||||
// (the extractor needs the question with its answer).
|
||||
|
||||
/** The call ids an assistant message carries. */
|
||||
function callIdsOf(message: LhMessage): Set<string> {
|
||||
const out = new Set<string>();
|
||||
const content = (message as { content?: unknown }).content;
|
||||
if (!Array.isArray(content)) return out;
|
||||
for (const block of content as Array<{ type?: string; id?: string }>) {
|
||||
if (block.type === "toolCall" && typeof block.id === "string") out.add(block.id);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The contiguous bond-range containing slot i: an assistant-with-calls
|
||||
* message plus its immediately-following results; a toolResult message
|
||||
* plus the assistant message that made its call (and that call's sibling
|
||||
* results). Anything else bonds to itself alone. */
|
||||
function bondRange(messages: ReadonlyArray<LhMessage>, i: number): [number, number] {
|
||||
const roleOf = (m: LhMessage | undefined) => (m ? (m as { role?: string }).role : undefined);
|
||||
const m = messages[i];
|
||||
if (!m) return [i, i];
|
||||
if (roleOf(m) === "assistant") {
|
||||
const calls = callIdsOf(m);
|
||||
if (calls.size === 0) return [i, i];
|
||||
let end = i;
|
||||
while (
|
||||
roleOf(messages[end + 1]) === "toolResult" &&
|
||||
calls.has((messages[end + 1] as ToolResultMessage).toolCallId)
|
||||
) {
|
||||
end++;
|
||||
}
|
||||
return [i, end];
|
||||
}
|
||||
if (roleOf(m) === "toolResult") {
|
||||
let start = i;
|
||||
while (roleOf(messages[start - 1]) === "toolResult") start--;
|
||||
const owner = messages[start - 1];
|
||||
if (
|
||||
owner &&
|
||||
roleOf(owner) === "assistant" &&
|
||||
callIdsOf(owner).has((m as ToolResultMessage).toolCallId)
|
||||
) {
|
||||
const [s, e] = bondRange(messages, start - 1);
|
||||
return [s, Math.max(e, i)];
|
||||
}
|
||||
return [start, i]; // orphaned results (no asking message): keep the contiguous result run together
|
||||
}
|
||||
return [i, i];
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch the uncovered messages into token-budgeted RUNS: consecutive slots,
|
||||
* oldest first, greedy fill — a message is never split, and a bond-range
|
||||
* (call + results) never splits either (owner fork 3): a selected uncovered
|
||||
* slot EXPANDS to its whole bond-range, covered partners riding as context
|
||||
* (checkpoint idempotency makes re-marking them a no-op). A single
|
||||
* bond-range larger than the budget becomes its own (over-budget) run
|
||||
* rather than being dropped. Runs are ephemeral — the durable record is
|
||||
* per-message coverage, checkpointed after each run merges.
|
||||
* Batch the uncovered teacher-prose messages into token-budgeted RUNS:
|
||||
* consecutive slots, oldest first, greedy fill — a message is never split.
|
||||
* A single message larger than the budget becomes its own (over-budget)
|
||||
* run rather than being dropped. Runs are ephemeral — the durable record
|
||||
* is per-message coverage, checkpointed after each run merges.
|
||||
*/
|
||||
export function batchRuns(slots: ReadonlyArray<CoverageSlot>, uncovered: ReadonlyArray<number>, budget: number): CoverageSlot[][] {
|
||||
if (budget <= 0 || uncovered.length === 0) return [];
|
||||
const messages = slots.map((s) => s.message);
|
||||
// Expand: every uncovered index marks its whole bond-range as needed.
|
||||
const needed = new Set<number>();
|
||||
for (const i of uncovered) {
|
||||
const [start, end] = bondRange(messages, i);
|
||||
for (let j = start; j <= end; j++) needed.add(j);
|
||||
}
|
||||
// Group the needed slots in order, greedy by budget, never splitting a
|
||||
// bond-range: a range that would overflow starts a new run (and an
|
||||
// over-budget range rides alone).
|
||||
const runs: CoverageSlot[][] = [];
|
||||
let current: CoverageSlot[] = [];
|
||||
let currentTokens = 0;
|
||||
@@ -254,17 +168,11 @@ export function batchRuns(slots: ReadonlyArray<CoverageSlot>, uncovered: Readonl
|
||||
current = [];
|
||||
currentTokens = 0;
|
||||
};
|
||||
for (let i = 0; i < slots.length; i++) {
|
||||
if (!needed.has(i)) continue;
|
||||
const [start, end] = bondRange(messages, i);
|
||||
const rangeTokens = slots.slice(start, end + 1).reduce((t, s) => t + s.tokens, 0);
|
||||
if (current.length > 0 && currentTokens + rangeTokens > budget) flush();
|
||||
// The whole range enters together — skip past its end in the outer loop.
|
||||
for (let j = start; j <= end; j++) {
|
||||
current.push(slots[j]!);
|
||||
currentTokens += slots[j]!.tokens;
|
||||
}
|
||||
i = end;
|
||||
for (const i of uncovered) {
|
||||
const slot = slots[i]!;
|
||||
if (current.length > 0 && currentTokens + slot.tokens > budget) flush();
|
||||
current.push(slot);
|
||||
currentTokens += slot.tokens;
|
||||
}
|
||||
flush();
|
||||
return runs;
|
||||
|
||||
Reference in New Issue
Block a user