extraction cleanup: events ledger dies, markdown source view, auditor-as-linker (2026-09-27 rulings)

The graph becomes raw reasoning chains only. Quiz outcomes and
understanding observations are no longer formalized; assessment
moves to the future practice module.

Events ledger removed end-to-end: LedgerEvent/LedgerEventKind/
UnderstandingLevel/ExtractionAttribution/AuditResult.dropped and
snapshot nodeObservations deleted from contracts; quizEvent builder
and the events step of mergeFindings gone; appendEvent/readEvents
dropped from the port and store; LEDGER_FILES loses events.jsonl
with a one-time purge of a stale file next to the nodes ledger;
graph-deletion and the delete dialog lose event purge + quiz-answer
impact counts; GraphSessionProposition becomes a pure birth list.

Whole-session markdown source view: new core/session-markdown.ts is
the shared formatting engine (id-anchored speaker blocks, quiz/
recall/question blocks, tool one-liners) — the extractor's prompt
view and the future user-facing export render from one engine.
Coverage fingerprints deliberately stay on the old line format
(content-hash semantics; re-formatting never re-opens covered
history). The whole session goes to the extractor — no phase cut.

Auditor refocused as the identity linker: never sees the source;
explores the graph via new auditor-specific tools in
agent/tools/graph.ts (find — glob over name/statement/aliases;
list_sessions; list_propositions with full statement text; no
timestamps) instead of an injected full-graph dump; equivalence
bias — paraphrases merge, only genuinely distinct propositions stay
apart; no drop channel, the extractor is fully trusted; may draw
bridging edges (typed follows/supports) between existing nodes and
the batch, resolved by the merger with chat-scoped message-less
provenance. Agentic loop, maxTurns 16.

UI: the node detail card gains an edges section above the sessions
list (follows-from / supported-by / what-follows / what-it-supports,
rendered only when present, click-to-select neighbors); the
understanding-note chips are gone.

Prober list_propositions reworded to what a session covered —
names only, no levels. docs/architecture.md + README updated to
the as-built pipeline. Gates: typecheck, 1059/1059, lint, build.
This commit is contained in:
amosblomqvist
2026-09-28 16:54:44 +03:00
parent cd09c12b56
commit 1b50013551
22 changed files with 909 additions and 1064 deletions
+126
View File
@@ -0,0 +1,126 @@
/**
* Session → markdown — the ONE formatting engine (owner ruling 2026-09-27).
*
* The extractor's view of a teaching session and the future user-facing
* session export share this engine: a session is rendered as clean,
* readable markdown — speaker headers, quiz/check-in blocks, tool notes —
* with the message-id anchors preserved (extraction provenance depends on
* the extractor reporting the `[id]` tags it saw; `anchors: false` mints
* the same document without them, for the user export).
*
* Pure core: native messages in (LhMessage), string out. Content parity
* with the coverage line builder (core/coverage.ts messageViewLines) is
* deliberate — every message that produces view lines produces a markdown
* block, in the same order — but the formats are SEPARATE by design
* (2026-09-27): coverage fingerprints hash the line format and stay stable
* (no mass re-extraction when presentation changes); markdown is
* presentation. A failed tool call renders nothing (retry context, not
* learning evidence); custom markers are invisible, same as the view.
*/
import type { LhMessage } from "./native-messages.ts";
import type { ToolResultMessage } from "@earendil-works/pi-ai";
/** The user-facing text blocks of a native message (string or text parts). */
function textOf(content: unknown): string[] {
if (typeof content === "string") return content.trim() ? [content] : [];
if (!Array.isArray(content)) return [];
return content
.filter((c): c is { type: "text"; text: string } => (c as { type?: string }).type === "text")
.map((c) => c.text)
.filter((t) => t.trim().length > 0);
}
/** One tool result's learner-visible outcome, rendered as body lines. */
function toolResultBody(result: ToolResultMessage): { heading: string; body: string[] } {
const out = result.details as
| Partial<{
questionText: string;
correct: boolean;
dontKnow: boolean;
note: string;
text: string;
answers: string[];
other: string;
dismissed: boolean;
}>
| undefined;
if (result.toolName === "quiz" || result.toolName === "explain") {
const heading = result.toolName === "quiz" ? "Quiz" : "Recall check";
const question = typeof out?.questionText === "string" && out.questionText ? out.questionText : "(no question text)";
const body = [`**Q:** ${question}`];
if (out?.dismissed === true) body.push("The learner skipped this check.");
else if (out?.dontKnow === true) body.push("The learner did not know.");
else if (out?.correct === true) body.push("The learner answered correctly.");
else if (out?.correct === false) body.push("The learner answered incorrectly.");
else if (typeof out?.text === "string" && out.text.trim()) body.push(`The learner answered in his own words: ${out.text.trim()}`);
if (typeof out?.note === "string" && out.note) body.push(`*(his thinking: ${out.note})*`);
return { heading, body };
}
if (result.toolName === "ask_user_question") {
const question = typeof out?.questionText === "string" && out.questionText ? out.questionText : "(no question text)";
const body = [`**Q:** ${question}`];
const chosen = [
...(Array.isArray(out?.answers) ? out!.answers! : []),
...(typeof out?.other === "string" && out.other.trim() ? [out.other.trim()] : []),
];
if (typeof out?.text === "string" && out.text.trim()) body.push(`The learner answered: ${out.text.trim()}`);
else if (chosen.length > 0) body.push(`The learner chose: ${chosen.join(", ")}`);
else if (out?.dismissed === true) body.push("The learner skipped this question.");
if (typeof out?.note === "string" && out.note) body.push(`*(his thinking: ${out.note})*`);
return { heading: "Question", body };
}
// Parity with the line builder: other clean tool results are one line.
return { heading: "Tool", body: [`*${result.toolName} result delivered*`] };
}
/**
* ONE message as a markdown block — `null` when the message renders
* nothing (a failed tool call, a bare marker, an empty text part set).
* `mid` is the provenance anchor shown when `anchors` is on.
*/
export function messageMarkdown(message: LhMessage, mid: string, anchors = true): string | null {
const role = (message as { role?: string }).role;
const anchor = anchors ? ` · ${mid}` : "";
const parts: string[] = [];
if (role === "user") {
for (const text of textOf((message as { content: unknown }).content)) {
parts.push(`### Learner${anchor}\n\n${text.trim()}`);
}
} else if (role === "assistant") {
for (const text of textOf((message as { content: unknown }).content)) {
parts.push(`### Teacher${anchor}\n\n${text.trim()}`);
}
} else if (role === "toolResult") {
const result = message as ToolResultMessage;
if (result.isError) return null; // retry context, not learning evidence
const { heading, body } = toolResultBody(result);
parts.push(`### ${heading}${anchor}\n\n${body.join("\n\n")}`);
}
// custom markers are invisible, same as the extraction view.
return parts.length > 0 ? parts.join("\n\n") : null;
}
/**
* A whole session (or one extraction BATCH — same engine, subset of
* messages) as one markdown document. Blocks render in message order;
* messages that render nothing are skipped silently. The optional title
* mints the document header (`# <title>`).
*/
export function sessionMarkdown(
messages: ReadonlyArray<LhMessage>,
opts: { title?: string; anchors?: boolean } = {},
): string {
const blocks: string[] = [];
if (opts.title !== undefined && opts.title.length > 0) blocks.push(`# ${opts.title}`);
for (const message of messages) {
const block = messageMarkdown(message, (message as { id?: string }).id ?? "", opts.anchors ?? true);
if (block !== null) blocks.push(block);
}
return blocks.join("\n\n");
}