Files
2026Technology-Competition/src/genesis/writer/writer_agent.py
T
lhl 3f4d17cf69 fix(writer): auto 模式语言 fallback 改用规则文档,修复日文文档被误判期望 zh
真实 ja 试运行暴露:auto 模式 fallback 误用 impact/data 源数据(含中文元素名
止损风控等 + 汉字标签无假名)→ 把日文文档误判为期望 zh → LLM 正常日文输出被
判违规 → 硬失败。修复:fallback 改用 write_rules/design_rules(RAG 自日文作成
说明书/记入规则检索,含假名可靠判 ja);加回归测试
test_generate_chapter_auto_with_ja_rules_but_chinese_source_data_enforces_ja。
修复后 ja 真实试运行成功(7章);zh 真实试运行成功(7章);425 passed / 99.15%
2026-08-26 00:36:54 +08:00

180 lines
8.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Writer Agent:调用推理引擎生成单章内容(Phase 5)。
接入真实 InferenceEngine.chat_structuredsession_id/prompt/variables/schema/retry_count),
并对章节级失败做有限重试;token 估算分块(真实拼回留待后续并发实现)。
"""
from __future__ import annotations
import asyncio
from genesis.inference.engine import InferenceEngine
from genesis.inference.prompt_registry import PromptRegistry
from genesis.inference.types import Prompt, StructuredResult
from genesis.writer.models import ChapterContent, GenerationContext
from genesis.writer.writer_state import WriterState
from genesis.writer.exceptions import WriterGenerationError
from genesis.writer.language import (
find_language_violations,
resolve_expected_language,
)
WRITER_PROMPT_TEMPLATE = (
"你是概要设计书撰写专家。\n"
"章节: {{chapter_id}} {{title}}\n"
"本章小节结构:\n{{sub_headings}}\n"
"写入规则:\n{{write_rules}}\n"
"设计规则:\n{{design_rules}}\n"
"模板样式:\n{{template_styles}}\n"
"影响调查上下文:\n{{impact}}\n"
"参考资料(本章对应数据):\n{{data}}\n"
"请输出符合 schema 的章节内容 JSON。\n"
"【小节约束】若上方「本章小节结构」非空,必须按该小节顺序组织内容,"
"每个小节以 type=heading、level=2 的内容块开头(标题使用小节原文),随后为该小节的内容块;"
"不得遗漏或新增小节。若「本章小节结构」为空,则不得输出任何 type=heading 的内容块"
"(章节标题已由模板提供),仅以 paragraph/table/list/note 块组织内容。\n"
"【主题约束】本章必须且仅围绕标题「{{title}}」所对应的主题撰写,"
"严格以「参考资料(本章对应数据)」中的要件定义数据和「影响调查上下文」为核心依据;"
"禁止输出与本章无关的系统整体架构、通用设计说明等内容,禁止套用其他章节的主题。"
"若本章数据为空,则基于规则与影响调查上下文简要撰写,不得虚构数据。\n"
"【语言约束】章节正文(所有 block 的 text 字段)所使用的自然语言:{{language_instruction}}\n"
)
CHAPTER_OUTPUT_SCHEMA = {
"type": "object",
"properties": {
"title": {"type": "string"},
"blocks": {
"type": "array",
"items": {
"type": "object",
"properties": {
"type": {"type": "string"},
"text": {"type": "string"},
"level": {"type": "integer"},
"headers": {"type": "array", "items": {"type": "string"}},
"rows": {"type": "array", "items": {"type": "array", "items": {"type": "string"}}},
"items": {"type": "array", "items": {"type": "string"}},
"source_uris": {"type": "array", "items": {"type": "string"}},
},
"required": ["type"],
},
},
},
"required": ["title", "blocks"],
}
class WriterAgent:
def __init__(
self,
session_id,
engine: InferenceEngine,
prompt_registry: PromptRegistry,
state: WriterState,
max_retries: int = 2,
) -> None:
self.session_id = session_id
self.engine = engine
self.prompt_registry = prompt_registry
self.state = state
self.max_retries = max_retries
def _chunk_source(self, source) -> list[dict]:
if source is None:
return [{"index": 0, "text": ""}]
src = source if isinstance(source, str) else str(source)
n = max(1, len(src) // 1800 + 1)
return [{"index": i, "text": src[i * 1800:(i + 1) * 1800]} for i in range(n)]
def _resolve_prompt(self) -> Prompt:
"""取用/注册 writer.chapter 模板,并保证返回 Prompt 对象。
优先使用 get_or_create(与测试 Fake 兼容);真实 PromptRegistry 无该方法时,
回退为 register + get。注意:真实 get() 返回模板字符串而非 Prompt,
而引擎 _render_prompt 仅对 Prompt 对象做变量渲染、str 原样发送
(否则 {{占位符}} 不被替换直接进 LLM)→ 此处统一包装为 Prompt。
"""
get_or_create = getattr(self.prompt_registry, "get_or_create", None)
if get_or_create is not None:
resolved = get_or_create("writer.chapter", WRITER_PROMPT_TEMPLATE)
else:
self.prompt_registry.register("writer.chapter", "1", WRITER_PROMPT_TEMPLATE)
resolved = self.prompt_registry.get("writer.chapter", "1")
if isinstance(resolved, Prompt):
return resolved
template = getattr(resolved, "template", None) or str(resolved)
version = str(getattr(resolved, "version", "1") or "1")
return Prompt(name="writer.chapter", version=version, template=template)
def _call_llm(self, context: GenerationContext) -> dict:
prompt = self._resolve_prompt()
result = self.engine.chat_structured(
session_id=self.session_id,
prompt=prompt,
variables=context.to_vars(),
schema=CHAPTER_OUTPUT_SCHEMA,
retry_count=2,
)
# 真实 InferenceEngine.chat_structured 为 async;测试用同步 FakeEngine 返回普通对象。
# 兼容两者:若返回协程则通过 asyncio.run 驱动(调用方 orchestrator/qa_loop/脚本均为同步上下文)。
if asyncio.iscoroutine(result):
result = asyncio.run(result)
if result.status not in ("ok", "fallback"):
# 透传底层错误详情(如 401 鉴权失败原因),便于人工门禁定位
err = getattr(result, "error", None)
code = getattr(result, "error_code", None)
suffix = ""
if err:
suffix += f"; {err}"
if code:
suffix += f" (code={code})"
raise WriterGenerationError(f"引擎返回异常状态: {result.status}{suffix}")
return result.data
def generate_chapter(self, context: GenerationContext) -> ChapterContent:
self._chunk_source(context.structured_source) # 分块可用性验证(真实拼回留待后续)
# 步骤 A:推导本章期望输出语言(仅依赖 context,循环前置,稳定)
# fallback 用「规则文档」(write_rules/design_rulesRAG 自日文作成说明书/记入规则检索,
# 含假名可判日文)。不能用 impact/data(源数据):样本含中文元素名(止损风控等)、
# 影响标签为汉字无假名 → 会把日文文档误判为期望 zh(真实试运行暴露)。
expected = resolve_expected_language(
explicit=context.output_language,
title=context.title,
fallback_texts=[
"\n".join(context.write_rules or []),
"\n".join(context.design_rules or []),
],
)
last_err: Exception | None = None
for _ in range(max(1, self.max_retries)):
try:
data = self._call_llm(context)
except Exception as e: # 引擎可能抛出任意异常,统一按章节级失败重试
last_err = e
continue
try:
content = ChapterContent.from_llm(context.chapter_id, context.title, data)
except (KeyError, TypeError, ValueError) as e:
last_err = e
continue
# 模板结构为准(design §6.5):无子节结构的章,剔除 LLM 自造的
# heading 块(prompt 约束为尽力而为,此处程序化强制)
if not (getattr(context.template_marker, "sub_headings", None) or []):
content.blocks = [b for b in content.blocks if b.type != "heading"]
# 步骤 A:输出语言一致性强制(期望语言可推导时,违规按失败重试)
if expected:
viol = find_language_violations(content.blocks, expected)
if viol:
last_err = WriterGenerationError(
f"章节 {context.chapter_id} 语言不一致(期望 {expected}"
f"发现 {len(viol)} 处违规正文)"
)
continue
self.state.record_success(content)
return content
raise WriterGenerationError(f"章节 {context.chapter_id} 重试耗尽: {last_err}")