fix: share question math repair with practice displays
This commit is contained in:
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { MessageResponse } from "@/components/ai-elements/message";
|
import { MessageResponse } from "@/components/ai-elements/message";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { ArtifactPartProps } from "@/src/core";
|
import { repairMathForDisplay, type ArtifactPartProps } from "@/src/core";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The practice transcript cards (practice sessions, owner design
|
* The practice transcript cards (practice sessions, owner design
|
||||||
@@ -66,7 +66,7 @@ export function ProblemCard({ part }: ArtifactPartProps) {
|
|||||||
<div data-testid="problem-card" className="lh-card-enter my-1 rounded-xl border bg-card p-4">
|
<div data-testid="problem-card" className="lh-card-enter my-1 rounded-xl border bg-card p-4">
|
||||||
<div className="mb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">Problem</div>
|
<div className="mb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">Problem</div>
|
||||||
<div className="lh-prose text-[15px]">
|
<div className="lh-prose text-[15px]">
|
||||||
<MessageResponse>{statement}</MessageResponse>
|
<MessageResponse>{repairMathForDisplay(statement)}</MessageResponse>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -133,7 +133,7 @@ export function ConcludeCard({ part }: ArtifactPartProps) {
|
|||||||
(owner round 3): the same quiet 50% register as quiz explanations. */}
|
(owner round 3): the same quiet 50% register as quiz explanations. */}
|
||||||
{verdict.length > 0 && (
|
{verdict.length > 0 && (
|
||||||
<div className="text-sm text-foreground/50">
|
<div className="text-sm text-foreground/50">
|
||||||
<MessageResponse>{verdict}</MessageResponse>
|
<MessageResponse>{repairMathForDisplay(verdict)}</MessageResponse>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* Then the divider, then the labeled model solution (Slice G: the
|
{/* Then the divider, then the labeled model solution (Slice G: the
|
||||||
@@ -144,7 +144,7 @@ export function ConcludeCard({ part }: ArtifactPartProps) {
|
|||||||
<div>
|
<div>
|
||||||
<div className="mb-0.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">Model solution</div>
|
<div className="mb-0.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">Model solution</div>
|
||||||
<div className="lh-prose text-[15px]">
|
<div className="lh-prose text-[15px]">
|
||||||
<MessageResponse>{reveal}</MessageResponse>
|
<MessageResponse>{repairMathForDisplay(reveal)}</MessageResponse>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { joinDictation, useDictationTarget } from "@/components/dictation";
|
import { joinDictation, useDictationTarget } from "@/components/dictation";
|
||||||
import { useEnterSubmits } from "@/components/enter-submits";
|
import { useEnterSubmits } from "@/components/enter-submits";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { repairMathForDisplay } from "@/src/core";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The practice dock (practice sessions, owner design 2026-10-04): an
|
* The practice dock (practice sessions, owner design 2026-10-04): an
|
||||||
@@ -108,7 +109,7 @@ export function PracticeDock({ problemCallId, input, expanded, onExpand, onSubmi
|
|||||||
<div className="max-h-[var(--dock-max-h,40vh)] overflow-y-auto px-4 pt-3 pb-2">
|
<div className="max-h-[var(--dock-max-h,40vh)] overflow-y-auto px-4 pt-3 pb-2">
|
||||||
<div className="mb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">Problem</div>
|
<div className="mb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">Problem</div>
|
||||||
<div className="lh-prose text-[15px]">
|
<div className="lh-prose text-[15px]">
|
||||||
<MessageResponse>{input.statement}</MessageResponse>
|
<MessageResponse>{typeof input.statement === "string" ? repairMathForDisplay(input.statement) : input.statement}</MessageResponse>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -325,6 +325,11 @@ const DEBRIS_RE = /[\b\t\n\v\f\r]/;
|
|||||||
* `\right`/`\rho`/`\rangle` corruption is common in display math). */
|
* `\right`/`\rho`/`\rangle` corruption is common in display math). */
|
||||||
const DISPLAY_DEBRIS_RE = /[\b\t\v\f\r]/;
|
const DISPLAY_DEBRIS_RE = /[\b\t\v\f\r]/;
|
||||||
|
|
||||||
|
/** Shared display-only repair. Keep raw arguments and model history unchanged. */
|
||||||
|
export function repairMathForDisplay(text: string): string {
|
||||||
|
return healOverescapedCommands(restoreMathEscapes(text));
|
||||||
|
}
|
||||||
|
|
||||||
export function restoreMathEscapes(text: string): string {
|
export function restoreMathEscapes(text: string): string {
|
||||||
if (!DEBRIS_RE.test(text)) return text;
|
if (!DEBRIS_RE.test(text)) return text;
|
||||||
return restoreDollarMathEscapes(restoreLegacyMathEscapes(text));
|
return restoreDollarMathEscapes(restoreLegacyMathEscapes(text));
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
import { Value } from "typebox/value";
|
import { Value } from "typebox/value";
|
||||||
import type { TSchema } from "typebox";
|
import type { TSchema } from "typebox";
|
||||||
import { quizDisplayInputSchema, askInputSchema, askOptionProblems, quizInputProblems, explainInputProblems, explainDisplayInputSchema, type AskToolInput, type QuizToolInput, type ExplainToolInput } from "./question-core.ts";
|
import { quizDisplayInputSchema, askInputSchema, askOptionProblems, quizInputProblems, explainInputProblems, explainDisplayInputSchema, type AskToolInput, type QuizToolInput, type ExplainToolInput } from "./question-core.ts";
|
||||||
import { restoreMathEscapes, healOverescapedCommands } from "./math-delims.ts";
|
import { repairMathForDisplay } from "./math-delims.ts";
|
||||||
import { healEncodedArguments } from "./argument-heal.ts";
|
import { healEncodedArguments } from "./argument-heal.ts";
|
||||||
import { probeDoneInputSchema, type ProbeDoneInput } from "./contracts/probe.ts";
|
import { probeDoneInputSchema, type ProbeDoneInput } from "./contracts/probe.ts";
|
||||||
|
|
||||||
@@ -79,7 +79,7 @@ function boundaryParse<T>(schema: TSchema, input: unknown): T | undefined {
|
|||||||
* plus "nabla" as letters), so even runs before a command start inside
|
* plus "nabla" as letters), so even runs before a command start inside
|
||||||
* balanced math spans collapse to one backslash. */
|
* balanced math spans collapse to one backslash. */
|
||||||
function restoreMathEscapesDeep(value: unknown): unknown {
|
function restoreMathEscapesDeep(value: unknown): unknown {
|
||||||
if (typeof value === "string") return healOverescapedCommands(restoreMathEscapes(value));
|
if (typeof value === "string") return repairMathForDisplay(value);
|
||||||
if (Array.isArray(value)) return value.map(restoreMathEscapesDeep);
|
if (Array.isArray(value)) return value.map(restoreMathEscapesDeep);
|
||||||
if (typeof value === "object" && value !== null) {
|
if (typeof value === "object" && value !== null) {
|
||||||
const out: Record<string, unknown> = {};
|
const out: Record<string, unknown> = {};
|
||||||
|
|||||||
@@ -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