fix(markdown): repair nested code fences
This commit is contained in:
parent
a8b84b1d6f
commit
a00ebfe50e
|
|
@ -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),
|
||||||
|
)
|
||||||
|
|
@ -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())
|
||||||
|
|
|
||||||
|
|
@ -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 源码 / 让用户提供 / 明说不知道
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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>`;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue