Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions .github/workflows/twin-files-consistency.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
name: Twin Files Consistency

# 孪生文件(逐字节副本)一致性校验 —— 横切关注点,正交于各 app CI。
#
# 某些模块因构建边界无法提为共享包,只能以逐字节副本形式在多处持有。
# 副本的固有风险是单边漂移:改了一处忘了另一处,缺陷从此静默分叉。
# 本 job 校验登记表内每组副本精确字节相等,漂移时打印统一 diff 指明同步方向。
#
# 登记表与判据详见 scripts/check_twin_files.py。pre-commit 侧有同名钩子做本地前置拦截;
# CI 侧是兜底 —— 未装 hooks 或 --no-verify 绕过的提交仍会在此被拦下。
#
# fail-only:检测到漂移即阻塞合并,不自动同步(哪份权威取决于改动意图,机器不该代猜)。

on:
pull_request:
paths:
- "scripts/check_twin_files.py"
- "apps/negentropy-wiki/src/components/markdown/remark-math-sanitize.ts"
- "apps/negentropy-ui/utils/remark-math-sanitize.ts"
- ".github/workflows/twin-files-consistency.yml"
push:
branches: [master, main]
paths:
- "scripts/check_twin_files.py"
- "apps/negentropy-wiki/src/components/markdown/remark-math-sanitize.ts"
- "apps/negentropy-ui/utils/remark-math-sanitize.ts"

permissions:
contents: read

jobs:
twin-files-consistency:
name: Twin Files Check
runs-on: ubuntu-latest
timeout-minutes: 3
steps:
- uses: actions/checkout@v4

- name: Set up uv
uses: astral-sh/setup-uv@v6

- name: Check twin files are byte-identical
run: uv run --no-project scripts/check_twin_files.py
18 changes: 18 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,24 @@ repos:
files: ^(VERSION|scripts/sync_versions\.py|package\.json|apps/negentropy/(pyproject\.toml|uv\.lock)|apps/negentropy-ui/package\.json|apps/negentropy-wiki/package\.json|apps/negentropy-perceives/(pyproject\.toml|uv\.lock)|packages/agents-chat-core/package\.json)$
pass_filenames: false

# ── 孪生文件(逐字节副本)一致性执法 ─────────────────────────────────────
# 某些模块因构建边界无法提为共享包,只能以逐字节副本形式多处持有(登记表见脚本)。
# 本钩子把「改一处必须同步另一处」从人工纪律升格为机器保证,漂移时打印统一 diff。
# ⚠️ files: 覆盖登记表内全部路径 + 脚本自身;新增副本组时须同步扩充此正则,
# 否则钩子对新组**静默失效**(pre-commit 跳过不报错)。改动后须验证:
# 暂存一处副本改动,确认钩子 Passed 而非 Skipped。
# 注:pre-commit 会 stash 未暂存改动、仅对暂存内容执行,故同组副本**必须整组
# 一同 git add**;只暂存一侧会因另一侧仍是 HEAD 版本而判定漂移 —— 这正是
# 期望语义:它连「部分同步的提交」也一并拦住。
- repo: local
hooks:
- id: twin-files-consistency
name: Twin files consistency
language: system
entry: uv run --no-project scripts/check_twin_files.py
files: ^(scripts/check_twin_files\.py|apps/negentropy-wiki/src/components/markdown/remark-math-sanitize\.ts|apps/negentropy-ui/utils/remark-math-sanitize\.ts)$
pass_filenames: false

