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

1"""Writer 编排:上下文装配 → 逐章生成 → 渲染 → docx 注入(Phase 5 垂直切片)。 

2 

3Impact Agent MVP(2026-08-23):门控 = 用户是否提供既有系统(existing_system 非 None)。 

4门控通过且未显式传入 impact_report 时,自动运行 ImpactAgent 生成影响调查书, 

5并作为生成主上下文(GenerationContext.impact_report → prompt 的 {{impact}} 变量)。 

6""" 

7from __future__ import annotations 

8 

9import logging 

10from datetime import date 

11from pathlib import Path 

12 

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 

24 

25_LOGGER = logging.getLogger(__name__) 

26 

27 

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:"):] 

32 

33 

34def _warn_unanchored(ctxs) -> None: 

35 """防静默丢章:对缺少 {{section:<id>}} 锚点的章节打显式告警。 

36 

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 ) 

46 

47 

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) 

76 

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 

100 

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