Add Comments
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
"""
|
||||
Report Engine核心工具集合。
|
||||
|
||||
包含模板切片、章节存储等基础能力,供agent流水线复用。
|
||||
该包封装了模板切片、章节存储与章节装订三大基础能力,
|
||||
所有上层节点都会复用这些工具保证结构一致。
|
||||
"""
|
||||
|
||||
from .template_parser import TemplateSection, parse_template_sections
|
||||
|
||||
@@ -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", [])
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user