# ── 科普视频系列一致性(series.json 执法) ───────────────────────────────
# 规则:口播反串线(自身标题排除)/多标题顺序/序号绑定/清单完整性/相对链接死链。
# 注意:挂在内容修复之后落地;顺序调整类变更须先改 series.json 再动散文。
Expand Down
2 changes: 2 additions & 0 deletions apps/negentropy-ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@
"sonner": "^2.0.7",
"tailwind-merge": "^2.6.1",
"three-spritetext": "^1.10.0",
"unified": "^11.0.5",
"unist-util-visit": "^5.0.0",
"zod": "3.24.4"
},
"devDependencies": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,4 +108,30 @@ describe("DocumentMarkdownRenderer", () => {
expect(figcaption).not.toBeNull();
expect(figcaption?.textContent).toContain("Figure 1");
});

it("PDF 语料的病态公式经 remarkMathSanitize 净化后零 KaTeX 告警", () => {
// 与 wiki 端 MarkdownRenderer 共用同一份提取语料,插件链须对齐(判据见
// utils/remark-math-sanitize.ts 的孪生副本说明)。
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
try {
const content = [
"按输入 $3/ 百万 token 、输出 $15/ 百万 token 的示例价格计算",
"$11.5%\\to15.0%$",
"KL $(P‖Q) \\neq$ KL $(Q‖P)$",
"$$\n d_{L2}^2 = \\underbrace{\\|a\\|^2 + \\|b\\|^2}_{常数 2}\n$$",
].join("\n\n");
const { container } = render(
<DocumentMarkdownRenderer
content={content}
corpusId="corpus-1"
documentId="document-1"
/>,
);

expect(container.querySelector(".katex-error")).toBeNull();
expect(warn).not.toHaveBeenCalled();
} finally {
warn.mockRestore();
}
});
});
92 changes: 92 additions & 0 deletions apps/negentropy-ui/tests/unit/utils/markdown-plugins.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { render } from "@testing-library/react";
import ReactMarkdown from "react-markdown";
import { defaultRemarkPlugins, defaultRehypePlugins } from "@/utils/markdown-plugins";

/**
* remarkMathSanitize 接入 defaultRemarkPlugins 的回归护栏。
*
* 该插件与 `apps/negentropy-wiki/src/components/markdown/remark-math-sanitize.ts`
* 是孪生副本,本文件用例与 wiki 端 MarkdownRenderer.test.tsx 的对应用例同步维护。
*/
function renderMd(md: string) {
return render(
<div data-testid="md">
<ReactMarkdown remarkPlugins={defaultRemarkPlugins} rehypePlugins={defaultRehypePlugins}>
{md}
</ReactMarkdown>
</div>,
);
}

