fix: share question math repair with practice displays
This commit is contained in:
@@ -0,0 +1,204 @@
|
||||
/** Display repair is shared, not a mutation of saved practice/teaching arguments. */
|
||||
import React from "react";
|
||||
(globalThis as Record<string, unknown>).React = React;
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { parseHTML } from "linkedom";
|
||||
import { validateToolArguments } from "@earendil-works/pi-ai";
|
||||
import { problemInputObject } from "../src/agent/tools/practice.ts";
|
||||
import { PracticeDock } from "../components/practice-dock.tsx";
|
||||
import { ProblemCard, ConcludeCard } from "../components/practice-cards.tsx";
|
||||
import { MessageResponse } from "../components/ai-elements/message.tsx";
|
||||
import {
|
||||
repairMathForDisplay, healOverescapedCommands, restoreMathEscapes,
|
||||
parseQuizInput, parseAskInput, parseExplainInput, activeProblemOf, projectView,
|
||||
toLlmMessages, type LhMessage, type ArtifactPartProps,
|
||||
} from "../src/core/index.ts";
|
||||
|
||||
const tex = String.raw`\nabla f(x) \in \mathbb{R}^{m \times n}`;
|
||||
const clean = `$${tex}$`;
|
||||
const doubled = clean.replace(/\\/g, "\\\\");
|
||||
const quadrupled = clean.replace(/\\/g, "\\\\\\\\");
|
||||
const debris = "$\forall x \\in \\mathbb{R}$"; // JSON's form feed consumed the f.
|
||||
const repairedDebris = String.raw`\forall x \in \mathbb{R}`;
|
||||
const matrix = "$$\n" + String.raw`\\begin{pmatrix}1 & 2 \\ 3 & 4\\end{pmatrix}` + "\n$$";
|
||||
const repairedMatrix = String.raw`\begin{pmatrix}1 & 2 \\ 3 & 4\end{pmatrix}`;
|
||||
const part = (input: unknown, over: Partial<ArtifactPartProps["part"]> = {}): ArtifactPartProps["part"] => ({
|
||||
type: "tool-problem", state: "input-available", toolCallId: "call_math", input, ...over,
|
||||
});
|
||||
const dock = (input: { statement: string }, expanded = true) => (
|
||||
<PracticeDock problemCallId="call_math" input={input} expanded={expanded} onExpand={() => {}} onSubmit={() => {}} />
|
||||
);
|
||||
|
||||
function math(html: string) {
|
||||
const { document } = parseHTML(html);
|
||||
return {
|
||||
annotations: [...document.querySelectorAll('annotation[encoding="application/x-tex"]')].map((n) => n.textContent),
|
||||
glyphs: [...document.querySelectorAll(".katex-html")].map((n) => n.textContent).join(""),
|
||||
errors: document.querySelectorAll(".katex-error").length,
|
||||
};
|
||||
}
|
||||
function assertMath(html: string, expected = [tex], glyphs = ["∇", "∈", "×"]) {
|
||||
const actual = math(html);
|
||||
assert.deepEqual(actual.annotations, expected, "exact TeX, not merely absence of errors");
|
||||
assert.equal(actual.errors, 0);
|
||||
for (const glyph of glyphs) assert.ok(actual.glyphs.includes(glyph), `rendered glyph ${glyph}`);
|
||||
}
|
||||
|
||||
describe("repairMathForDisplay — the existing composition, unchanged", () => {
|
||||
const unchanged = [
|
||||
clean,
|
||||
String.raw`outside \\nabla`,
|
||||
String.raw`$a \\\nabla b$`,
|
||||
String.raw`$a \\\\\nabla b$`,
|
||||
String.raw`$a \\ 2 \\& 3 \\[6pt] 4$`,
|
||||
"$$\na \\\\\\\\ 2\nb\n$$", // four real row-break slashes survive too.
|
||||
"`" + doubled + "`",
|
||||
"```tex\n" + doubled + "\n```",
|
||||
"`" + debris + "`",
|
||||
"```tex\n" + debris + "\n```",
|
||||
String.raw`$\\nabla`,
|
||||
"$$A = \\\\begin{pmatrix}\n1 & 2\n\\\\end{pmatrix}$$", // unsupported midline display close.
|
||||
];
|
||||
for (const [index, value] of [...unchanged, doubled, quadrupled, debris, matrix,
|
||||
String.raw`\(\\nabla\)`, "\\[\n\\\\nabla\n\\]"].entries()) {
|
||||
it(`matches the old composition byte-for-byte (${index})`, () => {
|
||||
assert.equal(repairMathForDisplay(value), healOverescapedCommands(restoreMathEscapes(value)));
|
||||
});
|
||||
}
|
||||
it("preserves clean math, odd runs, row breaks, code, prose and existing recognition limits", () => {
|
||||
for (const value of unchanged) assert.equal(repairMathForDisplay(value), value);
|
||||
assert.equal(repairMathForDisplay(doubled), clean);
|
||||
assert.equal(repairMathForDisplay(quadrupled), clean);
|
||||
assert.equal(repairMathForDisplay(debris), `$${repairedDebris}$`);
|
||||
assert.equal(repairMathForDisplay(matrix), `$$\n${repairedMatrix}\n$$`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("practice display surfaces", () => {
|
||||
for (const [name, raw, expected, glyphs] of [
|
||||
["two command slashes", doubled, tex, ["∇", "∈", "×"]],
|
||||
["four command slashes", quadrupled, tex, ["∇", "∈", "×"]],
|
||||
["JSON debris", debris, repairedDebris, ["∀", "∈"]],
|
||||
["clean math", clean, tex, ["∇", "∈", "×"]],
|
||||
["matrix with legitimate row break", matrix, repairedMatrix, ["1", "2", "3", "4"]],
|
||||
] as const) {
|
||||
it(`${name}: expanded dock, settled statement, verdict, committed reveal and legacy fallback`, () => {
|
||||
const input = Object.freeze({ statement: raw, modelSolution: `HIDDEN COMMITMENT ${raw}` });
|
||||
const conclusion = Object.freeze({ outcome: "solved", verdict: raw, solutionReveal: raw });
|
||||
const before = JSON.stringify({ input, conclusion });
|
||||
for (const element of [dock(input), <ProblemCard part={part(input)} />]) {
|
||||
const html = renderToStaticMarkup(element);
|
||||
assertMath(html, [expected], [...glyphs]);
|
||||
assert.ok(!html.includes("HIDDEN COMMITMENT"));
|
||||
}
|
||||
assertMath(renderToStaticMarkup(<ConcludeCard part={part(conclusion, { type: "tool-conclude", reveal: raw })} />),
|
||||
[expected, expected], [...glyphs]);
|
||||
assertMath(renderToStaticMarkup(<ConcludeCard part={part({ outcome: "solved", solutionReveal: raw })} />),
|
||||
[expected], [...glyphs]);
|
||||
assert.equal(JSON.stringify({ input, conclusion }), before);
|
||||
});
|
||||
}
|
||||
|
||||
it("keeps a boundary-converted numeric statement raw and preserves prior dock passthrough", () => {
|
||||
const raw = { statement: 17, mode: "recall", modelSolution: "Committed solution." };
|
||||
const call = { type: "toolCall", id: "numeric_problem", name: "problem", arguments: raw };
|
||||
const validated = validateToolArguments(
|
||||
{ name: "problem", parameters: problemInputObject } as never,
|
||||
call as never,
|
||||
) as { statement: unknown };
|
||||
assert.equal(validated.statement, "17", "the actual tool boundary converts its cloned arguments");
|
||||
assert.equal(raw.statement, 17, "saved arguments retain the number");
|
||||
const messages = [
|
||||
{ id: "numeric_pose", role: "assistant", content: [call], timestamp: 1 },
|
||||
{ id: "numeric_ack", role: "toolResult", toolCallId: call.id, toolName: "problem", content: [{ type: "text", text: "Problem posed" }], details: null, isError: false, timestamp: 2 },
|
||||
] as unknown as LhMessage[];
|
||||
const before = structuredClone(messages);
|
||||
const active = activeProblemOf(messages);
|
||||
assert.ok(active);
|
||||
assert.equal((active.input as { statement: unknown }).statement, 17);
|
||||
// Mirror the existing runtime cast at the Chat -> PracticeDock seam.
|
||||
const input = active.input as { statement: string };
|
||||
let expanded = "";
|
||||
assert.doesNotThrow(() => { expanded = renderToStaticMarkup(dock(input)); });
|
||||
const { document } = parseHTML(expanded);
|
||||
assert.equal(document.querySelector(".lh-prose")!.innerHTML,
|
||||
parseHTML(renderToStaticMarkup(<MessageResponse>{input.statement}</MessageResponse>)).document.firstElementChild!.outerHTML,
|
||||
"non-string values pass through exactly as before, without string coercion or a new fallback");
|
||||
const collapsed = renderToStaticMarkup(dock(input, false));
|
||||
assert.match(collapsed, /data-testid="practice-dock-bar"/);
|
||||
assert.equal(parseHTML(collapsed).document.querySelector("span.truncate")!.textContent, "17");
|
||||
assert.deepEqual(math(collapsed).annotations, []);
|
||||
assert.deepEqual(messages, before);
|
||||
assert.equal(raw.statement, 17);
|
||||
});
|
||||
|
||||
it("preserves code spans/fences across every repaired display surface", () => {
|
||||
const raw = "`" + doubled + "`\n\n```text\n" + doubled + "\n```";
|
||||
for (const element of [dock({ statement: raw }), <ProblemCard part={part({ statement: raw })} />,
|
||||
<ConcludeCard part={part({ outcome: "solved", verdict: raw }, { reveal: raw })} />]) {
|
||||
const html = renderToStaticMarkup(element);
|
||||
assert.deepEqual(math(html).annotations, []);
|
||||
const { document } = parseHTML(html);
|
||||
assert.ok(document.querySelector("code")?.textContent?.includes(doubled));
|
||||
}
|
||||
});
|
||||
|
||||
it("does not change the collapsed preview, dismissal or global prose renderer", () => {
|
||||
const preview = renderToStaticMarkup(dock({ statement: doubled }, false));
|
||||
assert.ok(preview.includes(doubled));
|
||||
assert.deepEqual(math(preview).annotations, []);
|
||||
const dismissed = renderToStaticMarkup(<ConcludeCard part={part({ outcome: "dismissed", verdict: doubled }, { reveal: doubled })} />);
|
||||
assert.ok(dismissed.includes("Problem dismissed."));
|
||||
assert.deepEqual(math(dismissed).annotations, []);
|
||||
const plain = math(renderToStaticMarkup(<MessageResponse>{doubled}</MessageResponse>));
|
||||
assert.deepEqual(plain.annotations, [doubled.slice(1, -1)]);
|
||||
assert.ok(!plain.glyphs.includes("∇")); // Same unchanged assistant/user MessageResponse path.
|
||||
});
|
||||
|
||||
it("teaching quiz/ask/explain still repair the same bytes without mutating their inputs", () => {
|
||||
const quiz = { question: doubled, options: [{ label: "yes" }, { label: "no" }], correctAnswer: "yes", explanation: debris };
|
||||
const ask = { question: doubled };
|
||||
const explain = { question: doubled, correctAnswer: debris };
|
||||
const before = structuredClone({ quiz, ask, explain });
|
||||
assert.equal(parseQuizInput(quiz)?.question, clean);
|
||||
assert.equal(parseQuizInput(quiz)?.explanation, `$${repairedDebris}$`);
|
||||
assert.equal(parseAskInput(ask)?.question, clean);
|
||||
assert.equal(parseExplainInput(explain)?.question, clean);
|
||||
assert.equal(parseExplainInput(explain)?.correctAnswer, `$${repairedDebris}$`);
|
||||
assertMath(renderToStaticMarkup(<MessageResponse>{parseAskInput(ask)!.question}</MessageResponse>));
|
||||
assert.deepEqual({ quiz, ask, explain }, before);
|
||||
});
|
||||
|
||||
it("native history, active args and the view join remain raw; commitment appears only at conclusion", () => {
|
||||
const stamps = { api: "faux", provider: "test", model: "test-model", timestamp: 1, stopReason: "toolUse" };
|
||||
const input = { statement: doubled, modelSolution: `COMMITTED ${quadrupled}` };
|
||||
const messages = [
|
||||
{ ...stamps, id: "a1", role: "assistant", content: [{ type: "toolCall", id: "p1", name: "problem", arguments: input }] },
|
||||
{ id: "r1", role: "toolResult", toolCallId: "p1", toolName: "problem", content: [{ type: "text", text: "Problem posed" }], details: null, isError: false, timestamp: 2 },
|
||||
] as unknown as LhMessage[];
|
||||
const concluded = [...messages,
|
||||
{ ...stamps, id: "a2", role: "assistant", content: [{ type: "toolCall", id: "c1", name: "conclude", arguments: { outcome: "solved", verdict: doubled } }] },
|
||||
{ id: "r2", role: "toolResult", toolCallId: "c1", toolName: "conclude", content: [{ type: "text", text: "Problem concluded" }], details: null, isError: false, timestamp: 3 },
|
||||
] as unknown as LhMessage[];
|
||||
const before = structuredClone(concluded);
|
||||
const history = structuredClone(toLlmMessages(concluded));
|
||||
assert.deepEqual(activeProblemOf(messages)?.input, input);
|
||||
const problem = projectView(messages)[0]!.parts.find((p) => p.type === "tool-problem")!;
|
||||
assert.ok(problem.state && problem.toolCallId);
|
||||
assert.deepEqual(problem.input, input);
|
||||
const statementHtml = renderToStaticMarkup(<ProblemCard part={{ ...problem, state: problem.state, toolCallId: problem.toolCallId }} />);
|
||||
assertMath(statementHtml);
|
||||
assert.ok(!statementHtml.includes("COMMITTED"));
|
||||
const conclusion = projectView(concluded).flatMap((m) => m.parts).find((p) => p.type === "tool-conclude")!;
|
||||
assert.ok(conclusion.state && conclusion.toolCallId);
|
||||
assert.equal(conclusion.reveal, input.modelSolution);
|
||||
const revealHtml = renderToStaticMarkup(<ConcludeCard part={{ ...conclusion, state: conclusion.state, toolCallId: conclusion.toolCallId }} />);
|
||||
assert.ok(revealHtml.includes("COMMITTED"));
|
||||
assertMath(revealHtml, [tex, tex]);
|
||||
assert.deepEqual(concluded, before);
|
||||
assert.deepEqual(toLlmMessages(concluded), history);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user