LaTeX-escape restoration at the display seam (owner ruling 2026-09-26): JSON-escape debris (FF ate \forall's f, VT \varphi's v, TAB \to's t) restored inside balanced math spans — one seam (tool-parts parse) heals new turns and poisoned records; outside spans untouched
This commit is contained in:
@@ -214,3 +214,56 @@ export function transformMathDelimiters(markdown: string): string {
|
||||
|
||||
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 `\(…\)` / `\[…\]` spans are touched — the same scanner rules as
|
||||
* transformMathDelimiters (escaped delimiters are literal, unbalanced
|
||||
* delimiters pass through untouched).
|
||||
*/
|
||||
const ESCAPE_DEBRIS: Record<string, string> = {
|
||||
"\b": "\\b",
|
||||
"\t": "\\t",
|
||||
"\n": "\\n",
|
||||
"\v": "\\v",
|
||||
"\f": "\\f",
|
||||
"\r": "\\r",
|
||||
};
|
||||
|
||||
const DEBRIS_RE = /[\b\t\n\v\f\r]/;
|
||||
|
||||
export function restoreMathEscapes(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);
|
||||
out.push(open + inner.replace(DEBRIS_RE, (c) => ESCAPE_DEBRIS[c]!) + close);
|
||||
i = closeAt + close.length;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
out.push(ch);
|
||||
i += 1;
|
||||
}
|
||||
return out.join("");
|
||||
}
|
||||
|
||||
+20
-2
@@ -30,6 +30,7 @@
|
||||
import { Value } from "typebox/value";
|
||||
import type { TSchema } from "typebox";
|
||||
import { quizInputBase, askInputSchema, askOptionProblems, quizInputProblems, type AskToolInput, type QuizToolInput } from "./question-core.ts";
|
||||
import { restoreMathEscapes } from "./math-delims.ts";
|
||||
import { probeDoneInputSchema, type ProbeDoneInput } from "./contracts/probe.ts";
|
||||
|
||||
/** The pi-boundary parse step, mirrored here (live-smoke fix, 2026-09-26):
|
||||
@@ -65,8 +66,25 @@ 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. */
|
||||
function restoreMathEscapesDeep(value: unknown): unknown {
|
||||
if (typeof value === "string") return restoreMathEscapes(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;
|
||||
}
|
||||
|
||||
export function parseQuizInput(input: unknown): QuizToolInput | undefined {
|
||||
const cleaned = boundaryParse<QuizToolInput>(quizInputBase, input);
|
||||
const cleaned = boundaryParse<QuizToolInput>(quizInputBase, restoreMathEscapesDeep(input));
|
||||
if (!cleaned) return undefined;
|
||||
if (quizInputProblems(cleaned).length > 0) return undefined;
|
||||
return cleaned;
|
||||
@@ -79,7 +97,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, input);
|
||||
const cleaned = boundaryParse<AskToolInput>(askInputSchema, restoreMathEscapesDeep(input));
|
||||
if (!cleaned) return undefined;
|
||||
if (askOptionProblems(cleaned.options ?? []).length > 0) return undefined;
|
||||
return cleaned;
|
||||
|
||||
Reference in New Issue
Block a user