describe("defaultRemarkPlugins · remarkMathSanitize", () => {
it("相邻货币 $ 误配对降级回正文,文本不被打乱重复", () => {
// 未净化时 remark-math 把两个 $ 配成 inlineMath,实测正文被打乱、
// `$15` 段渲染成 `$3` 段并触发多次 KaTeX 告警。LLM 回复中报价文本极常见。
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
try {
const { container } = renderMd("输入 $3/ 百万 token ,输出 $15/ 百万 token 。");

expect(container.querySelector(".katex")).toBeNull();
const text = container.textContent ?? "";
expect(text).toContain("$3/ 百万 token");
expect(text).toContain("$15/ 百万 token");
expect(warn).not.toHaveBeenCalled();
} finally {
warn.mockRestore();
}
});

it("正常行内公式不受影响", () => {
const { container } = renderMd("质能方程 $E=mc^2$ 广为人知。");

expect(container.querySelector(".katex")).not.toBeNull();
});

it("未转义的 % 被转义,其后内容不被当注释吞掉", () => {
const { container } = renderMd("$11.5%\\to15.0%$");

const katex = container.querySelector(".katex");
expect(katex).not.toBeNull();
expect(katex?.textContent).toContain("15.0");
expect(katex?.textContent).toContain("%");
});

it("\\text{} 内的裸 % 同样被转义(catcode 14 不分模式)", () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
try {
const { container } = renderMd("$\\text{增长 50%}$");

expect(container.querySelector(".katex")).not.toBeNull();
expect(container.querySelector(".katex-error")).toBeNull();
// KaTeX 文本模式把空格输出为 NBSP(U+00A0),归一后再比对。
expect(container.querySelector(".katex")?.textContent?.replace(/\u00A0/g, " "))
.toContain("增长 50%");
expect(warn).not.toHaveBeenCalled();
} finally {
warn.mockRestore();
}
});

it("‖ 归一为 \\Vert,公式正常渲染", () => {
const { container } = renderMd("KL 散度不对称:KL $(P‖Q) \\neq$ KL $(Q‖P)$");

expect(container.querySelectorAll(".katex").length).toBe(2);
});

it("\\textrm 等 KaTeX 文本宏内的 CJK 不触发误降级", () => {
const { container } = renderMd(
"梯度 $g = \\textrm{方向导数}$ 与 $h = \\textnormal{常规}$ 及 $k = \\textsf{无衬线}$",
);

expect(container.querySelectorAll(".katex").length).toBe(3);
});

it("下标裸 CJK 包进 \\text{},非 CJK(谚文)不被误包", () => {
const { container } = renderMd("$$\nd_{常数 2} + e_{한글}\n$$");

const ann = container.querySelector("annotation")?.textContent ?? "";
expect(ann).toContain("_{\\text{常数 2}}");
expect(ann).toContain("e_{한글}");
expect(ann).not.toContain("\\text{한글}");
});
});
13 changes: 11 additions & 2 deletions apps/negentropy-ui/utils/markdown-plugins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,18 @@
import remarkGfm from "remark-gfm";
import remarkMath from "remark-math";
import rehypeKatex from "rehype-katex";
import { remarkMathSanitize } from "./remark-math-sanitize";

/** remark 插件链:GFM 扩展 + 数学公式语法解析 */
export const defaultRemarkPlugins = [remarkGfm, remarkMath];
/**
* remark 插件链:GFM 扩展 + 数学公式语法解析 + 病态公式净化。
*
* remarkMathSanitize 须紧随 remarkMath 之后。它修复 remark-math 的通用缺陷,
* 不限 PDF 语料:形如「输入 $3/ 百万 token ,输出 $15/ 百万 token」的相邻货币符
* 会被配成 inlineMath,实测正文被打乱重复(`$15` 段渲染成 `$3` 段)并触发 5 次
* KaTeX 告警——LLM 回复中报价文本极常见,故挂在全局链而非仅文档渲染处。
* 与 wiki 端 MarkdownRenderer 的插件链保持一致。
*/
export const defaultRemarkPlugins = [remarkGfm, remarkMath, remarkMathSanitize];

/** rehype 插件链:KaTeX 渲染 */
export const defaultRehypePlugins = [rehypeKatex];
166 changes: 166 additions & 0 deletions apps/negentropy-ui/utils/remark-math-sanitize.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import type { Plugin } from "unified";
import { visit } from "unist-util-visit";

