v0.7.2: 前置件排版重构 + emoji 禁令 + 引文核查
用户反馈 7 个 bug 修复: 1. 禁止 LLM 使用 emoji(全链路) - scripts/prompts/translate_system.txt 增加规则 12 - scripts/prompts/polish_system.txt 增加规则 7 - .opencode/agents/dr-analyst.md Hard Rules 增加第 10 条(同时把 prompt 自身的 ✅❌ 改为 MUST / MUST NOT) - .opencode/agents/dr-editor-in-chief.md 禁止事项加入 emoji 条款 - .opencode/skills/output-hygiene/SKILL.md 新增 §J emoji 强制禁用 2. 术语表位置错误(应在目录之后) 重构 build_body 为两阶段: (a) 扫描所有前置件(第一个正文 H1 前的所有 H1/H2),按 title_kind 分组收集 (b) 按固定顺序渲染:免责声明 → 执行摘要 → 目录 → 术语表 → 正文 → 参考文献 无论 Markdown 原文顺序如何,排版都一致。 3. 执行摘要/术语表提升为一级标题 + 分页空页 bug 统一所有独立章节(disclaimer/executive_summary/toc/glossary/references)用 h1 样式, 章节前 PageBreak;但第一个独立章节不 PageBreak(封面后已换页,避免空白)。 去掉 build_toc 内部末尾 PageBreak(原双 PageBreak 夹出空白页)。 4. 参考文献分页 已作为独立章节自动分页。 5. 附录章节自动删除 _title_kind 识别 "appendix" / "version_history" / "abstract" 全部跳过。 正文中若写了这些章节,模板直接丢弃。 6. 信源完整性核查 新增 scripts/check_citations.py: - 孤立引用(正文有 sources 无)检测 - 孤岛信源(sources 有正文无)检测 - emoji 扫描 - 实测发现项目中 61 条孤立引用(dr-analyst 编造的占位符)+ 5 条孤岛信源 7. git commit message 中文转义 bug 之前 commit 用 shell 双引号 + 反斜杠导致 \uXXXX 字面保留。 本 commit 用 heredoc 保证中文以 UTF-8 直接写入。 已 push 的历史不改,之后都用本 commit 的写法。 PDF 验证结果:55 页,0 空白页。 章节起始页:封面(1) - 免责声明(2) - 执行摘要(3) - 目录(5) - 术语表(7) - 第一章(12) - 第十章(48) - 参考文献(52)。
This commit is contained in:
@@ -1106,108 +1106,181 @@ def build_body(
|
||||
break
|
||||
i = first_section_idx # 从第一个 section 开始处理
|
||||
|
||||
def _title_kind(title_raw: str) -> str:
|
||||
"""识别标题的"语义类别"。无论原文 H1 或 H2,统一归类。
|
||||
|
||||
返回:
|
||||
'abstract' — 摘要(将被跳过)
|
||||
'appendix' — 附录(将被跳过)
|
||||
'version_history' — 版本历史(将被跳过)
|
||||
'toc' — 目录(自动生成)
|
||||
'references' — 参考文献(自动生成)
|
||||
'glossary' — 术语表(独立章节)
|
||||
'disclaimer' — 免责声明(独立章节)
|
||||
'executive_summary' — 执行摘要(独立章节)
|
||||
'chapter' — 正文章节(默认)
|
||||
"""
|
||||
t = title_raw.strip().lower()
|
||||
# 跳过类
|
||||
if t in ("摘要", "abstract"):
|
||||
return "abstract"
|
||||
if t.startswith("附录") or t.startswith("appendix"):
|
||||
return "appendix"
|
||||
if t in ("版本历史", "version history", "版本"):
|
||||
return "version_history"
|
||||
# 自动生成类
|
||||
if t in ("目录", "table of contents"):
|
||||
return "toc"
|
||||
if t in ("参考文献", "references", "bibliography"):
|
||||
return "references"
|
||||
# 识别类(带 PageBreak 独立成章)
|
||||
if t in ("术语表", "glossary"):
|
||||
return "glossary"
|
||||
if t in ("免责声明", "disclaimer"):
|
||||
return "disclaimer"
|
||||
if t in ("执行摘要", "executive summary", "管理层摘要"):
|
||||
return "executive_summary"
|
||||
return "chapter"
|
||||
|
||||
def _consume_until_next_section(start: int) -> int:
|
||||
"""从 start 开始收集内容(非 H1/H2 的 block),返回下一个 H1/H2 的索引。"""
|
||||
j = start
|
||||
while j < n and blocks[j].kind not in ("h1", "h2"):
|
||||
_render_generic_block(blocks[j], story, base_dir, styles, in_summary=in_summary)
|
||||
j += 1
|
||||
return j
|
||||
|
||||
def _skip_until_next_section(start: int) -> int:
|
||||
"""从 start 开始跳过内容,返回下一个 H1/H2 的索引。"""
|
||||
j = start
|
||||
while j < n and blocks[j].kind not in ("h1", "h2"):
|
||||
j += 1
|
||||
return j
|
||||
|
||||
# 收集前置件(在第一个"正文 H1 章节"之前的所有内容)按 title_kind 分组。
|
||||
# 然后按固定顺序输出:免责声明 → 执行摘要 → 目录 → 术语表 → 正文 → 参考文献。
|
||||
# 这样无论 Markdown 里写的顺序如何,最终排版都一致(目录在术语表之前)。
|
||||
first_h1_idx = n
|
||||
for k in range(i, n):
|
||||
if blocks[k].kind == "h1" and _title_kind(blocks[k].content) == "chapter":
|
||||
first_h1_idx = k
|
||||
break
|
||||
|
||||
# 收集"前置件段":从 i 到 first_h1_idx
|
||||
front_sections: dict[str, list[Block]] = {}
|
||||
|
||||
def _collect_section(start: int, until: int) -> tuple[str, list[Block], int]:
|
||||
"""从 start 处的 H1/H2 开始,收集这一 section 直到下一个 H1/H2(或 until)。
|
||||
返回 (kind, blocks 列表, 下一个 section 的起始索引)。
|
||||
"""
|
||||
head = blocks[start]
|
||||
kind = _title_kind(head.content)
|
||||
sec = [head]
|
||||
k = start + 1
|
||||
while k < until and blocks[k].kind not in ("h1", "h2"):
|
||||
sec.append(blocks[k])
|
||||
k += 1
|
||||
return kind, sec, k
|
||||
|
||||
# 在前置件区域内遍历
|
||||
k = i
|
||||
while k < first_h1_idx:
|
||||
b = blocks[k]
|
||||
if b.kind not in ("h1", "h2"):
|
||||
k += 1
|
||||
continue
|
||||
kind, sec, next_k = _collect_section(k, first_h1_idx)
|
||||
if kind in ("abstract", "appendix", "version_history"):
|
||||
pass # 丢弃
|
||||
elif kind in front_sections:
|
||||
# 重复出现:保留第一份
|
||||
pass
|
||||
else:
|
||||
front_sections[kind] = sec
|
||||
k = next_k
|
||||
|
||||
# 前置件输出顺序(固定)
|
||||
front_order = [
|
||||
"disclaimer", # 免责声明
|
||||
"executive_summary", # 执行摘要
|
||||
"toc", # 目录
|
||||
"glossary", # 术语表
|
||||
]
|
||||
|
||||
def _render_head_section(kind: str, sec: list[Block]) -> None:
|
||||
"""渲染一个前置件 section。sec[0] 是标题,其余是正文。"""
|
||||
nonlocal in_summary
|
||||
# 独立章节前加 PageBreak(但第一个除外,避免封面后空白页)
|
||||
if len(story) > 0:
|
||||
story.append(PageBreak())
|
||||
in_summary = (kind == "executive_summary")
|
||||
head = sec[0]
|
||||
|
||||
# TOC 和 references 调用专门的生成器
|
||||
if kind == "toc":
|
||||
story.extend(build_toc(blocks, styles))
|
||||
return
|
||||
if kind == "references":
|
||||
story.extend(build_references(blocks, sources_path, styles))
|
||||
return
|
||||
|
||||
# 其它前置件:H1 样式渲染标题 + 内容
|
||||
story.append(Paragraph(md_inline_to_rl(head.content), styles["h1"]))
|
||||
for sub in sec[1:]:
|
||||
# 跳过占位符段
|
||||
if sub.kind == "p" and (
|
||||
_TOC_PLACEHOLDER_RE.search(sub.content)
|
||||
or _REF_PLACEHOLDER_RE.search(sub.content)
|
||||
):
|
||||
continue
|
||||
_render_generic_block(sub, story, base_dir, styles, in_summary=in_summary)
|
||||
|
||||
for kind in front_order:
|
||||
if kind in front_sections:
|
||||
_render_head_section(kind, front_sections[kind])
|
||||
|
||||
in_summary = False
|
||||
|
||||
# 现在输出正文(从 first_h1_idx 开始)
|
||||
i = first_h1_idx
|
||||
while i < n:
|
||||
block = blocks[i]
|
||||
|
||||
# --- H1 处理(非首个;本循环内 first_section_idx 之后的 H1 都是真正的章节 H1)---
|
||||
if block.kind == "h1":
|
||||
content = block.content
|
||||
title_low = content.strip().lower()
|
||||
if block.kind not in ("h1", "h2"):
|
||||
_render_generic_block(block, story, base_dir, styles, in_summary=in_summary)
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# 跳过整章:Abstract(保留 Executive Summary)
|
||||
if title_low in ("摘要", "abstract"):
|
||||
j = i + 1
|
||||
while j < n and blocks[j].kind != "h1":
|
||||
j += 1
|
||||
i = j
|
||||
continue
|
||||
kind = _title_kind(block.content)
|
||||
|
||||
# 所有非被替换的 H1 都 PageBreak
|
||||
# 跳过类
|
||||
if kind in ("abstract", "appendix", "version_history"):
|
||||
i = _skip_until_next_section(i + 1)
|
||||
continue
|
||||
|
||||
# 参考文献:自动生成
|
||||
if kind == "references":
|
||||
story.append(PageBreak())
|
||||
|
||||
if any(k in content for k in ("执行摘要", "Executive Summary", "管理层摘要")):
|
||||
in_summary = True
|
||||
else:
|
||||
in_summary = False
|
||||
|
||||
# 目录:已经 PageBreak 了,build_toc 内部再 PageBreak(独立页)
|
||||
if any(s in title_low for s in ("目录", "table of contents")):
|
||||
story.extend(build_toc(blocks, styles))
|
||||
# 跳过紧随其后的占位段
|
||||
j = i + 1
|
||||
while j < n and blocks[j].kind == "p" and _TOC_PLACEHOLDER_RE.search(blocks[j].content):
|
||||
j += 1
|
||||
i = j
|
||||
continue
|
||||
# 术语表:H1 本身已 PageBreak,末尾靠下一个 H1 自然换页
|
||||
if any(s in title_low for s in ("术语表", "glossary")):
|
||||
story.append(Paragraph(md_inline_to_rl(content), styles["h1"]))
|
||||
i += 1
|
||||
while i < n and blocks[i].kind != "h1":
|
||||
sub = blocks[i]
|
||||
_render_generic_block(sub, story, base_dir, styles, in_summary=False)
|
||||
i += 1
|
||||
continue
|
||||
if any(s in title_low for s in ("参考文献", "references")):
|
||||
story.extend(build_references(blocks, sources_path, styles))
|
||||
j = i + 1
|
||||
while j < n and blocks[j].kind == "p" and _REF_PLACEHOLDER_RE.search(blocks[j].content):
|
||||
j += 1
|
||||
i = j
|
||||
continue
|
||||
|
||||
story.append(Paragraph(md_inline_to_rl(content), styles["h1"]))
|
||||
i += 1
|
||||
story.extend(build_references(blocks, sources_path, styles))
|
||||
j = i + 1
|
||||
while j < n and blocks[j].kind == "p" and _REF_PLACEHOLDER_RE.search(blocks[j].content):
|
||||
j += 1
|
||||
i = j
|
||||
continue
|
||||
|
||||
# --- H2 同样检测占位符 ---
|
||||
if block.kind == "h2":
|
||||
title_low = block.content.strip().lower()
|
||||
|
||||
# 跳过整段:Abstract(与 Executive Summary 重复,根据用户偏好保留 Executive Summary)
|
||||
if title_low in ("摘要", "abstract"):
|
||||
# 跳到下一个 H1/H2
|
||||
j = i + 1
|
||||
while j < n and blocks[j].kind not in ("h1", "h2"):
|
||||
j += 1
|
||||
i = j
|
||||
continue
|
||||
|
||||
# 目录:前后 PageBreak(独立成页)
|
||||
if any(s in title_low for s in ("目录", "table of contents")):
|
||||
story.append(PageBreak())
|
||||
story.extend(build_toc(blocks, styles))
|
||||
j = i + 1
|
||||
while j < n and blocks[j].kind == "p" and _TOC_PLACEHOLDER_RE.search(blocks[j].content):
|
||||
j += 1
|
||||
i = j
|
||||
continue
|
||||
|
||||
# 术语表:前 PageBreak,后靠下一个 H1/H2 自然换页
|
||||
if any(s in title_low for s in ("术语表", "glossary")):
|
||||
story.append(PageBreak())
|
||||
story.append(Paragraph(md_inline_to_rl(block.content), styles["h1"]))
|
||||
i += 1
|
||||
while i < n and blocks[i].kind not in ("h1", "h2"):
|
||||
sub = blocks[i]
|
||||
_render_generic_block(sub, story, base_dir, styles, in_summary=False)
|
||||
i += 1
|
||||
continue
|
||||
|
||||
if any(s in title_low for s in ("参考文献", "references")):
|
||||
story.extend(build_references(blocks, sources_path, styles))
|
||||
j = i + 1
|
||||
while j < n and blocks[j].kind == "p" and _REF_PLACEHOLDER_RE.search(blocks[j].content):
|
||||
j += 1
|
||||
i = j
|
||||
continue
|
||||
# 目录/术语表不应在正文中出现(已经作为前置件处理)
|
||||
# 如果原文里在正文中又写了一遍目录/术语表,则跳过
|
||||
if kind in ("toc", "glossary", "disclaimer", "executive_summary"):
|
||||
i = _skip_until_next_section(i + 1)
|
||||
continue
|
||||
|
||||
# H1 正文章节(chapter):PageBreak + h1 样式
|
||||
if block.kind == "h1":
|
||||
story.append(PageBreak())
|
||||
story.append(Paragraph(md_inline_to_rl(block.content), styles["h1"]))
|
||||
else:
|
||||
# H2 正文小节:h2 样式(不分页)
|
||||
story.append(Paragraph(md_inline_to_rl(block.content), styles["h2"]))
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# 非 H1/H2 的 block:统一走 _render_generic_block
|
||||
_render_generic_block(block, story, base_dir, styles, in_summary=in_summary)
|
||||
i += 1
|
||||
|
||||
return story
|
||||
|
||||
Reference in New Issue
Block a user