Coverage for src\genesis\writer\writer_agent.py: 93%

75 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-26 14:20 +0800

1"""Writer Agent:调用推理引擎生成单章内容(Phase 5)。 

2 

3接入真实 InferenceEngine.chat_structured(session_id/prompt/variables/schema/retry_count), 

4并对章节级失败做有限重试;token 估算分块(真实拼回留待后续并发实现)。 

5""" 

6from __future__ import annotations 

7 

8import asyncio 

9 

10from genesis.inference.engine import InferenceEngine 

11from genesis.inference.prompt_registry import PromptRegistry 

12from genesis.inference.types import Prompt, StructuredResult 

13from genesis.writer.models import ChapterContent, GenerationContext 

14from genesis.writer.writer_state import WriterState 

15from genesis.writer.exceptions import WriterGenerationError 

16from genesis.writer.language import ( 

17 find_language_violations, 

18 resolve_expected_language, 

19) 

20 

21 

22WRITER_PROMPT_TEMPLATE = ( 

23 "你是概要设计书撰写专家。\n" 

24 "章节: {{chapter_id}} {{title}}\n" 

25 "本章小节结构:\n{{sub_headings}}\n" 

26 "写入规则:\n{{write_rules}}\n" 

27 "设计规则:\n{{design_rules}}\n" 

28 "模板样式:\n{{template_styles}}\n" 

29 "影响调查上下文:\n{{impact}}\n" 

30 "参考资料(本章对应数据):\n{{data}}\n" 

31 "请输出符合 schema 的章节内容 JSON。\n" 

32 "【小节约束】若上方「本章小节结构」非空,必须按该小节顺序组织内容," 

33 "每个小节以 type=heading、level=2 的内容块开头(标题使用小节原文),随后为该小节的内容块;" 

34 "不得遗漏或新增小节。若「本章小节结构」为空,则不得输出任何 type=heading 的内容块" 

35 "(章节标题已由模板提供),仅以 paragraph/table/list/note 块组织内容。\n" 

36 "【主题约束】本章必须且仅围绕标题「{{title}}」所对应的主题撰写," 

37 "严格以「参考资料(本章对应数据)」中的要件定义数据和「影响调查上下文」为核心依据;" 

38 "禁止输出与本章无关的系统整体架构、通用设计说明等内容,禁止套用其他章节的主题。" 

39 "若本章数据为空,则基于规则与影响调查上下文简要撰写,不得虚构数据。\n" 

40 "【语言约束】章节正文(所有 block 的 text 字段)所使用的自然语言:{{language_instruction}}\n" 

41 ) 

42 

43 

44CHAPTER_OUTPUT_SCHEMA = { 

45 "type": "object", 

46 "properties": { 

47 "title": {"type": "string"}, 

48 "blocks": { 

49 "type": "array", 

50 "items": { 

51 "type": "object", 

52 "properties": { 

53 "type": {"type": "string"}, 

54 "text": {"type": "string"}, 

55 "level": {"type": "integer"}, 

56 "headers": {"type": "array", "items": {"type": "string"}}, 

57 "rows": {"type": "array", "items": {"type": "array", "items": {"type": "string"}}}, 

58 "items": {"type": "array", "items": {"type": "string"}}, 

59 "source_uris": {"type": "array", "items": {"type": "string"}}, 

60 }, 

61 "required": ["type"], 

62 }, 

63 }, 

64 }, 

65 "required": ["title", "blocks"], 

66} 

67 

68 

69class WriterAgent: 

70 def __init__( 

71 self, 

72 session_id, 

73 engine: InferenceEngine, 

74 prompt_registry: PromptRegistry, 

75 state: WriterState, 

76 max_retries: int = 2, 

77 ) -> None: 

78 self.session_id = session_id 

79 self.engine = engine 

80 self.prompt_registry = prompt_registry 

81 self.state = state 

82 self.max_retries = max_retries 

83 

84 def _chunk_source(self, source) -> list[dict]: 

85 if source is None: 

86 return [{"index": 0, "text": ""}] 

87 src = source if isinstance(source, str) else str(source) 

