Coverage for src\genesis\writer\orchestrator.py: 99%
65 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-26 14:20 +0800
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-26 14:20 +0800
1"""Writer 编排:上下文装配 → 逐章生成 → 渲染 → docx 注入(Phase 5 垂直切片)。
3Impact Agent MVP(2026-08-23):门控 = 用户是否提供既有系统(existing_system 非 None)。
4门控通过且未显式传入 impact_report 时,自动运行 ImpactAgent 生成影响调查书,
5并作为生成主上下文(GenerationContext.impact_report → prompt 的 {{impact}} 变量)。
6"""
7from __future__ import annotations
9import logging
10from datetime import date
11from pathlib import Path
13from genesis.data_models import StructuredSource
14from genesis.impact.impact_agent import ImpactAgent
15from genesis.inference.factory import build_inference_engine
16from genesis.inference.prompt_registry import PromptRegistry
17from genesis.writer.context_builder import build_contexts
18from genesis.writer.docx_injector import Block, DocxInjector
19from genesis.writer.exceptions import WriterGenerationError
20from genesis.writer.models import ChapterContent
21from genesis.writer.renderer import render_chapter_blocks
22from genesis.writer.writer_agent import WriterAgent
23from genesis.writer.writer_state import WriterState
25_LOGGER = logging.getLogger(__name__)
28def _section_id_of(placeholder: str | None) -> str | None:
29 if not placeholder or not placeholder.startswith("section:"):
30 return None
31 return placeholder[len("section:"):]
34def _warn_unanchored(ctxs) -> None:
35 """防静默丢章:对缺少 {{section:<id>}} 锚点的章节打显式告警。
37 管线会对模板中每个 Heading 都生成内容,但只有带锚点的章才会注入 docx;
38 无锚点章生成后会被丢弃。此函数将其从「静默丢弃」变为「可见告警」。
39 """
40 unanchored = [ctx.title for ctx in ctxs if not ctx.template_marker.section_placeholder]
41 if unanchored:
42 _LOGGER.warning(
43 "章节已生成但模板缺少 {{section:<id>}} 锚点,内容未注入(静默丢弃):%s",
44 ", ".join(unanchored),
45 )
48class WriteOrchestrator:
49 def generate(
50 self,
51 structured_source: StructuredSource,
52 output_path: str,
53 session_id: str = "writer",
54 samples_dir: str = "sample",
55 engine=None,
56 prompt_registry=None,
57 template_path: str | None = None,
58 impact_report=None,
59 meta: dict | None = None,
60 output_language: str = "auto",
61 chapter_attempts: int = 3,
62 ) -> list[ChapterContent]:
63 engine = engine or build_inference_engine()
64 prompt_registry = prompt_registry or PromptRegistry()
65 if impact_report is None and getattr(structured_source, "existing_system", None) is not None:
66 # 门控:用户提供了既有系统(existing_system 非 None)→ 自动执行影响调查
67 _LOGGER.info("检测到既有系统,自动执行影响调查(追加改修场景)")
68 impact_report = ImpactAgent().run(structured_source, session_id=session_id)
69 if impact_report is not None:
70 # 回填 structured_source,便于 QA/日志/后续下载
71 structured_source.impact_report = impact_report
72 ctxs = build_contexts(structured_source, samples_dir, output_language=output_language)
73 _warn_unanchored(ctxs)
74 state = WriterState([c.chapter_id for c in ctxs])
75 agent = WriterAgent(session_id=session_id, engine=engine, prompt_registry=prompt_registry, state=state)
77 contents: list[ChapterContent] = []
78 sections: dict[str, list[Block]] = {}
79 for ctx in ctxs:
80 # 章级管道重试(#1/#2):真实 LLM 输出有随机方差,单章硬失败不连坐整次运行。
81 # 每轮管道尝试内部已含 WriterAgent.max_retries 次 LLM 调用;chapter_attempts 为
82 # 管道层兜底轮数(默认 3)。耗尽后仍抛错(不吞错)。
83 content: ChapterContent | None = None
84 last_err: Exception | None = None
85 for attempt in range(max(1, chapter_attempts)):
86 try:
87 content = agent.generate_chapter(ctx)
88 break
89 except WriterGenerationError as e:
90 last_err = e
91 _LOGGER.warning("章节 %s 生成失败(第 %d/%d 轮管道重试): %s",
92 ctx.chapter_id, attempt + 1, chapter_attempts, e)
93 if content is None:
94 raise WriterGenerationError(f"章节 {ctx.chapter_id} 管道重试耗尽: {last_err}")
95 contents.append(content)
96 blocks = render_chapter_blocks(content)
97 sec_id = _section_id_of(ctx.template_marker.section_placeholder)
98 if sec_id:
99 sections[sec_id] = blocks
101 tpl = template_path or getattr(structured_source.template, "file_name", None)
102 if not tpl:
103 raise ValueError("template_path 必须提供(structured_source.template.file_name 为空)")
104 if meta is None: 104 ↛ 110line 104 didn't jump to line 110 because the condition on line 104 was always true
105 meta = {
106 "doc_title": Path(tpl).stem,
107 "version": "v1",
108 "created_at": date.today().isoformat(),
109 }
110 doc = DocxInjector(tpl).inject(sections, meta=meta)
111 Path(output_path).parent.mkdir(parents=True, exist_ok=True)
112 doc.save(output_path)
113 return contents