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:
@@ -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");
|
||||
}
|
||||
Reference in New Issue
Block a user