/**
* remarkMathSanitize —— 净化 PDF 提取语料中 remark-math 产生的病态公式节点,
* 消除 rehype-katex 构建期告警与实际渲染缺陷。须挂在 `remarkMath` 之后。
*
* ⚠️ 本文件是**逐字节孪生副本**,同时存在于:
* - `apps/negentropy-wiki/src/components/markdown/remark-math-sanitize.ts`
* - `apps/negentropy-ui/utils/remark-math-sanitize.ts`
* 两端渲染同一份 PDF 提取语料(wiki 静态站 / UI 知识库文档详情页),判据一旦单边漂移
* 即产生渲染不对称。未提为共享包是因 wiki 无 workspace 依赖且以 GitHub Pages 为出口,
* 为单个插件引入构建链的爆炸半径过大(参照 rehype-notranslate 亦为单端持有)。
* **改动任一份时必须同步另一份,并同步两端回归用例。**
* 一致性由 `scripts/check_twin_files.py` 在 pre-commit 与 CI 双侧执法(精确字节相等)。
*
* 三类病灶与处置:
* 1. 货币 `$` 误配对:正文「按输入 $3/ 百万 token 、输出 $15/ 百万 token」的相邻
* 货币符被 remark-math 配成 inlineMath,中间中文全进 math mode(KaTeX 逐字告警
* unicodeTextInMathMode)。处置:值内含 `\text{...}` 之外的 CJK 字符 → 判定
* 误配对,降级回正文 text 节点(补回字面 `$`)。仅作用于 inlineMath——真实
* display 公式(`$$` 围栏)无货币配对形态,且误降级代价高(丢公式渲染)。
* 2. `%` 未转义:`$11.5%\to15.0%$` 中 `%` 是 TeX 注释符,渲染吞掉其后内容
* (commentAtEnd 告警即其末态)。处置:未转义的 `%` → `\%`,作用于整串——
* `%` 是 KaTeX 词法层 catcode 14,文本模式内不豁免(见 escapePercent)。
* 3. Unicode 符号无度量:`‖`/`∥`/`•` KaTeX 无字形度量(unknownSymbol + No
* character metrics,渲染为 tofu)。处置:映射为 LaTeX 等价命令
* `\Vert`/`\parallel`/`\bullet`(Pandoc texmath 同款归一化思路)。
*
* 判据均先剥离 `\text{...}` 片段再检验——`\text{中文}` 是合法用法(正文注释进公式),
* 不得触发 CJK 误判,`\text` 内的 Unicode 符号也保持原样(走浏览器字体)。
* 例外是 `%`:它在词法层被吞,`\text{}` 内外一律转义。
*/

/** 最小 mdast 节点结构(仅用到 type / value / data.hChildren);避免直接依赖 `mdast` 类型包。 */
interface MathNode {
type: "inlineMath" | "math";
value: string;
data?: {
hChildren?: HastLike[];
[key: string]: unknown;
};
}

function isMathNode(node: unknown): node is MathNode {
return (
typeof node === "object" &&
node !== null &&
((node as { type?: unknown }).type === "inlineMath" ||
(node as { type?: unknown }).type === "math") &&
typeof (node as { value?: unknown }).value === "string"
);
}

/** 最小 hast 内容节点(text 或 element,后者含 children)。 */
interface HastLike {
type?: string;
value?: unknown;
children?: HastLike[];
}

/**
* 同步写入公式值:`node.value` 与 `node.data.hChildren` 中的 hast text 副本须一并
* 更新——mdast-util-math 解析时把值存了两份,remark-rehype 转 hast 走 `data.hChildren`
* (rehype-katex 从 hast 取文本),只改 `node.value` 不会到达 KaTeX。副本层级不定
* (inlineMath 是 `hChildren[0].value`,display 是 `hChildren[0](code).children[0].value`),
* 递归替换其中的 text 节点。
*/
function setMathValue(node: MathNode, value: string): void {
node.value = value;
const sync = (n: HastLike): void => {
if (n.type === "text" && typeof n.value === "string") {
n.value = value;
return;
}
for (const child of n.children ?? []) sync(child);
};
for (const child of node.data?.hChildren ?? []) sync(child);
}

/**
* CJK 字符类源串:统一表意文字扩展 A 起至基本区(U+3400–U+9FFF)+ 兼容表意文字
* (U+F900–U+FAFF)。以转义码位书写——兼容区 U+F900 与普通汉字 U+8C48 字形相同码位
* 不同,字面量易混淆致区间误跨谚文段与代理项。下方两处判据共用此串,避免双处漂移。
*/
const CJK_CLASS = "[\\u3400-\\u9FFF\\uF900-\\uFAFF]";
const CJK_RE = new RegExp(CJK_CLASS);

