Merge source-preserving math content presentation
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
/** Read-only provenance for historical question cards. Never persist this
|
||||
* projection: answer identities, ordering, grading, and learner text remain
|
||||
* the recorded facts; only model-authored display text comes from the call. */
|
||||
import { normalizeOptions, type QuestionAnswerData } from "./question-core.ts";
|
||||
import { parseAskInput, parseExplainInput, parseQuizInput } from "./tool-parts.ts";
|
||||
import { repairMathForDisplay } from "./math-delims.ts";
|
||||
|
||||
export function answerForPresentation(answer: QuestionAnswerData, input: unknown): QuestionAnswerData {
|
||||
if (answer.dismissed) return answer;
|
||||
const quiz = answer.kind === "quiz" ? parseQuizInput(input) : undefined;
|
||||
const explain = answer.kind === "explain" ? parseExplainInput(input) : undefined;
|
||||
const source = quiz ?? explain ?? (answer.kind === "ask" ? parseAskInput(input) : undefined);
|
||||
if (!source) return answer;
|
||||
const projected = { ...answer, questionText: source.question };
|
||||
if (quiz) projected.explanation = quiz.explanation;
|
||||
if (explain) projected.committed = explain.correctAnswer;
|
||||
if (!answer.options?.length || !("options" in source) || !source.options?.length) return projected;
|
||||
|
||||
const original = normalizeOptions(source.options);
|
||||
const saved = answer.options;
|
||||
const bindings = new Map<number, number>(); // saved position -> original position
|
||||
const used = new Set<number>();
|
||||
const bindUnique = (candidates: number[][]) => {
|
||||
for (let si = 0; si < saved.length; si++) {
|
||||
if (bindings.has(si)) continue;
|
||||
const possible = candidates[si]!.filter((oi) => !used.has(oi));
|
||||
if (possible.length !== 1) continue;
|
||||
const oi = possible[0]!;
|
||||
// Two saved rows claiming the same source are ambiguous, even if
|
||||
// each row independently has just one candidate.
|
||||
if (candidates.some((other, sj) => sj !== si && !bindings.has(sj) && other.includes(oi))) continue;
|
||||
bindings.set(si, oi);
|
||||
used.add(oi);
|
||||
}
|
||||
};
|
||||
// Selected-answer values are stable identities when the record retained
|
||||
// them. Do not normalize those values, recompute a shuffle, or regrade.
|
||||
bindUnique(saved.map((option) => {
|
||||
const values = answer.answers.filter((a) => a.index === option.index).map((a) => a.value);
|
||||
return original.flatMap((o, oi) => values.includes(o.value) ? [oi] : []);
|
||||
}));
|
||||
const recoveredLabels = original.map((o) => repairMathForDisplay(o.label));
|
||||
bindUnique(saved.map((option) => original.flatMap((o, oi) =>
|
||||
o.label === option.label || recoveredLabels[oi] === option.label ? [oi] : [])));
|
||||
// A bijection with one remaining pair has no ordering ambiguity. With
|
||||
// multiple unmatched rows, keep their snapshots rather than guess.
|
||||
if (saved.length === original.length && bindings.size === saved.length - 1) {
|
||||
const si = saved.findIndex((_, i) => !bindings.has(i));
|
||||
const oi = original.findIndex((_, i) => !used.has(i));
|
||||
bindings.set(si, oi);
|
||||
}
|
||||
projected.options = saved.map((option, si) => {
|
||||
const oi = bindings.get(si);
|
||||
return oi === undefined ? option : { ...option, label: original[oi]!.label };
|
||||
});
|
||||
return projected;
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
/** Source-preserving boundaries for tool-authored Markdown. Recovery never
|
||||
* mutates input, reserializes Markdown, or enters protected literal content. */
|
||||
import katex from "katex";
|
||||
import { fromMarkdown } from "mdast-util-from-markdown";
|
||||
import { mathFromMarkdown } from "mdast-util-math";
|
||||
import { math } from "micromark-extension-math";
|
||||
|
||||
export type ContentSpan =
|
||||
| { kind: "prose" | "code"; raw: string }
|
||||
| { kind: "math"; raw: string; open: string; body: string; close: string; display: boolean };
|
||||
const blankLine = /^\r?\n[ \t]*\r?\n/;
|
||||
const escaped = (text: string, i: number) => {
|
||||
let n = 0; while (i > 0 && text[--i] === "\\") n++;
|
||||
return n % 2 === 1;
|
||||
};
|
||||
const runLength = (text: string, i: number, char: string) => { let n = 0; while (text[i + n] === char) n++; return n; };
|
||||
type MarkdownNode = { type: string; position?: { start: { offset?: number }; end: { offset?: number } }; children?: MarkdownNode[] };
|
||||
/** Use the rendering stack's actual CommonMark+math grammar for literal
|
||||
* ranges, including nested quotes/lists and implicit container endings.
|
||||
* Keep source offsets: never serialize the AST back into Markdown. */
|
||||
function literalRanges(text: string): Array<{ start: number; end: number }> {
|
||||
const pending: MarkdownNode[] = [fromMarkdown(text, { extensions: [math({ singleDollarTextMath: true })], mdastExtensions: [mathFromMarkdown()] })];
|
||||
const ranges: Array<{ start: number; end: number }> = [];
|
||||
while (pending.length) {
|
||||
const node = pending.pop()!;
|
||||
if (node.type === "code" || node.type === "inlineCode") {
|
||||
const start = node.position?.start.offset, end = node.position?.end.offset;
|
||||
if (start !== undefined && end !== undefined) ranges.push({ start, end });
|
||||
} else if (node.children) pending.push(...node.children);
|
||||
}
|
||||
return ranges.sort((a, b) => a.start - b.start);
|
||||
}
|
||||
|
||||
/** Mirrors the app's native dollar-run and legacy-delimiter conventions,
|
||||
* with code and blank paragraphs as inline boundaries. */
|
||||
export function readContentSpans(text: string): ContentSpan[] {
|
||||
const spans: ContentSpan[] = [];
|
||||
const literals = literalRanges(text);
|
||||
let literal = 0, i = 0, plain = 0;
|
||||
const opaque = (end: number) => {
|
||||
if (plain < i) spans.push({ kind: "prose", raw: text.slice(plain, i) });
|
||||
spans.push({ kind: "code", raw: text.slice(i, end) }); i = plain = end;
|
||||
};
|
||||
while (i < text.length) {
|
||||
while (literal < literals.length && literals[literal]!.end <= i) literal++;
|
||||
const barrier = literals[literal]?.start ?? Infinity;
|
||||
if (i >= barrier) { opaque(literals[literal]!.end); continue; }
|
||||
let open = "", close = "", display = false, after = -1, at = -1;
|
||||
if (text[i] === "\\" && !escaped(text, i) && ["(", "["].includes(text[i + 1] ?? "")) {
|
||||
open = text.slice(i, i + 2); close = open === "\\(" ? "\\)" : "\\]"; display = open === "\\[";
|
||||
for (let j = i + 2; j < text.length - 1; j++) {
|
||||
if (j >= barrier || (!display && blankLine.test(text.slice(j)))) break;
|
||||
if (text.startsWith(close, j) && !escaped(text, j)) { at = j; after = j + 2; break; }
|
||||
}
|
||||
} else if (text[i] === "$" && !escaped(text, i)) {
|
||||
const size = runLength(text, i, "$"); open = "$".repeat(size);
|
||||
const eol = text.indexOf("\n", i + size);
|
||||
const before = text.slice(text.lastIndexOf("\n", i - 1) + 1, i);
|
||||
display = size >= 2 && /^[ \t]{0,3}$/.test(before) && !text.slice(i + size, eol < 0 ? text.length : eol).includes("$");
|
||||
if (display) {
|
||||
let start = eol;
|
||||
while (start >= 0 && start < text.length) {
|
||||
start++;
|
||||
const next = text.indexOf("\n", start);
|
||||
const match = /^([ \t]{0,3})(\$+)[ \t]*\r?$/.exec(text.slice(start, next < 0 ? text.length : next));
|
||||
if (match && match[2]!.length >= size) { at = start + match[1]!.length; close = match[2]!; after = at + close.length; break; }
|
||||
start = next;
|
||||
}
|
||||
} else {
|
||||
for (let j = i + size; j < text.length;) {
|
||||
if (j >= barrier || blankLine.test(text.slice(j))) break;
|
||||
if (text[j] === "$" && !escaped(text, j)) {
|
||||
const n = runLength(text, j, "$");
|
||||
if (n === size) { at = j; close = open; after = j + n; break; }
|
||||
j += n;
|
||||
} else j++;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (at >= 0) {
|
||||
if (plain < i) spans.push({ kind: "prose", raw: text.slice(plain, i) });
|
||||
spans.push({ kind: "math", raw: text.slice(i, after), open, body: text.slice(i + open.length, at), close, display });
|
||||
i = plain = after;
|
||||
} else i += open.length || 1;
|
||||
}
|
||||
if (plain < text.length) spans.push({ kind: "prose", raw: text.slice(plain) });
|
||||
return spans;
|
||||
}
|
||||
|
||||
export function mapMathContent(text: string, transform: (body: string, display: boolean) => string): string {
|
||||
return readContentSpans(text).map(span => span.kind === "math" ? span.open + transform(span.body, span.display) + span.close : span.raw).join("");
|
||||
}
|
||||
|
||||
const knownCommands = new Map<string, boolean>();
|
||||
/** Probe only a command name, never arbitrary content. Missing-argument errors
|
||||
* still identify a known command. No trust, macros, IO, or persistent TeX state. */
|
||||
export function knownTexCommand(name: string): boolean {
|
||||
if (!/^[A-Za-z]+$/.test(name)) return false;
|
||||
const cached = knownCommands.get(name); if (cached !== undefined) return cached;
|
||||
let known = true;
|
||||
try { katex.renderToString("\\" + name, { throwOnError: true, trust: false, strict: false }); }
|
||||
catch (error) { known = !String(error).includes("Undefined control sequence: \\" + name); }
|
||||
knownCommands.set(name, known);
|
||||
return known;
|
||||
}
|
||||
|
||||
export function canParseTex(body: string, display: boolean): boolean {
|
||||
try { katex.renderToString(body, { throwOnError: true, trust: false, strict: false, displayMode: display }); return true; }
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
export interface TexRange { start: number; end: number; kind: "row" | "encoded" | "comment"; factor?: number }
|
||||
export function numericPercentEscape(inner: string, percent: number): boolean {
|
||||
let start = percent; while (start > 0 && inner[start - 1] === "\\") start--;
|
||||
if (percent - start < 2 || (percent - start) % 2 !== 0 || !/\d[ \t]*$/.test(inner.slice(0, start))) return false;
|
||||
const tail = inner.slice(percent + 1);
|
||||
// A final source-formatting LF before the closing fence is not a next
|
||||
// equation row. A meaningful following line or comment text remains literal.
|
||||
if (/^[\s.)\]}]*$/.test(tail)) return true;
|
||||
return !/[\r\n]/.test(tail) && !/^[ \t]*[A-Za-z]/.test(tail);
|
||||
}
|
||||
|
||||
/** TeX boundaries shared by document decoding and command recovery. Native
|
||||
* rows are literal; consistently extra-escaped environment pairs supply an
|
||||
* explicit escaping factor for both commands AND row separators. */
|
||||
export function texRanges(inner: string): TexRange[] {
|
||||
const ranges: TexRange[] = [];
|
||||
const stack: Array<{ name: string; start: number; factor: number }> = [];
|
||||
let i = 0;
|
||||
while (i < inner.length) {
|
||||
if (inner[i] === "%" && !escaped(inner, i)) {
|
||||
let start = i; while (start > 0 && inner[start - 1] === "\\") start--;
|
||||
const factor = stack.at(-1)?.factor ?? ranges.filter(r => r.kind === "encoded" && i >= r.start && i < r.end).at(-1)?.factor;
|
||||
const encodedLiteral = factor && factor > 1 && (i - start) % factor === 0 && ((i - start) / factor) % 2 === 1;
|
||||
const native = stack.some(frame => frame.factor === 1) || ranges.some(r => r.kind === "row" && i >= r.start && i < r.end);
|
||||
if (encodedLiteral || (!native && numericPercentEscape(inner, i))) { i++; continue; }
|
||||
const newline = inner.indexOf("\n", i);
|
||||
const end = newline < 0 ? inner.length : newline + 1;
|
||||
ranges.push({ start: i, end, kind: "comment" }); i = end; continue;
|
||||
}
|
||||
if (inner[i] !== "\\") { i++; continue; }
|
||||
let end = i + 1; while (inner[end] === "\\") end++;
|
||||
const count = end - i;
|
||||
const marker = /^(begin|end)\s*\{([^{}]+)\}/.exec(inner.slice(end));
|
||||
if (marker) {
|
||||
const factor = count % 2 === 1 ? 1 : count;
|
||||
if (marker[1] === "begin") stack.push({ name: marker[2]!, start: i, factor });
|
||||
else if (stack.at(-1)?.name === marker[2] && stack.at(-1)?.factor === factor) {
|
||||
const open = stack.pop()!;
|
||||
ranges.push({ start: open.start, end: end + marker[0].length, kind: factor === 1 ? "row" : "encoded", factor });
|
||||
}
|
||||
i = end + marker[0].length; continue;
|
||||
}
|
||||
// KaTeX's substack macro expands its braced argument into subarray:
|
||||
// these are row separators even without literal begin/end markers.
|
||||
const substack = /^substack\s*\{/.exec(inner.slice(end));
|
||||
if (substack) {
|
||||
let depth = 1, j = end + substack[0].length;
|
||||
for (; j < inner.length && depth > 0; j++) {
|
||||
if (inner[j] === "%" && !escaped(inner, j)) { const lf = inner.indexOf("\n", j); j = lf < 0 ? inner.length : lf; continue; }
|
||||
if (!escaped(inner, j)) { if (inner[j] === "{") depth++; else if (inner[j] === "}") depth--; }
|
||||
}
|
||||
if (depth === 0) ranges.push({ start: i, end: j, kind: count % 2 === 1 ? "row" : "encoded", factor: count % 2 === 1 ? 1 : count });
|
||||
}
|
||||
i = end;
|
||||
}
|
||||
return ranges.sort((a, b) => a.start - b.start);
|
||||
}
|
||||
|
||||
export function outsideTexComments(body: string, transform: (part: string) => string): string {
|
||||
let from = 0; const out: string[] = [];
|
||||
for (const range of texRanges(body).filter(r => r.kind === "comment")) {
|
||||
out.push(transform(body.slice(from, range.start)), body.slice(range.start, range.end)); from = range.end;
|
||||
}
|
||||
out.push(transform(body.slice(from))); return out.join("");
|
||||
}
|
||||
|
||||
/** Document escapes are not arbitrary string decoding. Complete known TeX
|
||||
* words win over the prefix `\\n`; code is never inspected or rewritten. */
|
||||
export function recoverDocumentLayout(text: string): string {
|
||||
const spans = readContentSpans(text);
|
||||
const candidates = spans.filter(span => span.kind !== "code").map(span => span.raw).join("\u0000");
|
||||
const declared = new Set([
|
||||
...candidates.matchAll(/\\(?:newcommand|renewcommand|providecommand)\*?\s*(?:\{\s*)?\\(n[A-Za-z]+)/g),
|
||||
...candidates.matchAll(/\\(?:def|gdef|edef)\s*\\(n[A-Za-z]+)/g),
|
||||
].map(m => m[1]!));
|
||||
const knownN = (name: string) => declared.has(name) || knownTexCommand(name);
|
||||
const tokens = [...candidates.matchAll(/(\\+)(n[A-Za-z]*)/g)];
|
||||
const evidence = tokens.filter(m => m[1]!.length % 2 === 1 && !knownN(m[2]!));
|
||||
const structural = evidence.length >= 2 || candidates.includes("\\n\\n") || (evidence.length > 0 &&
|
||||
[...candidates.matchAll(/(\\+)([A-Za-z]+)/g)].some(m => m[1]!.length % 2 === 0 && knownTexCommand(m[2]!)));
|
||||
if (!structural) return text;
|
||||
const decode = (part: string) => part.replace(/(\\+)(n[A-Za-z]*)/g, (whole: string, slashes: string, name: string, offset: number) => {
|
||||
if (slashes.length % 2 === 0 || knownN(name)) return whole;
|
||||
const word = part.slice(0, offset).split(/\s/).at(-1) ?? "";
|
||||
if (/^(?:[A-Za-z]:[\\/]|https?:\/\/|\/)/.test(word + whole)) return whole;
|
||||
return slashes.slice(0, -1) + "\n" + name.slice(1);
|
||||
});
|
||||
const projected = spans.map(span => ({ span, body: span.kind === "math" ? outsideTexComments(span.body, decode) : span.kind === "code" ? span.raw : decode(span.raw) }));
|
||||
let out = "";
|
||||
for (let i = 0; i < projected.length; i++) {
|
||||
const { span, body } = projected[i]!;
|
||||
if (span.kind !== "math") { out += body; continue; }
|
||||
// Decoding document layout can expose a flow opener whose close was
|
||||
// written on the formula line. Frame BOTH ends; otherwise Markdown
|
||||
// consumes the closing dollars as TeX and introduces a rendering error.
|
||||
if (/^\${2,}$/.test(span.open) && body !== span.body && body.includes("\n")) {
|
||||
if (!/^[ \t]{0,3}$/.test(out.slice(out.lastIndexOf("\n") + 1))) out += "\n\n";
|
||||
out += span.open + (body.startsWith("\n") ? "" : "\n") + body + (body.endsWith("\n") ? "" : "\n") + span.close;
|
||||
const next = projected[i + 1];
|
||||
if (next && !/^[\r\n]/.test(next.span.kind === "math" ? next.span.open : next.body)) out += "\n\n";
|
||||
} else out += span.open + body + span.close;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -47,6 +47,7 @@ export * from "./input-events.ts";
|
||||
// the deleted server projection to the client side. Pure, browser-safe.
|
||||
export * from "./view.ts";
|
||||
export * from "./tool-parts.ts";
|
||||
export * from "./answer-presentation.ts";
|
||||
export * from "./prices.ts";
|
||||
export * from "./context-usage.ts";
|
||||
export type { ChatGPTStatus, ChatGPTControlBridge } from "./contracts/chatgpt.ts";
|
||||
|
||||
+107
-558
@@ -1,572 +1,121 @@
|
||||
/**
|
||||
* Legacy math delimiter preprocessing — the \(...\)/\[...\] reader.
|
||||
*
|
||||
* Current authoring uses `$...$` inline and `$$...$$` on separate lines for
|
||||
* display (owner, 2026-10-12). This pure transform keeps correctly formed
|
||||
* old backslash-delimited content readable; dollar math passes through to
|
||||
* Streamdown's native parser. Escape restoration below recognizes both
|
||||
* syntaxes while retaining the existing recovery rules.
|
||||
*
|
||||
* Rules:
|
||||
* - `\(...\)` → `$$...$$` (two-dollar inline, still accepted by micromark),
|
||||
* `\[...\]` → `$$\n...\n$$` (block form: flow math).
|
||||
* - An escaped delimiter (`\\(` = literal backslash + paren) is left alone.
|
||||
* - Nothing inside fenced code blocks or inline code spans is touched.
|
||||
* - Unbalanced delimiters are left untouched: Streamdown streams chunks, and
|
||||
* an incomplete `\[` completes on a later chunk (the whole text is
|
||||
* re-transformed each render).
|
||||
*
|
||||
* Pure string → string. No markdown parsing — a single linear scan with a
|
||||
* code-state machine, cheap enough to run in the render path of a memo'd
|
||||
* component.
|
||||
*/
|
||||
/** Shared Markdown/TeX boundaries for legacy delimiter compatibility and
|
||||
* display-only tool-content recovery. Source arguments are never rewritten. */
|
||||
import {
|
||||
canParseTex, knownTexCommand, mapMathContent, numericPercentEscape, outsideTexComments,
|
||||
readContentSpans, recoverDocumentLayout, texRanges,
|
||||
} from "./content-reader.ts";
|
||||
|
||||
const INLINE_OPEN = "\\(";
|
||||
const INLINE_CLOSE = "\\)";
|
||||
const DISPLAY_OPEN = "\\[";
|
||||
const DISPLAY_CLOSE = "\\]";
|
||||
|
||||
const FENCE_OPEN_RE = /^ {0,3}(`{3,}|~{3,})/;
|
||||
|
||||
/**
|
||||
* Is the backslash at `i` itself escaped (i.e. preceded by an odd number of
|
||||
* backslashes)? If so, the `\(`/`\[` starting there is a literal backslash
|
||||
* followed by a bracket — not a delimiter.
|
||||
*/
|
||||
function isEscaped(text: string, i: number): boolean {
|
||||
let count = 0;
|
||||
for (let j = i - 1; j >= 0 && text[j] === "\\"; j--) count += 1;
|
||||
return count % 2 === 1;
|
||||
}
|
||||
|
||||
/** Length of the backtick run starting at `i` (0 if `text[i]` is not a tick). */
|
||||
function backtickRun(text: string, i: number): number {
|
||||
let count = 0;
|
||||
while (text[i + count] === "`") count += 1;
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to find the closing delimiter for the opening one at `i` (2 chars,
|
||||
* already verified unescaped). The close scan stops at inline code spans and
|
||||
* fence starts (barriers — math never contains them), and returns -1 when the
|
||||
* delimiter is unbalanced (mid-stream chunk).
|
||||
*/
|
||||
function findClose(
|
||||
text: string,
|
||||
from: number,
|
||||
close: string,
|
||||
): number {
|
||||
for (let j = from; j < text.length - 1; j++) {
|
||||
if (text[j] === "`") return -1; // inline code barrier
|
||||
if (
|
||||
text[j] === "\\" &&
|
||||
text[j + 1] === close[1] &&
|
||||
!isEscaped(text, j)
|
||||
) {
|
||||
return j;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/** True when `i` sits at the start of a line (position 0 or right after \n). */
|
||||
function atLineStart(text: string, i: number): boolean {
|
||||
return i === 0 || text[i - 1] === "\n";
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy a fenced code block starting at `i` (line start, fence regex matched)
|
||||
* verbatim to the output. Returns the index after the closing fence line —
|
||||
* or the end of the text when the fence is unclosed (mid-stream: everything
|
||||
* after is literal code until the chunk completes).
|
||||
*/
|
||||
function copyFencedBlock(text: string, i: number, out: string[]): number {
|
||||
const open = FENCE_OPEN_RE.exec(text.slice(i));
|
||||
const marker = open?.[1] ?? "```";
|
||||
const closeRe = new RegExp(`^ {0,3}\\${marker[0]}{${marker.length},}[ \\t]*$`);
|
||||
let j = i;
|
||||
// Copy lines until the matching closing fence.
|
||||
while (j < text.length) {
|
||||
const lineEnd = text.indexOf("\n", j);
|
||||
const next = lineEnd === -1 ? text.length : lineEnd + 1;
|
||||
const line = text.slice(j, lineEnd === -1 ? text.length : lineEnd);
|
||||
out.push(text.slice(j, next));
|
||||
if (j > i && closeRe.test(line)) return next; // closing fence line
|
||||
j = next;
|
||||
}
|
||||
return text.length;
|
||||
}
|
||||
|
||||
/** Is the `\[` at `i` block-positioned (own line, only whitespace before)? */
|
||||
function displayIsBlockPositioned(text: string, i: number): boolean {
|
||||
for (let j = i - 1; j >= 0; j--) {
|
||||
const ch = text[j];
|
||||
if (ch === "\n") return true;
|
||||
if (ch !== " " && ch !== "\t") return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Is the `\[` at `i` indented (whitespace before it on the line)? */
|
||||
function displayIsIndented(text: string, i: number): boolean {
|
||||
let sawWhitespace = false;
|
||||
for (let j = i - 1; j >= 0; j--) {
|
||||
const ch = text[j];
|
||||
if (ch === "\n") return sawWhitespace;
|
||||
if (ch !== " " && ch !== "\t") return false; // mid-paragraph
|
||||
sawWhitespace = true;
|
||||
}
|
||||
return sawWhitespace;
|
||||
}
|
||||
|
||||
/** Does the closing delimiter end its line (only \n or EOF after it)? */
|
||||
function closeEndsLine(text: string, afterClose: number): boolean {
|
||||
return afterClose >= text.length || text[afterClose] === "\n";
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite balanced `\(...\)` → `$$...$$` and `\[...\]` → `$$\n...\n$$`.
|
||||
* Everything else passes through byte-for-byte.
|
||||
*/
|
||||
/** The escaped-dollar-aware inline closer (owner, 2026-10-12): micromark's
|
||||
* mathText closer is code-span logic — ANY size-matched `$` run closes,
|
||||
* `\$` included, so `$x = \$5$` closes at the `\$` and errors. KaTeX's
|
||||
* own auto-render scanner skips escaped dollars, and KaTeX renders `\$`
|
||||
* fine — the author's intent is unambiguous: an escaped dollar (odd number
|
||||
* of backslashes before it) never closes the span. Mirrors findDollarClose's
|
||||
* inline rules otherwise (exact size match, whole-run skip). */
|
||||
function findInlineDollarCloseEscapeAware(text: string, from: number): number {
|
||||
for (let j = from; j < text.length;) {
|
||||
if (text[j] !== "$") {
|
||||
j += 1;
|
||||
continue;
|
||||
}
|
||||
if (isEscaped(text, j)) {
|
||||
j += 1;
|
||||
continue; // `\$` never closes
|
||||
}
|
||||
const count = dollarRun(text, j);
|
||||
if (count === 1) return j;
|
||||
j += count;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/** Replace `\$` (odd-backslash dollars) inside a math span with the
|
||||
* dollar-free `\text{\textdollar}` — KaTeX-safe in math mode (bare
|
||||
* `\textdollar` is text-mode-only there). Even-backslash dollars (`\\$`,
|
||||
* row break + dollar) are left alone. */
|
||||
function replaceEscapedDollars(content: string): string {
|
||||
let replaced = "";
|
||||
for (let j = 0; j < content.length;) {
|
||||
if (content[j] === "\\" && content[j + 1] === "$" && !isEscaped(content, j)) {
|
||||
replaced += "\\text{\\textdollar}";
|
||||
j += 2;
|
||||
} else {
|
||||
replaced += content[j];
|
||||
j += 1;
|
||||
}
|
||||
}
|
||||
return replaced;
|
||||
}
|
||||
|
||||
export function transformMathDelimiters(markdown: string): string {
|
||||
const out: string[] = [];
|
||||
let i = 0;
|
||||
|
||||
while (i < markdown.length) {
|
||||
const ch = markdown[i]!;
|
||||
|
||||
// Fenced code blocks: copied verbatim (only detectable at line start).
|
||||
if (atLineStart(markdown, i) && FENCE_OPEN_RE.test(markdown.slice(i))) {
|
||||
i = copyFencedBlock(markdown, i, out);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Inline code spans: copied verbatim.
|
||||
if (ch === "`") {
|
||||
const run = backtickRun(markdown, i);
|
||||
let j = i + run;
|
||||
// Find the matching run (same length, per CommonMark).
|
||||
while (j < markdown.length) {
|
||||
const nextRun = backtickRun(markdown, j);
|
||||
if (nextRun === run) break;
|
||||
j += Math.max(nextRun, 1);
|
||||
}
|
||||
if (j < markdown.length) {
|
||||
out.push(markdown.slice(i, j + run));
|
||||
i = j + run;
|
||||
} else {
|
||||
// Unclosed run (mid-stream): literal, keep scanning after it.
|
||||
out.push(markdown.slice(i, i + run));
|
||||
i += run;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Math delimiters.
|
||||
if (ch === "\\" && !isEscaped(markdown, i)) {
|
||||
const next = markdown[i + 1];
|
||||
if (next === "(" || next === "[") {
|
||||
const isDisplay = next === "[";
|
||||
const open = isDisplay ? DISPLAY_OPEN : INLINE_OPEN;
|
||||
const close = isDisplay ? DISPLAY_CLOSE : INLINE_CLOSE;
|
||||
const closeAt = findClose(markdown, i + open.length, close);
|
||||
if (closeAt !== -1) {
|
||||
const content = markdown.slice(i + open.length, closeAt).trim();
|
||||
if (content.length > 0) {
|
||||
if (isDisplay && displayIsIndented(markdown, i)) {
|
||||
// Indented display math (inside a list item, quote, …):
|
||||
// an indented $$ flow block half-parses inside list
|
||||
// continuations (empty math, eaten items), so degrade to
|
||||
// the inline form — renders inline, stays inside the item.
|
||||
// mathText cannot span line endings, so collapse them.
|
||||
out.push(`$$${content.replace(/\s*\n\s*/g, " ")}$$`);
|
||||
} else if (isDisplay) {
|
||||
// Flow math ($$ alone on its lines) renders as display
|
||||
// KaTeX. Mid-paragraph \[...\] (or trailing text after the
|
||||
// closer) would half-parse into literal dollars + KaTeX
|
||||
// errors — pad with blank lines to force a clean block.
|
||||
// Block-positioned math passes through in place.
|
||||
const afterClose = closeAt + close.length;
|
||||
const padBefore =
|
||||
displayIsBlockPositioned(markdown, i) ? "" : "\n\n";
|
||||
const padAfter =
|
||||
closeEndsLine(markdown, afterClose) ? "" : "\n\n";
|
||||
out.push(`${padBefore}$$\n${content}\n$$${padAfter}`);
|
||||
} else {
|
||||
// Preserve the legacy reader's two-dollar inline output
|
||||
// (renders inline, not display).
|
||||
out.push(`$$${content}$$`);
|
||||
}
|
||||
i = closeAt + close.length;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Unbalanced or empty: leave both characters untouched.
|
||||
}
|
||||
}
|
||||
|
||||
// Escaped dollars inside inline math (owner, 2026-10-12): normalize
|
||||
// BEFORE tokenization — inside a balanced inline span, `\$` becomes the
|
||||
// dollar-free `\text{\textdollar}` so the scanner's closer is the one
|
||||
// the author meant (micromark would otherwise close at the `\$`, error
|
||||
// out, and cascade). Display runs are copied whole and left untouched —
|
||||
// a lone `$` can't close `$$`, so `\$` already survives there — and
|
||||
// copying the run stops its second `$` posing as an inline opener.
|
||||
// Spans without escaped dollars emit byte-identical text; prose `\$`,
|
||||
// code, and unbalanced spans are untouched.
|
||||
if (ch === "$" && !isEscaped(markdown, i)) {
|
||||
const run = dollarRun(markdown, i);
|
||||
if (run === 1) {
|
||||
const closeAt = findInlineDollarCloseEscapeAware(markdown, i + 1);
|
||||
if (closeAt !== -1) {
|
||||
const content = markdown.slice(i + 1, closeAt);
|
||||
const replaced = replaceEscapedDollars(content);
|
||||
if (replaced !== content) {
|
||||
out.push(`$${replaced}$`);
|
||||
i = closeAt + 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
out.push(markdown.slice(i, i + run));
|
||||
i += run;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
out.push(ch);
|
||||
i += 1;
|
||||
}
|
||||
|
||||
return out.join("");
|
||||
}
|
||||
|
||||
/**
|
||||
* LaTeX-escape restoration (owner ruling 2026-09-26) — the JSON-debris fix.
|
||||
*
|
||||
* When a model emits LaTeX with SINGLE backslashes inside JSON tool-call
|
||||
* arguments (`\forall`, not the properly escaped `\\forall`), JSON escape
|
||||
* resolution eats the command's first letter: `\f` → form feed + "orall",
|
||||
* `\v` → vertical tab + "arphi", `\t` → tab + "o". The debris survives into
|
||||
* the persisted RAW args and renders as garbage. Inside a MATH SPAN these
|
||||
* control chars can never be legitimate content, so restoring the two-char
|
||||
* escape reconstructs the model's intent byte-perfectly. Outside spans the
|
||||
* same chars might be real prose (pasted tables, formatted text), so only
|
||||
* balanced math spans are touched. The legacy `\(…\)` / `\[…\]` pass stays
|
||||
* unchanged; the dollar reader adds `$…$` and fenced `$$…$$` recognition,
|
||||
* excluding code and escaped opening dollars. Unbalanced spans pass through.
|
||||
*
|
||||
* ASYMMETRIC NEWLINE RULE (owner ruling 2026-09-27, Option A): inline spans
|
||||
* restore all six debris chars — real newlines/tabs inside INLINE math
|
||||
* essentially never occur (options and inline math are single-line by
|
||||
* nature). Display spans restore five and KEEP real newlines — multi-line
|
||||
* display math (aligned environments, equation systems) is common, and a
|
||||
* real line break is byte-identical on the wire to sloppy `\neq`/`\nu`/
|
||||
* `\neg`/`\nabla` (JSON `\n` resolves to a newline either way). Corrupting
|
||||
* legitimate content is strictly worse than failing to repair that rarest
|
||||
* slice, so display-span n-command sloppiness stays broken by design.
|
||||
*/
|
||||
const ESCAPE_DEBRIS: Record<string, string> = {
|
||||
"\b": "\\b",
|
||||
"\t": "\\t",
|
||||
"\n": "\\n",
|
||||
"\v": "\\v",
|
||||
"\f": "\\f",
|
||||
"\r": "\\r",
|
||||
const escaped = (text: string, i: number) => {
|
||||
let n = 0; while (i > 0 && text[--i] === "\\") n++;
|
||||
return n % 2 === 1;
|
||||
};
|
||||
|
||||
const DEBRIS_RE = /[\b\t\n\v\f\r]/;
|
||||
|
||||
/** Display spans keep real newlines (multi-line equations) — debris there is
|
||||
* everything except \n (a lone real \r never occurs in authored text, and
|
||||
* `\right`/`\rho`/`\rangle` corruption is common in display math). */
|
||||
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));
|
||||
/** Preserve the legacy list-continuation policy: indented display math
|
||||
* degrades to inline rather than disrupting list structure. Literal code
|
||||
* indentation is already protected by the shared reader. */
|
||||
function indented(text: string, i: number): boolean {
|
||||
const prefix = text.slice(text.lastIndexOf("\n", i - 1) + 1, i);
|
||||
return prefix.length > 0 && /^[ \t]+$/.test(prefix);
|
||||
}
|
||||
function blockPosition(text: string, i: number): boolean {
|
||||
return /^[ \t]*$/.test(text.slice(text.lastIndexOf("\n", i - 1) + 1, i));
|
||||
}
|
||||
|
||||
/** micromark closes native single-dollar spans at escaped dollars too.
|
||||
* Replace an intended literal dollar with equivalent dollar-free TeX before
|
||||
* tokenization. Code, prose, unbalanced spans and display dollars stay raw. */
|
||||
function replaceEscapedDollars(content: string): string {
|
||||
let out = "";
|
||||
for (let i = 0; i < content.length; i++) {
|
||||
if (content[i] === "\\" && content[i + 1] === "$" && !escaped(content, i)) {
|
||||
out += "\\text{\\textdollar}"; i++;
|
||||
} else out += content[i];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Read old \(...\)/\[...\] syntax without touching literal content or
|
||||
* reparsing native math as Markdown. Dollar authoring remains native. */
|
||||
export function transformMathDelimiters(markdown: string): string {
|
||||
let offset = 0;
|
||||
return readContentSpans(markdown).map(span => {
|
||||
const start = offset; offset += span.raw.length;
|
||||
if (span.kind !== "math") return span.raw;
|
||||
if (span.open === "$") return "$" + replaceEscapedDollars(span.body) + span.close;
|
||||
if (span.open !== "\\(" && span.open !== "\\[") return span.raw;
|
||||
const body = span.body.trim();
|
||||
if (!body) return span.raw;
|
||||
if (span.open === "\\(") return `$$${body}$$`;
|
||||
if (indented(markdown, start)) return `$$${body.replace(/\s*\n\s*/g, " ")}$$`;
|
||||
const before = blockPosition(markdown, start) ? "" : "\n\n";
|
||||
const end = start + span.raw.length;
|
||||
const after = end === markdown.length || markdown[end] === "\n" ? "" : "\n\n";
|
||||
return `${before}$$\n${body}\n$$${after}`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
const DEBRIS: Record<string, string> = {
|
||||
"\b": "\\b", "\t": "\\t", "\v": "\\v", "\f": "\\f", "\r": "\\r",
|
||||
};
|
||||
|
||||
/** Recover JSON-swallowed command prefixes only in recognized math, never
|
||||
* in code or genuine TeX comments. Restore all debris in one pass. Physical
|
||||
* display newlines and CRLF remain source whitespace. Inline LF recovery is
|
||||
* limited to complete known n-command names. Under the approved typical-
|
||||
* model-authoring policy this includes newline+u→nu in inline math only;
|
||||
* already-correct commands are never decoded into whitespace. */
|
||||
export function restoreMathEscapes(text: string): string {
|
||||
if (!DEBRIS_RE.test(text)) return text;
|
||||
return restoreDollarMathEscapes(restoreLegacyMathEscapes(text));
|
||||
if (!/[\b\t\n\v\f\r]/.test(text)) return text;
|
||||
return mapMathContent(text, (body, display) => outsideTexComments(body, part => {
|
||||
const restored = part.replace(/[\b\t\v\f\r]/g, (char, offset: number) => {
|
||||
if (char === "\r" && part[offset + 1] === "\n") return char;
|
||||
const suffix = /^[A-Za-z]+/.exec(part.slice(offset + 1))?.[0];
|
||||
return suffix && knownTexCommand(DEBRIS[char]!.slice(1) + suffix) ? DEBRIS[char]! : char;
|
||||
});
|
||||
if (display) return restored;
|
||||
return restored.replace(/\n([A-Za-z]+)/g, (whole, suffix: string) =>
|
||||
knownTexCommand("n" + suffix) ? "\\n" + suffix : whole);
|
||||
}));
|
||||
}
|
||||
|
||||
/** Keep legacy recovery byte-for-byte; only the recognized syntax expands. */
|
||||
function restoreLegacyMathEscapes(text: string): string {
|
||||
if (!DEBRIS_RE.test(text)) return text;
|
||||
const out: string[] = [];
|
||||
let i = 0;
|
||||
while (i < text.length) {
|
||||
const ch = text[i]!;
|
||||
if (ch === "\\" && !isEscaped(text, i)) {
|
||||
const next = text[i + 1];
|
||||
if (next === "(" || next === "[") {
|
||||
const isDisplay = next === "[";
|
||||
const open = isDisplay ? DISPLAY_OPEN : INLINE_OPEN;
|
||||
const close = isDisplay ? DISPLAY_CLOSE : INLINE_CLOSE;
|
||||
const closeAt = findClose(text, i + open.length, close);
|
||||
if (closeAt !== -1) {
|
||||
const inner = text.slice(i + open.length, closeAt);
|
||||
const debris = isDisplay ? DISPLAY_DEBRIS_RE : DEBRIS_RE;
|
||||
out.push(open + inner.replace(debris, (c) => ESCAPE_DEBRIS[c]!) + close);
|
||||
i = closeAt + close.length;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
out.push(ch);
|
||||
i += 1;
|
||||
}
|
||||
return out.join("");
|
||||
}
|
||||
const commandSymbol = /[,;:!|{}%]/;
|
||||
|
||||
function dollarRun(text: string, i: number): number {
|
||||
let count = 0;
|
||||
while (text[i + count] === "$") count += 1;
|
||||
return count;
|
||||
}
|
||||
|
||||
/** Native math fences start at a line's block position; one-line $$…$$
|
||||
* remains inline. A flow opener may have metadata, but never another $. */
|
||||
function dollarIsDisplay(text: string, i: number, size: number): boolean {
|
||||
if (size < 2 || !displayIsBlockPositioned(text, i)) return false;
|
||||
const end = text.indexOf("\n", i + size);
|
||||
return !text.slice(i + size, end === -1 ? text.length : end).includes("$");
|
||||
}
|
||||
|
||||
/** Match micromark's delimiter runs: exact size inline, at least the
|
||||
* opener's size on an otherwise-empty line for display. Inside math,
|
||||
* backslashes are TeX data, not Markdown escapes for closing dollars. */
|
||||
function findDollarClose(
|
||||
text: string,
|
||||
from: number,
|
||||
size: number,
|
||||
isDisplay: boolean,
|
||||
): { at: number; after: number } | undefined {
|
||||
if (isDisplay) {
|
||||
let lineEnd = text.indexOf("\n", from);
|
||||
while (lineEnd !== -1) {
|
||||
const start = lineEnd + 1;
|
||||
lineEnd = text.indexOf("\n", start);
|
||||
const line = text.slice(start, lineEnd === -1 ? text.length : lineEnd);
|
||||
const close = /^([ \t]{0,3})(\$+)[ \t]*\r?$/.exec(line);
|
||||
if (close && close[2]!.length >= size) {
|
||||
const at = start + close[1]!.length;
|
||||
return { at, after: at + close[2]!.length };
|
||||
}
|
||||
/** Decode prefixes and row separators according to ORIGINAL TeX context.
|
||||
* A native pair is literal; an encoded pair supplies a factor, so an encoded
|
||||
* four-backslash row becomes TWO, never a command prefix. Unknown commands
|
||||
* without that structural evidence are left alone. */
|
||||
function recoverCommandRuns(inner: string, display: boolean): string {
|
||||
const ranges = texRanges(inner);
|
||||
const recovered = inner.replace(/\\+/g, (run: string, offset: number) => {
|
||||
const contexts = ranges.filter(r => offset >= r.start && offset < r.end);
|
||||
if (contexts.some(r => r.kind === "row" || r.kind === "comment") || run.length % 2 !== 0) return run;
|
||||
const next = inner[offset + run.length] ?? "";
|
||||
const encoded = contexts.filter(r => r.kind === "encoded").at(-1);
|
||||
const word = /^[A-Za-z]+/.exec(inner.slice(offset + run.length))?.[0];
|
||||
if (encoded) {
|
||||
const factor = encoded.factor!;
|
||||
const rows = run.length % (2 * factor) === 0;
|
||||
const command = word ? knownTexCommand(word) : commandSymbol.test(next);
|
||||
if (run.length % factor === 0 && (rows || command)) return "\\".repeat(run.length / factor);
|
||||
return run;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
for (let j = from; j < text.length;) {
|
||||
if (text[j] !== "$") {
|
||||
j += 1;
|
||||
continue;
|
||||
}
|
||||
const count = dollarRun(text, j);
|
||||
if (count === size) return { at: j, after: j + count };
|
||||
j += count;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Dollar recognition only — the same six/five-character repair maps,
|
||||
* with no over-escaping normalization or mathematical validation. */
|
||||
function restoreDollarMathEscapes(text: string): string {
|
||||
if (!DEBRIS_RE.test(text) || !text.includes("$")) return text;
|
||||
const out: string[] = [];
|
||||
let i = 0;
|
||||
while (i < text.length) {
|
||||
if (atLineStart(text, i) && FENCE_OPEN_RE.test(text.slice(i))) {
|
||||
i = copyFencedBlock(text, i, out);
|
||||
continue;
|
||||
}
|
||||
if (text[i] === "`") {
|
||||
const run = backtickRun(text, i);
|
||||
let j = i + run;
|
||||
while (j < text.length) {
|
||||
const nextRun = backtickRun(text, j);
|
||||
if (nextRun === run) break;
|
||||
j += Math.max(nextRun, 1);
|
||||
}
|
||||
const after = j < text.length ? j + run : i + run;
|
||||
out.push(text.slice(i, after));
|
||||
i = after;
|
||||
continue;
|
||||
}
|
||||
// Legacy spans have already received their own inline/display policy.
|
||||
// Do not reinterpret dollars inside them in this second pass.
|
||||
if (text[i] === "\\" && !isEscaped(text, i)) {
|
||||
const next = text[i + 1];
|
||||
if (next === "(" || next === "[") {
|
||||
const closeAt = findClose(text, i + 2, next === "[" ? DISPLAY_CLOSE : INLINE_CLOSE);
|
||||
if (closeAt !== -1) {
|
||||
out.push(text.slice(i, closeAt + 2));
|
||||
i = closeAt + 2;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (text[i] === "$" && !isEscaped(text, i)) {
|
||||
const size = dollarRun(text, i);
|
||||
const isDisplay = dollarIsDisplay(text, i, size);
|
||||
const close = findDollarClose(text, i + size, size, isDisplay);
|
||||
if (close) {
|
||||
const debris = isDisplay ? DISPLAY_DEBRIS_RE : DEBRIS_RE;
|
||||
out.push(
|
||||
text.slice(i, i + size) +
|
||||
text.slice(i + size, close.at).replace(debris, (c) => ESCAPE_DEBRIS[c]!) +
|
||||
text.slice(close.at, close.after),
|
||||
);
|
||||
i = close.after;
|
||||
continue;
|
||||
}
|
||||
out.push(text.slice(i, i + size));
|
||||
i += size;
|
||||
continue;
|
||||
}
|
||||
out.push(text[i]!);
|
||||
i += 1;
|
||||
}
|
||||
return out.join("");
|
||||
}
|
||||
|
||||
/**
|
||||
* Over-escaped command heal (owner ruling 2026-10-15) — the doubled-
|
||||
* backslash fix.
|
||||
*
|
||||
* Some models stochastically emit DOUBLED backslashes inside JSON tool-call
|
||||
* arguments: the saved record holds `\\nabla` (two real backslashes) where
|
||||
* `\nabla` was meant. KaTeX reads the `\\` as a row break and then renders
|
||||
* the command name as plain letters — the learner sees "the word nabla, not
|
||||
* the symbol". Field evidence (2026-10-15, real data): xiaomi/mimo-v2.6-pro
|
||||
* doubled per-call, per-field (one probe's question doubled while its
|
||||
* explanation and options in the SAME call stayed clean — authoring, not
|
||||
* transport), and deepseek-v4.1-flash once; quadrupling appeared when the
|
||||
* in-session agent "compensated" for its own doubled first attempt.
|
||||
*
|
||||
* THE RULE. Inside a balanced math span ONLY (legacy `\(…\)`/`\[…\]` and
|
||||
* dollar `$…$`/`$$…$$`, code spans and fences excluded), an EVEN-length
|
||||
* backslash run (2, 4, …) immediately followed by a command start — a letter
|
||||
* (any command name; bare letters after a row break are never authored
|
||||
* content) or a punctuation command (`,` `;` `:` `!` `|` — thin spaces,
|
||||
* norm bars) — collapses to a single backslash. Even runs before anything
|
||||
* else (space, newline, `&`, `[`, `$`, digits) are legitimate row breaks and
|
||||
* stay; ODD runs (3, 5, …) are row break(s) plus a real command with its own
|
||||
* backslash — e.g. matrix cells written `a \\\nabla` — and stay untouched,
|
||||
* so matrix content cannot be corrupted. Outside spans nothing is touched:
|
||||
* doubled backslashes in prose or code are not ours to judge. Unbalanced
|
||||
* spans pass through, exactly like the debris passes.
|
||||
*/
|
||||
const COMMAND_START_CLASS = "[A-Za-z,;:!|]";
|
||||
|
||||
/** Collapse even backslash runs before a command start; keep odd runs. */
|
||||
function halveOverescapedCommandRuns(inner: string): string {
|
||||
return inner.replace(
|
||||
new RegExp("(\\\\+)(?=" + COMMAND_START_CLASS + ")", "g"),
|
||||
(run: string) => (run.length % 2 === 0 ? "\\" : run),
|
||||
);
|
||||
if (next === "%") return numericPercentEscape(inner, offset + run.length) ? "\\" : run;
|
||||
if (word) return word.length > 1 && knownTexCommand(word) ? "\\" : run;
|
||||
return next && commandSymbol.test(next) ? "\\" : run;
|
||||
});
|
||||
// Structural evidence is primary (parse success cannot prove row
|
||||
// preservation). As an additional guard, never turn parseable source into
|
||||
// a parse failure through partial command recovery.
|
||||
if (recovered !== inner && !canParseTex(recovered, display) && canParseTex(inner, display)) return inner;
|
||||
return recovered;
|
||||
}
|
||||
|
||||
export function healOverescapedCommands(text: string): string {
|
||||
if (!text.includes("\\\\")) return text;
|
||||
const out: string[] = [];
|
||||
let i = 0;
|
||||
while (i < text.length) {
|
||||
if (atLineStart(text, i) && FENCE_OPEN_RE.test(text.slice(i))) {
|
||||
i = copyFencedBlock(text, i, out);
|
||||
continue;
|
||||
}
|
||||
if (text[i] === "`") {
|
||||
const run = backtickRun(text, i);
|
||||
let j = i + run;
|
||||
while (j < text.length) {
|
||||
const nextRun = backtickRun(text, j);
|
||||
if (nextRun === run) break;
|
||||
j += Math.max(nextRun, 1);
|
||||
}
|
||||
const after = j < text.length ? j + run : i + run;
|
||||
out.push(text.slice(i, after));
|
||||
i = after;
|
||||
continue;
|
||||
}
|
||||
if (text[i] === "\\" && !isEscaped(text, i)) {
|
||||
const next = text[i + 1];
|
||||
if (next === "(" || next === "[") {
|
||||
const isDisplay = next === "[";
|
||||
const open = isDisplay ? DISPLAY_OPEN : INLINE_OPEN;
|
||||
const close = isDisplay ? DISPLAY_CLOSE : INLINE_CLOSE;
|
||||
const closeAt = findClose(text, i + open.length, close);
|
||||
if (closeAt !== -1) {
|
||||
const inner = text.slice(i + open.length, closeAt);
|
||||
out.push(open + halveOverescapedCommandRuns(inner) + close);
|
||||
i = closeAt + close.length;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (text[i] === "$" && !isEscaped(text, i)) {
|
||||
const size = dollarRun(text, i);
|
||||
const isDisplay = dollarIsDisplay(text, i, size);
|
||||
const close = findDollarClose(text, i + size, size, isDisplay);
|
||||
if (close) {
|
||||
out.push(
|
||||
text.slice(i, i + size) +
|
||||
halveOverescapedCommandRuns(text.slice(i + size, close.at)) +
|
||||
text.slice(close.at, close.after),
|
||||
);
|
||||
i = close.after;
|
||||
continue;
|
||||
}
|
||||
out.push(text.slice(i, i + size));
|
||||
i += size;
|
||||
continue;
|
||||
}
|
||||
out.push(text[i]!);
|
||||
i += 1;
|
||||
}
|
||||
return out.join("");
|
||||
return text.includes("\\\\") ? mapMathContent(text, recoverCommandRuns) : text;
|
||||
}
|
||||
|
||||
/** Presentation-only, language-independent recovery. Shape parsing, saved
|
||||
* identities, grading and model history must use the unprojected source. */
|
||||
export function repairMathForDisplay(text: string): string {
|
||||
return healOverescapedCommands(restoreMathEscapes(recoverDocumentLayout(text)));
|
||||
}
|
||||
|
||||
+6
-26
@@ -30,7 +30,6 @@
|
||||
import { Value } from "typebox/value";
|
||||
import type { TSchema } from "typebox";
|
||||
import { quizDisplayInputSchema, askInputSchema, askOptionProblems, quizInputProblems, explainInputProblems, explainDisplayInputSchema, type AskToolInput, type QuizToolInput, type ExplainToolInput } from "./question-core.ts";
|
||||
import { repairMathForDisplay } from "./math-delims.ts";
|
||||
import { healEncodedArguments } from "./argument-heal.ts";
|
||||
import { probeDoneInputSchema, type ProbeDoneInput } from "./contracts/probe.ts";
|
||||
|
||||
@@ -67,28 +66,9 @@ function boundaryParse<T>(schema: TSchema, input: unknown): T | undefined {
|
||||
* never have been posed (e.g. `correctAnswer: undefined`, which Convert
|
||||
* widens to "") renders as a quiet fallback, not a broken quiz.
|
||||
*/
|
||||
/** LaTeX-escape restoration, deep over every string field (the JSON-debris
|
||||
* fix, owner ruling 2026-09-26): records persist RAW call args, so
|
||||
* single-backslash LaTeX eaten by JSON escape resolution (FF ate the 'f' of
|
||||
* \forall, VT the 'v' of \varphi, TAB the 't' of \to) survives to the
|
||||
* display path. This is the ONE seam every rendered question passes
|
||||
* through — new turns and already-poisoned records alike heal here.
|
||||
* healOverescapedCommands (owner ruling 2026-10-15) rides the same walk:
|
||||
* the same models also stochastically DOUBLE command backslashes
|
||||
* (`\\nabla` saved where `\nabla` was meant — KaTeX shows the row break
|
||||
* plus "nabla" as letters), so even runs before a command start inside
|
||||
* balanced math spans collapse to one backslash. */
|
||||
function restoreMathEscapesDeep(value: unknown): unknown {
|
||||
if (typeof value === "string") return repairMathForDisplay(value);
|
||||
if (Array.isArray(value)) return value.map(restoreMathEscapesDeep);
|
||||
if (typeof value === "object" && value !== null) {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(value)) out[k] = restoreMathEscapesDeep(v);
|
||||
return out;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Shape parsing never repairs authored text. The dock builds its answer
|
||||
* data from this source copy; recovery belongs exclusively to presentation
|
||||
* (ToolContentResponse), never option identities, grading, or snapshots. */
|
||||
export function parseQuizInput(input: unknown): QuizToolInput | undefined {
|
||||
// quizDisplayInputSchema (question-core, owner 2026-10-03): base + the
|
||||
// OPTIONAL `type` — new teach-phase records carry predict|check and the
|
||||
@@ -100,7 +80,7 @@ export function parseQuizInput(input: unknown): QuizToolInput | undefined {
|
||||
// wobble must heal HERE too or a posed quiz renders invisible (the
|
||||
// tool-parts law). healEncodedArguments is the same pure pass the
|
||||
// boundary's withArgumentHeal runs.
|
||||
const healedInput = healEncodedArguments(restoreMathEscapesDeep(input), quizDisplayInputSchema);
|
||||
const healedInput = healEncodedArguments(input, quizDisplayInputSchema);
|
||||
const cleaned = boundaryParse<QuizToolInput>(quizDisplayInputSchema, healedInput);
|
||||
if (!cleaned) return undefined;
|
||||
if (quizInputProblems(cleaned).length > 0) return undefined;
|
||||
@@ -114,7 +94,7 @@ export function parseQuizInput(input: unknown): QuizToolInput | undefined {
|
||||
* keying, so they parse to undefined exactly like the zod seam did).
|
||||
*/
|
||||
export function parseAskInput(input: unknown): AskToolInput | undefined {
|
||||
const cleaned = boundaryParse<AskToolInput>(askInputSchema, restoreMathEscapesDeep(input));
|
||||
const cleaned = boundaryParse<AskToolInput>(askInputSchema, input);
|
||||
if (!cleaned) return undefined;
|
||||
if (askOptionProblems(cleaned.options ?? []).length > 0) return undefined;
|
||||
return cleaned;
|
||||
@@ -127,7 +107,7 @@ export function parseAskInput(input: unknown): AskToolInput | undefined {
|
||||
* variants share the same fields; the teacher's nodeId is an unknown
|
||||
* key Value.Clean strips, same as the quiz seam. */
|
||||
export function parseExplainInput(input: unknown): ExplainToolInput | undefined {
|
||||
const cleaned = boundaryParse<ExplainToolInput>(explainDisplayInputSchema, restoreMathEscapesDeep(input));
|
||||
const cleaned = boundaryParse<ExplainToolInput>(explainDisplayInputSchema, input);
|
||||
if (!cleaned) return undefined;
|
||||
if (explainInputProblems(cleaned).length > 0) return undefined;
|
||||
return cleaned;
|
||||
|
||||
Reference in New Issue
Block a user