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 import session_scope
from .storage.models import Message, Task, sanitize_jsonb_nul from .storage.models import Message, Task, sanitize_jsonb_nul
from .file_store import atomic_write_text from .file_store import atomic_write_text
from .markdown_guard import normalize_markdown_fences
def _to_dict(msg: Any) -> Any: def _to_dict(msg: Any) -> Any:
@ -72,6 +73,20 @@ class Session:
# 与 Message.payload 的 ORM 守门保持一致,使当前 run 的内存上下文和落库值 # 与 Message.payload 的 ORM 守门保持一致,使当前 run 的内存上下文和落库值
# 完全相同;外部工具提取文本偶尔会携带 PostgreSQL JSONB 不支持的 NUL。 # 完全相同;外部工具提取文本偶尔会携带 PostgreSQL JSONB 不支持的 NUL。
msg_dict = sanitize_jsonb_nul(_to_dict(msg)) 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) self.messages.append(msg_dict)
if msg_dict.get("role") == "system": if msg_dict.get("role") == "system":
return None return None
@ -201,7 +216,12 @@ class Session:
.order_by(Message.idx) .order_by(Message.idx)
).scalars().all() ).scalars().all()
for row in rows: 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。 # 真实总条数(含 base 之前的归档历史),保证 append 续号不撞 idx。
sess._db_idx = s.execute( sess._db_idx = s.execute(
select(func.count()) select(func.count())

View File

@ -36,6 +36,7 @@
- 动手前先看: 用 read/grep/glob 摸清现状,再 edit - 动手前先看: 用 read/grep/glob 摸清现状,再 edit
- 改动最小化: edit 工具的 old_str 必须唯一匹配,不够唯一就多带上下文 - 改动最小化: edit 工具的 old_str 必须唯一匹配,不够唯一就多带上下文
- 有测试就跑测试验证;没有就用 run_python 写一段最小复现验证 - 有测试就跑测试验证;没有就用 run_python 写一段最小复现验证
- Markdown 围栏:展示包含 fenced code block 的 Markdown 源码时,外层统一用 `~~~~` 围栏,内层保留反引号围栏,并确保外层围栏长于任何同字符内层围栏
- 输出简洁: 不复述 diff,只说做了什么、下一步要不要继续 - 输出简洁: 不复述 diff,只说做了什么、下一步要不要继续
- 工具结果带 `[Error ...]` 时,先想清楚原因再重试,不要盲目重复同一调用 - 工具结果带 `[Error ...]` 时,先想清楚原因再重试,不要盲目重复同一调用
- 不臆造 API、文献、数据 —— 不知道就 read 源码 / 让用户提供 / 明说不知道 - 不臆造 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 import unittest
from contextlib import contextmanager from contextlib import contextmanager
from types import SimpleNamespace
from unittest.mock import patch from unittest.mock import patch
from uuid import uuid4 from uuid import uuid4
@ -62,6 +63,72 @@ class MessagePayloadSanitizationTests(unittest.TestCase):
self.assertEqual(session.messages, [expected]) self.assertEqual(session.messages, [expected])
self.assertEqual(fake_db.row.payload, 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__": if __name__ == "__main__":
unittest.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 }); 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) { export function renderMd(text) {
const raw = String(text || ""); const raw = normalizeMarkdownFences(text);
if (!window.marked || !window.marked.parse) { 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>`; return `<pre style="white-space:pre-wrap;word-break:break-word;font-family:inherit;margin:0;">${escapeHtml(raw)}</pre>`;
} }