fix(markdown): repair nested code fences

This commit is contained in:
caoqianming 2026-08-03 10:11:25 +08:00
parent a8b84b1d6f
commit a00ebfe50e
7 changed files with 381 additions and 2 deletions

133
core/markdown_guard.py Normal file
View File

@ -0,0 +1,133 @@
"""Narrow repairs for malformed nested Markdown fence examples.
Models occasionally wrap a fenced example in an equally long ``markdown``
fence. CommonMark cannot nest those fences: the inner closing fence closes
the outer block and the intended outer close opens a new, unclosed block.
Only that unambiguous shape is repaired here; arbitrary unclosed fences are
reported but left untouched.
"""
from __future__ import annotations
import re
from dataclasses import dataclass
_FENCE_RE = re.compile(r"^( {0,3})(`{3,}|~{3,})([^\r\n]*)(\r?\n)?$")
@dataclass(frozen=True)
class MarkdownFenceResult:
text: str
repairs: int = 0
unclosed_fence: bool = False
def _fence(line: str) -> tuple[str, str, int, str] | None:
match = _FENCE_RE.match(line)
if not match:
return None
token = match.group(2)
return match.group(1), token[0], len(token), match.group(3).strip()
def _next_nonblank(lines: list[str], start: int) -> int | None:
for idx in range(start, len(lines)):
if lines[idx].strip():
return idx
return None
def _replace_fence(line: str, length: int) -> str:
match = _FENCE_RE.match(line)
if not match:
return line
return (
match.group(1)
+ match.group(2)[0] * length
+ match.group(3)
+ (match.group(4) or "")
)
def _has_unclosed_fence(lines: list[str]) -> bool:
opened: tuple[str, int] | None = None
for line in lines:
parsed = _fence(line)
if parsed is None:
continue
_indent, char, length, info = parsed
if opened is None:
opened = (char, length)
elif not info and char == opened[0] and length >= opened[1]:
opened = None
return opened is not None
def normalize_markdown_fences(text: str) -> MarkdownFenceResult:
"""Repair only an equally-long fenced block nested in markdown/md.
Recognised shape (blank lines between the two closing fences are allowed):
``markdown opener -> language opener -> language close -> outer close``.
The outer pair is lengthened by one character. Other malformed input is
preserved so the platform never guesses broadly at author intent.
"""
if not text:
return MarkdownFenceResult(text=text)
lines = text.splitlines(keepends=True)
repairs = 0
idx = 0
while idx < len(lines):
outer = _fence(lines[idx])
if outer is None or outer[3].lower() not in {"markdown", "md"}:
idx += 1
continue
inner_idx = _next_nonblank(lines, idx + 1)
inner = _fence(lines[inner_idx]) if inner_idx is not None else None
if (
inner is None
or not inner[3]
or inner[1] != outer[1]
or inner[2] < outer[2]
):
idx += 1
continue
inner_close_idx = None
for candidate in range(inner_idx + 1, len(lines)):
closing = _fence(lines[candidate])
if (
closing is not None
and not closing[3]
and closing[1] == inner[1]
and closing[2] >= inner[2]
):
inner_close_idx = candidate
break
if inner_close_idx is None:
idx += 1
continue
outer_close_idx = _next_nonblank(lines, inner_close_idx + 1)
outer_close = _fence(lines[outer_close_idx]) if outer_close_idx is not None else None
if (
outer_close is None
or outer_close[3]
or outer_close[1] != outer[1]
or outer_close[2] < outer[2]
):
idx += 1
continue
repaired_length = max(outer[2], inner[2]) + 1
lines[idx] = _replace_fence(lines[idx], repaired_length)
lines[outer_close_idx] = _replace_fence(lines[outer_close_idx], repaired_length)
repairs += 1
idx = outer_close_idx + 1
normalized = "".join(lines)
return MarkdownFenceResult(
text=normalized,
repairs=repairs,
unclosed_fence=_has_unclosed_fence(lines),
)

View File

