Add Comments

This commit is contained in:
马一丁
2025-11-13 22:49:59 +08:00
parent 82152547e1
commit e267b1fc04
23 changed files with 500 additions and 145 deletions
+2 -1
View File
@@ -1,7 +1,8 @@
"""
Report Engine核心工具集合。
包含模板切片、章节存储等基础能力,供agent流水线复用。
该包封装了模板切片、章节存储与章节装订三大基础能力,
所有上层节点都会复用这些工具保证结构一致。
"""
from .template_parser import TemplateSection, parse_template_sections
+51 -20
View File
@@ -17,7 +17,12 @@ from typing import Dict, Generator, List, Optional
@dataclass
class ChapterRecord:
"""manifest中记录的章节元数据"""
"""
manifest中记录的章节元数据。
该结构用于在 `manifest.json` 中追踪每章的状态、文件位置、
以及可能的错误列表,方便前端或调试工具读取。
"""
chapter_id: str
slug: str
@@ -46,12 +51,10 @@ class ChapterStorage:
"""
章节JSON写入与manifest管理器。
用法
run_dir = storage.start_session(report_id, {...})
chapter_dir = storage.begin_chapter(run_dir, meta)
with storage.capture_stream(chapter_dir) as fp:
fp.write(chunk)
storage.persist_chapter(run_dir, meta, payload, errors)
负责
- 为每次报告创建独立run目录与manifest快照;
- 在章节流式生成时即时写入 `stream.raw`
- 校验通过后持久化 `chapter.json` 并更新manifest状态。
"""
def __init__(self, base_dir: str):
@@ -68,7 +71,11 @@ class ChapterStorage:
# ======== 会话 & manifest ========
def start_session(self, report_id: str, metadata: Dict[str, object]) -> Path:
"""为本次报告创建独立的章节输出目录与manifest"""
"""
为本次报告创建独立的章节输出目录与manifest。
同时把全局metadata写入 `manifest.json`,供渲染/调试查询。
"""
run_dir = self.base_dir / report_id
run_dir.mkdir(parents=True, exist_ok=True)
manifest = {
@@ -82,7 +89,11 @@ class ChapterStorage:
return run_dir
def begin_chapter(self, run_dir: Path, chapter_meta: Dict[str, object]) -> Path:
"""创建章节子目录并在manifest中标记为streaming状态"""
"""
创建章节子目录并在manifest中标记为streaming状态。
会生成 `order-slug` 风格的子目录,并提前登记 raw 文件路径。
"""
slug_value = str(
chapter_meta.get("slug") or chapter_meta.get("chapterId") or "section"
)
@@ -109,7 +120,11 @@ class ChapterStorage:
payload: Dict[str, object],
errors: Optional[List[str]] = None,
) -> Path:
"""章节流式生成完毕后写入最终JSON并更新manifest状态"""
"""
章节流式生成完毕后写入最终JSON并更新manifest状态。
若校验失败,错误信息会被写入manifest,供前端展示。
"""
slug_value = str(
chapter_meta.get("slug") or chapter_meta.get("chapterId") or "section"
)
@@ -140,7 +155,11 @@ class ChapterStorage:
return final_path
def load_chapters(self, run_dir: Path) -> List[Dict[str, object]]:
"""从指定run目录读取全部chapter.json并按order排序返回"""
"""
从指定run目录读取全部chapter.json并按order排序返回。
常用于 DocumentComposer 将多个章节装订成整本IR。
"""
payloads: List[Dict[str, object]] = []
for child in sorted(run_dir.iterdir()):
if not child.is_dir():
@@ -160,7 +179,11 @@ class ChapterStorage:
@contextmanager
def capture_stream(self, chapter_dir: Path) -> Generator:
"""将流式输出实时写入raw文件"""
"""
将流式输出实时写入raw文件。
通过 contextmanager 暴露文件句柄,简化章节节点的写入逻辑。
"""
raw_path = self._raw_stream_path(chapter_dir)
raw_path.parent.mkdir(parents=True, exist_ok=True)
with raw_path.open("w", encoding="utf-8") as fp:
@@ -169,7 +192,7 @@ class ChapterStorage:
# ======== 内部工具 ========
def _chapter_dir(self, run_dir: Path, slug: str, order: int) -> Path:
"""根据slug/order生成稳定的章节目录,确保各章分隔存盘"""
"""根据slug/order生成稳定目录,确保各章分隔存盘且可排序。"""
safe_slug = self._safe_slug(slug)
folder = f"{order:03d}-{safe_slug}"
path = run_dir / folder
@@ -177,38 +200,46 @@ class ChapterStorage:
return path
def _safe_slug(self, slug: str) -> str:
"""移除危险字符,避免生成非法文件夹名"""
"""移除危险字符,避免生成非法文件夹名"""
slug = slug.replace(" ", "-").replace("/", "-")
return slug or "section"
def _raw_stream_path(self, chapter_dir: Path) -> Path:
"""返回某章节流式输出对应的raw文件路径"""
"""返回某章节流式输出对应的raw文件路径"""
return chapter_dir / "stream.raw"
def _key(self, run_dir: Path) -> str:
"""将run目录解析为字典缓存的键,避免重复读取磁盘"""
"""将run目录解析为字典缓存的键,避免重复读取磁盘"""
return str(run_dir.resolve())
def _manifest_path(self, run_dir: Path) -> Path:
"""获取manifest.json的实际文件路径"""
"""获取manifest.json的实际文件路径"""
return run_dir / "manifest.json"
def _write_manifest(self, run_dir: Path, manifest: Dict[str, object]):
"""将内存中的manifest快照全量写回磁盘"""
"""将内存中的manifest快照全量写回磁盘"""
self._manifest_path(run_dir).write_text(
json.dumps(manifest, ensure_ascii=False, indent=2),
encoding="utf-8",
)
def _read_manifest(self, run_dir: Path) -> Dict[str, object]:
"""从磁盘读取已有manifest,用于进程重启或多实例协作"""
"""
从磁盘读取已有manifest。
进程重启或多实例写盘时可借助它恢复上下文。
"""
manifest_path = self._manifest_path(run_dir)
if manifest_path.exists():
return json.loads(manifest_path.read_text(encoding="utf-8"))
return {"reportId": run_dir.name, "chapters": []}
def _upsert_record(self, run_dir: Path, record: ChapterRecord):
"""更新或追加manifest中的章节记录,保证顺序一致"""
"""
更新或追加manifest中的章节记录,保证顺序一致。
内部会自动排序并写回缓存+磁盘。
"""
key = self._key(run_dir)
manifest = self._manifests.get(key) or self._read_manifest(run_dir)
chapters: List[Dict[str, object]] = manifest.get("chapters", [])
+13 -2
View File
@@ -1,5 +1,7 @@
"""
章节装订器:负责把多个章节JSON合并为整本IR。
DocumentComposer 会注入缺失锚点、统一顺序,并补齐 IR 级元数据。
"""
from __future__ import annotations
@@ -13,6 +15,11 @@ from ..ir import IR_VERSION
class DocumentComposer:
"""
将章节拼接成Document IR的简单装订器。
作用:
- 按order排序章节,补充默认chapterId
- 防止anchor重复,生成全局唯一锚点;
- 注入 IR 版本与生成时间戳。
"""
def __init__(self):
@@ -25,7 +32,11 @@ class DocumentComposer:
metadata: Dict[str, object],
chapters: List[Dict[str, object]],
) -> Dict[str, object]:
"""把所有章节按order排序并注入唯一锚点,形成整本IR"""
"""
把所有章节按order排序并注入唯一锚点,形成整本IR。
同时合并 metadata/themeTokens/assets,供渲染器直接消费。
"""
ordered = sorted(chapters, key=lambda c: c.get("order", 0))
for idx, chapter in enumerate(ordered, start=1):
chapter.setdefault("chapterId", f"S{idx}")
@@ -48,7 +59,7 @@ class DocumentComposer:
return document
def _ensure_unique_anchor(self, anchor: str) -> str:
"""若存在重复锚点则追加序号,确保全局唯一"""
"""若存在重复锚点则追加序号,确保全局唯一"""
base = anchor
counter = 2
while anchor in self._seen_anchors:
+37 -9
View File
@@ -18,7 +18,12 @@ SECTION_ORDER_STEP = 10
@dataclass
class TemplateSection:
"""模板章节实体"""
"""
模板章节实体。
记录标题、slug、序号、层级、原始标题、章节编号与提纲,
方便后续节点在提示词中引用并保持锚点一致。
"""
title: str
slug: str
@@ -30,7 +35,11 @@ class TemplateSection:
outline: List[str] = field(default_factory=list)
def to_dict(self) -> dict:
"""将章节实体序列化为字典,方便传给LLM或落盘"""
"""
将章节实体序列化为字典。
该结构广泛用于提示词上下文以及 layout/word budget 节点的输入。
"""
return {
"title": self.title,
"slug": self.slug,
@@ -52,7 +61,8 @@ def parse_template_sections(template_md: str) -> List[TemplateSection]:
将Markdown模板切分成章节列表(按大标题)。
返回的每个TemplateSection都携带slug/order/章节号,
方便后续分章调用与锚点生成。
方便后续分章调用与锚点生成。解析时会同时兼容
“# 标题”“无符号编号”“列表提纲”等不同写法。
"""
sections: List[TemplateSection] = []
@@ -98,7 +108,12 @@ def parse_template_sections(template_md: str) -> List[TemplateSection]:
def _classify_line(stripped: str, indent: int) -> Optional[dict]:
"""根据缩进与符号分类行"""
"""
根据缩进与符号分类行。
借助正则判断当前行是章节标题、提纲还是普通列表项,
并衍生 depth/slug/number 等派生信息。
"""
heading_match = heading_pattern.match(stripped)
if heading_match:
@@ -154,14 +169,19 @@ def _classify_line(stripped: str, indent: int) -> Optional[dict]:
def _strip_markup(text: str) -> str:
"""去除包裹的**、__等简单强调标记"""
"""去除包裹的**、__等强调标记,避免干扰标题匹配。"""
if text.startswith(("**", "__")) and text.endswith(("**", "__")) and len(text) > 4:
return text[2:-2].strip()
return text
def _split_number(payload: str) -> dict:
"""拆分编号与标题"""
"""
拆分编号与标题。
例如 `1.2 市场趋势` 会被拆成 number=1.2、label=市场趋势,
并提供 display 用于回填标题。
"""
match = number_pattern.match(payload)
number = match.group("num") if match else ""
label = match.group("label") if match else payload
@@ -176,7 +196,7 @@ def _split_number(payload: str) -> dict:
def _build_slug(number: str, title: str) -> str:
"""根据编号/标题生成锚点"""
"""根据编号/标题生成锚点,优先复用编号,缺失时对标题slug化。"""
if number:
token = number.replace(".", "-")
else:
@@ -186,7 +206,11 @@ def _build_slug(number: str, title: str) -> str:
def _slugify_text(text: str) -> str:
"""对任意文本做降噪与转写,得到URL友好的slug片段"""
"""
对任意文本做降噪与转写,得到URL友好的slug片段。
会规整大小写、移除特殊符号并保留汉字,确保锚点可读。
"""
text = unicodedata.normalize("NFKD", text)
text = text.replace("·", "-").replace(" ", "-")
text = re.sub(r"[^0-9a-zA-Z\u4e00-\u9fff-]+", "-", text)
@@ -195,7 +219,11 @@ def _slugify_text(text: str) -> str:
def _ensure_unique_slug(slug: str, used: set) -> str:
"""若slug重复则自动追加序号,直到在used集合中唯一"""
"""
若slug重复则自动追加序号,直到在used集合中唯一。
通过 `-2/-3...` 的方式保证相同标题不会产生重复锚点。
"""
if slug not in used:
used.add(slug)
return slug