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
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-26 14:20 +0800
1"""Writer Agent:调用推理引擎生成单章内容(Phase 5)。
3接入真实 InferenceEngine.chat_structured(session_id/prompt/variables/schema/retry_count),
4并对章节级失败做有限重试;token 估算分块(真实拼回留待后续并发实现)。
5"""
6from __future__ import annotations
8import asyncio
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)
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 )
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}
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
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)]
91 def _resolve_prompt(self) -> Prompt:
92 """取用/注册 writer.chapter 模板,并保证返回 Prompt 对象。
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)
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
136 def generate_chapter(self, context: GenerationContext) -> ChapterContent:
137 self._chunk_source(context.structured_source) # 分块可用性验证(真实拼回留待后续)
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 )
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}")