@ -19,6 +19,7 @@ from sqlalchemy import delete, func, select
from .storage import session_scope
from .storage.models import Message, Task, sanitize_jsonb_nul
from .file_store import atomic_write_text
from .markdown_guard import normalize_markdown_fences
def _to_dict(msg: Any) -> Any:
@ -72,6 +73,20 @@ class Session:
# 与 Message.payload 的 ORM 守门保持一致,使当前 run 的内存上下文和落库值
# 完全相同;外部工具提取文本偶尔会携带 PostgreSQL JSONB 不支持的 NUL。
msg_dict = sanitize_jsonb_nul(_to_dict(msg))
if msg_dict.get("role") == "assistant" and isinstance(msg_dict.get("content"), str):
fence_result = normalize_markdown_fences(msg_dict["content"])
msg_dict["content"] = fence_result.text
if fence_result.repairs:
print(
f"[markdown:fence-repair] task={self.task_id} "
f"repairs={fence_result.repairs}",
flush=True,
)
elif fence_result.unclosed_fence:
print(
f"[markdown:fence-warning] task={self.task_id} unclosed=1",
flush=True,
)
self.messages.append(msg_dict)
if msg_dict.get("role") == "system":
return None
@ -201,7 +216,12 @@ class Session:
.order_by(Message.idx)
).scalars().all()
for row in rows:
sess.messages.append(dict(row.payload))
payload = dict(row.payload)
if payload.get("role") == "assistant" and isinstance(payload.get("content"), str):
# 历史行不回写生产库;只在重建 LLM 上下文时应用同一窄修复,
# 与 Web 展示层保持一致,避免旧坏围栏继续污染后续轮次。
payload["content"] = normalize_markdown_fences(payload["content"]).text
sess.messages.append(payload)
# 真实总条数(含 base 之前的归档历史),保证 append 续号不撞 idx。
sess._db_idx = s.execute(
select(func.count())

View File

@ -36,6 +36,7 @@
- 动手前先看: 用 read/grep/glob 摸清现状,再 edit
- 改动最小化: edit 工具的 old_str 必须唯一匹配,不够唯一就多带上下文
- 有测试就跑测试验证;没有就用 run_python 写一段最小复现验证
- Markdown 围栏:展示包含 fenced code block 的 Markdown 源码时,外层统一用 `~~~~` 围栏,内层保留反引号围栏,并确保外层围栏长于任何同字符内层围栏
- 输出简洁: 不复述 diff,只说做了什么、下一步要不要继续
- 工具结果带 `[Error ...]` 时,先想清楚原因再重试,不要盲目重复同一调用
- 不臆造 API、文献、数据 —— 不知道就 read 源码 / 让用户提供 / 明说不知道

View File

@ -0,0 +1,35 @@
import assert from "node:assert/strict";
import { createRequire } from "node:module";
import test from "node:test";
const require = createRequire(import.meta.url);
const marked = require("../web/static/vendor/markdown/marked.umd.js");
globalThis.window = { marked };
const { normalizeMarkdownFences, renderMd } = await import("../web/static/js/markdown.js");
test("repairs equal-length nested markdown fences", () => {
const broken = [
"```markdown",
"```mermaid",
"flowchart LR",
" A --> B",
"```",
"```",
"",
"正文 **正常**。",
"",
].join("\n");
const repaired = normalizeMarkdownFences(broken);
assert.match(repaired, /^````markdown\n```mermaid/);
assert.match(repaired, /```\n````\n\n正文 \*\*正常\*\*。/);
const html = renderMd(broken);
assert.match(html, /<p>正文 <strong>正常<\/strong>。<\/p>/);
});
test("preserves unrelated unclosed fences", () => {
const broken = "正文\n```python\nprint('x')\n";
assert.equal(normalizeMarkdownFences(broken), broken);
});

View File

@ -0,0 +1,60 @@
from __future__ import annotations
import unittest
from core.markdown_guard import normalize_markdown_fences
BROKEN = """前文
```markdown
```mermaid
flowchart LR
A --> B
```
```
后文 **应正常渲染**
"""
class MarkdownFenceGuardTests(unittest.TestCase):
def test_repairs_equal_length_nested_fence_example(self) -> None:
result = normalize_markdown_fences(BROKEN)
self.assertEqual(result.repairs, 1)
self.assertFalse(result.unclosed_fence)
self.assertIn("````markdown\n```mermaid", result.text)
self.assertIn("```\n````\n\n后文", result.text)
def test_preserves_already_valid_longer_outer_fence(self) -> None:
valid = BROKEN.replace("```markdown", "````markdown", 1).replace(
"```\n\n后文", "````\n\n后文", 1
)
result = normalize_markdown_fences(valid)
self.assertEqual(result.text, valid)
self.assertEqual(result.repairs, 0)
self.assertFalse(result.unclosed_fence)
def test_preserves_arbitrary_unclosed_fence_and_reports_it(self) -> None:
broken = "正文\n\n```python\nprint('x')\n"
result = normalize_markdown_fences(broken)
self.assertEqual(result.text, broken)
self.assertEqual(result.repairs, 0)
self.assertTrue(result.unclosed_fence)
def test_supports_tilde_fences_and_blank_line_between_closes(self) -> None:
broken = "~~~~md\n~~~~js\nx = 1\n~~~~\n\n~~~~\n正文\n"
result = normalize_markdown_fences(broken)
self.assertEqual(result.repairs, 1)
self.assertTrue(result.text.startswith("~~~~~md\n~~~~js"))
self.assertIn("~~~~\n\n~~~~~\n正文", result.text)
if __name__ == "__main__":
unittest.main()

View File

@ -1,5 +1,6 @@
import unittest
from contextlib import contextmanager
from types import SimpleNamespace
from unittest.mock import patch
from uuid import uuid4
@ -62,6 +63,72 @@ class MessagePayloadSanitizationTests(unittest.TestCase):
self.assertEqual(session.messages, [expected])
self.assertEqual(fake_db.row.payload, expected)
def test_session_repairs_assistant_markdown_before_storing_it(self) -> None:
class FakeDbSession:
row: Message
def add(self, row: Message) -> None:
self.row = row
def flush(self) -> None:
self.row.message_id = uuid4()
fake_db = FakeDbSession()
@contextmanager
def fake_session_scope():
yield fake_db
broken = "```markdown\n```mermaid\nA --> B\n```\n```\n正文\n"
session = Session(task_id=uuid4())
with patch("core.session.session_scope", side_effect=fake_session_scope):
session.append({"role": "assistant", "content": broken})
stored = session.messages[0]["content"]
self.assertTrue(stored.startswith("````markdown\n```mermaid"))
self.assertIn("```\n````\n正文", stored)
self.assertEqual(fake_db.row.payload["content"], stored)
def test_session_repairs_historical_assistant_markdown_in_memory_only(self) -> None:
broken = "```markdown\n```mermaid\nA --> B\n```\n```\n正文\n"
class FakeResult:
def __init__(self, value):
self.value = value
def first(self):
return self.value
def scalars(self):
return self
def all(self):
return self.value
def scalar_one(self):
return self.value
class FakeDbSession:
def __init__(self):
self.results = iter([
FakeResult(SimpleNamespace(context_base_idx=0, context_summary="")),
FakeResult([SimpleNamespace(payload={"role": "assistant", "content": broken})]),
FakeResult(1),
])
def execute(self, _query):
return next(self.results)
@contextmanager
def fake_session_scope():
yield FakeDbSession()
with patch("core.session.session_scope", side_effect=fake_session_scope):
session = Session.load(uuid4())
self.assertTrue(session.messages[0]["content"].startswith("````markdown"))
self.assertEqual(session._db_idx, 1)
if __name__ == "__main__":
unittest.main()

View File

@ -6,8 +6,71 @@ if (window.marked && window.marked.setOptions) {
window.marked.setOptions({ gfm: true, breaks: true, headerIds: false, mangle: false });
}
const FENCE_RE = /^( {0,3})(`{3,}|~{3,})([^\r\n]*)(\r?\n)?$/;
function parseFence(line) {
const m = String(line || "").match(FENCE_RE);
if (!m) return null;
return { indent: m[1], char: m[2][0], len: m[2].length, info: m[3].trim() };
}
function nextNonblank(lines, start) {
for (let i = start; i < lines.length; i++) {
if (lines[i].trim()) return i;
}
return -1;
}
function replaceFence(line, length) {
const m = String(line || "").match(FENCE_RE);
if (!m) return line;
return m[1] + m[2][0].repeat(length) + m[3] + (m[4] || "");
}
// 只修复明确的「markdown 外层与内层语言块使用同长围栏」形态。
// 其他残缺 Markdown 原样交给 marked避免猜测作者意图。
export function normalizeMarkdownFences(text) {
const lines = String(text || "").match(/.*(?:\r\n|\n|$)/g).filter(Boolean);
for (let i = 0; i < lines.length;) {
const outer = parseFence(lines[i]);
if (!outer || !["markdown", "md"].includes(outer.info.toLowerCase())) {
i++;
continue;
}
const innerIdx = nextNonblank(lines, i + 1);
const inner = innerIdx >= 0 ? parseFence(lines[innerIdx]) : null;
if (!inner || !inner.info || inner.char !== outer.char || inner.len < outer.len) {
i++;
continue;
}
let innerCloseIdx = -1;
for (let j = innerIdx + 1; j < lines.length; j++) {
const close = parseFence(lines[j]);
if (close && !close.info && close.char === inner.char && close.len >= inner.len) {
innerCloseIdx = j;
break;
}
}
if (innerCloseIdx < 0) {
i++;
continue;
}
const outerCloseIdx = nextNonblank(lines, innerCloseIdx + 1);
const outerClose = outerCloseIdx >= 0 ? parseFence(lines[outerCloseIdx]) : null;
if (!outerClose || outerClose.info || outerClose.char !== outer.char || outerClose.len < outer.len) {
i++;
continue;
}
const repairedLen = Math.max(outer.len, inner.len) + 1;
lines[i] = replaceFence(lines[i], repairedLen);
lines[outerCloseIdx] = replaceFence(lines[outerCloseIdx], repairedLen);
i = outerCloseIdx + 1;
}
return lines.join("");
}
export function renderMd(text) {
const raw = String(text || "");
const raw = normalizeMarkdownFences(text);
if (!window.marked || !window.marked.parse) {
return `<pre style="white-space:pre-wrap;word-break:break-word;font-family:inherit;margin:0;">${escapeHtml(raw)}</pre>`;
}