2026-09-28 16:54:44 +03:00
|
|
|
/**
|
|
|
|
|
* 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 —
|
2026-09-28 19:04:39 +03:00
|
|
|
* with optional message-id anchors. Since the submit-schema slim
|
|
|
|
|
* (2026-09-28) propositions no longer report message ids — the extractor
|
|
|
|
|
* renders anchorless and provenance is chat-level — so the anchors today
|
|
|
|
|
* serve only the future user-facing export; `anchors: false` mints the
|
|
|
|
|
* same document without them.
|
2026-09-28 16:54:44 +03:00
|
|
|
*
|
|
|
|
|
* 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");
|
|
|
|
|
}
|