/**
* KaTeX 文本模式宏全集(`src/functions/text.ts` 的 `names`)+ `\mathrm` / `\mbox`。
* 遗漏项会使其内的 CJK 逃过剥离,被货币误配对分支判为误配对而整条降级丢渲染。
*/
const TEXT_MACRO_SOURCE =
"\\\\(?:text|textrm|textsf|texttt|textnormal|textbf|textmd|textit|textup|emph|mathrm|mbox)\\s*\\{[^{}]*\\}";

/** 剥离 `\text{...}` / `\textbf{...}` 等文本宏片段,仅在剩余「数学体」部分做判据检验。 */
function stripTextMacros(src: string): string {
return src.replace(new RegExp(TEXT_MACRO_SOURCE, "g"), "");
}

/**
* 转义未转义的 `%`(TeX 注释符)。按前导反斜杠奇偶判定——`\\%`(换行符紧邻裸 `%`)
* 中的 `%` 实为未转义,单字符回看 `(?<!\\)` 会漏判。作用于整串而非仅数学体:
* KaTeX 的 `%` 是 Lexer 构造期设定的 catcode 14,与数学/文本模式无关,
* `\text{增长 50%}` 的 `%` 会注释掉闭合 `}` 直接抛 ParseError。
*/
function escapePercent(src: string): string {
return src.replace(/(\\*)%/g, (_m, slashes: string) =>
slashes.length % 2 === 0 ? `${slashes}\\%` : `${slashes}%`,
);
}

/** 数学体中 Unicode 符号 → LaTeX 等价命令(KaTeX 有度量,消除 tofu)。 */
function normalizeSymbols(src: string): string {
return src
.replace(/‖/g, "\\Vert ")
.replace(/∥/g, "\\parallel ")
.replace(/•/g, "\\bullet ")
// 下标裸 CJK(`\underbrace{…}_{常数 2}` 这类 PDF 提取的标注)包进 `\text{}`,
// 消除逐字 unicodeTextInMathMode 告警(KaTeX 对 \text 内 Unicode 走文本模式)。
.replace(
new RegExp(`(_\\{|\\^\\{)([^{}]*${CJK_CLASS}[^{}]*)\\}`, "g"),
"$1\\text{$2}}",
);
}

/** 可写父节点结构(demote 时按索引替换 child)。 */
interface ParentLike {
children: { type?: string; value?: string }[];
}

export const remarkMathSanitize: Plugin = () => (tree) => {
visit(tree, (node, index, parent) => {
if (!isMathNode(node)) return;
// visit 回调的 parent 经泛型推断为 never(与 rehype-notranslate 的 hast 类型坑同款),
// 经 unknown 最小收窄为可写结构。
const holder = parent as unknown as ParentLike | undefined;
if (index == null || !holder) return;

// 1. 货币误配对降级:数学体含 CJK → 还原为正文文本(含字面 `$` 定界符)。
if (node.type === "inlineMath" && CJK_RE.test(stripTextMacros(node.value))) {
holder.children[index] = { type: "text", value: `$${node.value}$` };
return;
}

// 2. `%` 转义作用于整串(catcode 14 不分模式);3. 符号归一仅限数学体
// (`\text{...}` 内的 Unicode 符号保持原样,走浏览器字体)。
const cleaned = escapePercent(replaceOutsideTextMacros(node.value, normalizeSymbols));
if (cleaned !== node.value) setMathValue(node, cleaned);
});
};

/** 仅对 `\text{...}` 片段之外的部分应用 transform,文本宏片段原样保留。 */
function replaceOutsideTextMacros(src: string, transform: (s: string) => string): string {
const TEXT_MACRO_RE = new RegExp(TEXT_MACRO_SOURCE, "g");
let out = "";
let last = 0;
for (const m of src.matchAll(TEXT_MACRO_RE)) {
const at = m.index ?? 0;
out += transform(src.slice(last, at)) + m[0];
last = at + m[0].length;
}
return out + transform(src.slice(last));
}

export default remarkMathSanitize;
Loading
Loading