88 n = max(1, len(src) // 1800 + 1) 

89 return [{"index": i, "text": src[i * 1800:(i + 1) * 1800]} for i in range(n)] 

90 

91 def _resolve_prompt(self) -> Prompt: 

92 """取用/注册 writer.chapter 模板,并保证返回 Prompt 对象。 

93 

94 优先使用 get_or_create(与测试 Fake 兼容);真实 PromptRegistry 无该方法时, 

95 回退为 register + get。注意:真实 get() 返回模板字符串而非 Prompt, 

96 而引擎 _render_prompt 仅对 Prompt 对象做变量渲染、str 原样发送 

97 (否则 {{占位符}} 不被替换直接进 LLM)→ 此处统一包装为 Prompt。 

98 """ 

99 get_or_create = getattr(self.prompt_registry, "get_or_create", None) 

100 if get_or_create is not None: 

101 resolved = get_or_create("writer.chapter", WRITER_PROMPT_TEMPLATE) 

102 else: 

103 self.prompt_registry.register("writer.chapter", "1", WRITER_PROMPT_TEMPLATE) 

104 resolved = self.prompt_registry.get("writer.chapter", "1") 

105 if isinstance(resolved, Prompt): 105 ↛ 106line 105 didn't jump to line 106 because the condition on line 105 was never true

106 return resolved 

107 template = getattr(resolved, "template", None) or str(resolved) 

108 version = str(getattr(resolved, "version", "1") or "1") 

109 return Prompt(name="writer.chapter", version=version, template=template) 

110 

111 def _call_llm(self, context: GenerationContext) -> dict: 

112 prompt = self._resolve_prompt() 

113 result = self.engine.chat_structured( 

114 session_id=self.session_id, 

115 prompt=prompt, 

116 variables=context.to_vars(), 

117 schema=CHAPTER_OUTPUT_SCHEMA, 

118 retry_count=2, 

119 ) 

120 # 真实 InferenceEngine.chat_structured 为 async;测试用同步 FakeEngine 返回普通对象。 

121 # 兼容两者:若返回协程则通过 asyncio.run 驱动(调用方 orchestrator/qa_loop/脚本均为同步上下文)。 

122 if asyncio.iscoroutine(result): 

123 result = asyncio.run(result) 

124 if result.status not in ("ok", "fallback"): 

125 # 透传底层错误详情(如 401 鉴权失败原因),便于人工门禁定位 

126 err = getattr(result, "error", None) 

127 code = getattr(result, "error_code", None) 

128 suffix = "" 

129 if err: 129 ↛ 131line 129 didn't jump to line 131 because the condition on line 129 was always true

130 suffix += f"; {err}" 

131 if code: 131 ↛ 133line 131 didn't jump to line 133 because the condition on line 131 was always true

132 suffix += f" (code={code})" 

133 raise WriterGenerationError(f"引擎返回异常状态: {result.status}{suffix}") 

134 return result.data 

135 

136 def generate_chapter(self, context: GenerationContext) -> ChapterContent: 

137 self._chunk_source(context.structured_source) # 分块可用性验证(真实拼回留待后续) 

138 

139 # 步骤 A:推导本章期望输出语言(仅依赖 context,循环前置,稳定) 

140 # fallback 用「规则文档」(write_rules/design_rules,RAG 自日文作成说明书/记入规则检索, 

141 # 含假名可判日文)。不能用 impact/data(源数据):样本含中文元素名(止损风控等)、 

142 # 影响标签为汉字无假名 → 会把日文文档误判为期望 zh(真实试运行暴露)。 

143 expected = resolve_expected_language( 

144 explicit=context.output_language, 

145 title=context.title, 

146 fallback_texts=[ 

147 "\n".join(context.write_rules or []), 

148 "\n".join(context.design_rules or []), 

149 ], 

150 ) 

151 

152 last_err: Exception | None = None 

153 for _ in range(max(1, self.max_retries)): 

154 try: 

155 data = self._call_llm(context) 

156 except Exception as e: # 引擎可能抛出任意异常,统一按章节级失败重试 

157 last_err = e 

158 continue 

159 try: 

160 content = ChapterContent.from_llm(context.chapter_id, context.title, data) 

161 except (KeyError, TypeError, ValueError) as e: 

162 last_err = e 

163 continue 

164 # 模板结构为准(design §6.5):无子节结构的章,剔除 LLM 自造的 

165 # heading 块(prompt 约束为尽力而为,此处程序化强制) 

166 if not (getattr(context.template_marker, "sub_headings", None) or []): 

167 content.blocks = [b for b in content.blocks if b.type != "heading"] 

168 # 步骤 A:输出语言一致性强制(期望语言可推导时,违规按失败重试) 

169 if expected: 

170 viol = find_language_violations(content.blocks, expected) 

171 if viol: 

172 last_err = WriterGenerationError( 

173 f"章节 {context.chapter_id} 语言不一致(期望 {expected}" 

174 f"发现 {len(viol)} 处违规正文)" 

175 ) 

176 continue 

177 self.state.record_success(content) 

178 return content 

179 raise WriterGenerationError(f"章节 {context.chapter_id} 重试耗尽: {last_err}")