feat(writer): 输出语言一致性保障 + 用户可选输出语言

- 步骤0: 新增中文镜像模板 scripts/make_zh_template.py 与 samples/概要设计书模板_中文.docx(7章锚点原样保留)
- 步骤1: config.WriterConfig.output_language→Settings.writer;GenerationContext.output_language + to_vars.language_instruction(zh/ja/auto);writer_agent【语言约束】改引变量;context_builder/orchestrator/run_trial 透传 --output-language
- 步骤A: 新建 src/genesis/writer/language.py(detect_script/resolve_expected_language/find_language_violations);WriterAgent.generate_chapter 按期望语言强制、违规重试、耗尽硬失败;max_retries 默认 1→2
- 步骤B: _format_impact 影响调查标签按 output_language 本地化(zh 新建/变更/删除/警告)
- 步骤C: eval scorer 第 11 维度 language_consistency(不可验证=满分,不拉低总分);ChapterArtifact.expected_language;QAValidator.validate_doc 透传;QALoop.run 透传 output_language
- 测试: test_zh_template/test_language_plumbing/test_writer_language/test_scorer_language/test_language_coverage,并更新 test_phase5_e2e
- 全量 pytest 424 passed / 99.15%(覆盖率门槛 99% 达标)
This commit is contained in:
lhl
2026-08-25 23:30:24 +08:00
parent 9d0d3409c2
commit d01e1b720f
20 changed files with 1049 additions and 31 deletions
+15 -1
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
import os
from pathlib import Path
from typing import Any
from typing import Any, Literal
import yaml
from pydantic import BaseModel, Field
@@ -127,6 +127,19 @@ class RagConfig(BaseModel):
rerank: RerankConfig = Field(default_factory=RerankConfig)
class WriterConfig(BaseModel):
"""Writer 子系统配置(步骤 1:输出语言参数)。
output_language: 生成概要设计书正文的自然语言
- "auto":与章节标题所用语言保持一致(默认,向后兼容既有日文文档)
- "zh":强制简体中文
- "ja":强制日文
表格数据始终照抄源 Excel 原文(不翻译),见 design.md §7.2。
"""
output_language: Literal["auto", "zh", "ja"] = "auto"
# ---------- 加载辅助 ----------
def _expand_env(data: Any) -> Any:
@@ -192,6 +205,7 @@ class Settings(BaseSettings):
app: AppConfig = Field(default_factory=AppConfig)
inference: InferenceConfig = Field(default_factory=InferenceConfig)
rag: RagConfig = Field(default_factory=RagConfig)
writer: WriterConfig = Field(default_factory=WriterConfig)
@classmethod
def from_dir(cls, config_dir: Path | str) -> "Settings":
+45
View File
@@ -34,6 +34,9 @@ class ChapterArtifact:
text: str
source_uris: list[str]
template_sections_expected: list[str]
expected_language: str = "" # 期望输出语言("zh"/"ja";空=不可验证,维度记满分)
# 块级 (type, text) 列表:供语言一致性维度排除 heading/table(照抄源/跟随模板)
blocks: list[tuple[str, str]] = field(default_factory=list)
# 逐章评估通过阈值(基于逐章总分)
@@ -56,6 +59,7 @@ DEFAULT_THRESHOLDS: dict[str, float] = {
"traceability": 1.0,
"placeholder_residue": 1.0,
"chapter_completeness": 1.0,
"language_consistency": 1.0,
}
@@ -75,6 +79,7 @@ class ChapterScorer:
dimensions.append(self._traceability(chapters, source))
dimensions.append(self._placeholder_residue(chapters))
dimensions.append(self._completeness(chapters))
dimensions.append(self._language_consistency(chapters))
# LLM 语义维度钩子(每个章节独立评,取该维度平均)
for name, fn in self.llm_evaluators.items():
@@ -144,6 +149,45 @@ class ChapterScorer:
detail = " | ".join(s.detail for s in scores) if scores else "no chapters"
return DimensionScore(name, round(avg, 4), all(s.passed for s in scores), detail)
# ---------- 语言一致性维度(步骤 C,确定性)----------
def _language_consistency(self, chapters: list[ChapterArtifact]) -> DimensionScore:
"""第 11 维度:输出语言与期望语言一致(确定性,脚本可验证)。
与 writer.language 共用单一检测事实来源。期望语言为空(auto/不可验证)
→ 记满分 1.0 通过(评审 R1:不拉低总分,避免误伤既有断言)。
仅检正文块(heading/table 不检,表格照抄源、标题跟随模板)。
"""
from genesis.writer.language import find_language_violations
if not chapters:
return DimensionScore("language_consistency", 1.0, True, "no chapters")
per: list[DimensionScore] = []
for ch in chapters:
expected = ch.expected_language
if not expected:
per.append(DimensionScore(
"language_consistency", 1.0, True, "unverifiable (no expected language)"))
continue
from genesis.writer.models import ContentBlock
if ch.blocks:
# 优先用块级信息(可排除 heading/table
blocks = [ContentBlock(block_id=str(i), type=t, text=tx)
for i, (t, tx) in enumerate(ch.blocks)]
else:
# 回退:整段正文作为单个 paragraph 块
blocks = [ContentBlock(block_id="0", type="paragraph", text=ch.text or "")]
viol = find_language_violations(blocks, expected)
score = 0.0 if viol else 1.0
passed = score >= self.thresholds["language_consistency"]
per.append(DimensionScore(
"language_consistency", score, passed,
f"期望 {expected},违规 {len(viol)}" if viol else f"期望 {expected},一致"))
avg = sum(p.score for p in per) / len(per)
passed = all(p.passed for p in per)
detail = " | ".join(p.detail for p in per)
return DimensionScore("language_consistency", round(avg, 4), passed, detail)
# ---------- 逐章评估 ----------
def _score_chapter(self, chapter: ChapterArtifact, source: StructuredSource) -> EvalReport:
@@ -156,6 +200,7 @@ class ChapterScorer:
self._traceability([chapter], source),
self._placeholder_residue([chapter]),
self._adequacy(chapter),
self._language_consistency([chapter]),
]
total = sum(d.score for d in dims) / len(dims) if dims else 0.0
failed = (not all(d.passed for d in dims)) or total < PASS_THRESHOLD
+9 -6
View File
@@ -21,8 +21,8 @@ class QALoop:
def __init__(self, max_rounds: int = DEFAULT_MAX_QA_ROUNDS) -> None:
self.controller = QALoopController(max_rounds=max_rounds)
def _build(self, structured_source, samples_dir, engine, prompt_registry, template_path, output_path, session_id, only_ids=None, prev=None):
ctxs = build_contexts(structured_source, samples_dir)
def _build(self, structured_source, samples_dir, engine, prompt_registry, template_path, output_path, session_id, only_ids=None, prev=None, output_language: str = "auto"):
ctxs = build_contexts(structured_source, samples_dir, output_language=output_language)
_warn_unanchored(ctxs)
state = WriterState([c.chapter_id for c in ctxs])
agent = WriterAgent(session_id=session_id, engine=engine, prompt_registry=prompt_registry, state=state)
@@ -47,12 +47,14 @@ class QALoop:
doc.save(output_path)
return [contents_map[cid] for cid in order]
def run(self, structured_source, output_path, session_id="writer", samples_dir="samples", engine=None, prompt_registry=None, template_path=None) -> QAReport:
def run(self, structured_source, output_path, session_id="writer", samples_dir="samples", engine=None, prompt_registry=None, template_path=None, output_language: str = "auto") -> QAReport:
engine = engine or build_inference_engine()
prompt_registry = prompt_registry or PromptRegistry()
validator = QAValidator()
contents = self._build(structured_source, samples_dir, engine, prompt_registry, template_path, output_path, session_id)
report = validator.validate_doc(contents, structured_source)
# auto 不可推导期望语言 → 语言维度记满分(unverifiable);zh/ja 显式强制
expected = output_language if output_language in ("zh", "ja") else ""
contents = self._build(structured_source, samples_dir, engine, prompt_registry, template_path, output_path, session_id, output_language=output_language)
report = validator.validate_doc(contents, structured_source, expected_language=expected)
while self.controller.can_continue() and report.failed_chapters:
self.controller.advance()
contents = self._build(
@@ -65,6 +67,7 @@ class QALoop:
session_id,
only_ids=set(report.failed_chapters),
prev={c.chapter_id: c for c in contents},
output_language=output_language,
)
report = validator.validate_doc(contents, structured_source)
report = validator.validate_doc(contents, structured_source, expected_language=expected)
return report
+20 -8
View File
@@ -10,8 +10,12 @@ class QAValidator:
def __init__(self, scorer: ChapterScorer | None = None) -> None:
self.scorer = scorer or ChapterScorer()
def _to_artifact(self, content: ChapterContent) -> ChapterArtifact:
"""把 ChapterContent 转换为评分器所需的 ChapterArtifact(聚合正文与来源 URI)。"""
def _to_artifact(self, content: ChapterContent, expected_language: str = "") -> ChapterArtifact:
"""把 ChapterContent 转换为评分器所需的 ChapterArtifact(聚合正文与来源 URI)。
expected_language:期望输出语言("zh"/"ja";空=不可验证,维度记满分)。
blocks 保留 (type, text) 供语言维度排除 heading/table。
"""
text = "".join(b.text or "" for b in content.blocks)
source_uris: list[str] = []
for b in content.blocks:
@@ -21,18 +25,26 @@ class QAValidator:
text=text,
source_uris=source_uris,
template_sections_expected=[],
expected_language=expected_language,
blocks=[(b.type, b.text or "") for b in content.blocks],
)
def validate_chapter(self, content: ChapterContent, structured_source) -> tuple[bool, EvalReport]:
report = self.scorer.score([self._to_artifact(content)], structured_source)
def validate_chapter(
self, content: ChapterContent, structured_source, expected_language: str = ""
) -> tuple[bool, EvalReport]:
report = self.scorer.score([self._to_artifact(content, expected_language)], structured_source)
passed = content.chapter_id not in report.failed_chapters
return passed, report
def validate_document(self, contents: list[ChapterContent], structured_source) -> list[tuple[bool, EvalReport]]:
return [self.validate_chapter(c, structured_source) for c in contents]
def validate_document(
self, contents: list[ChapterContent], structured_source, expected_language: str = ""
) -> list[tuple[bool, EvalReport]]:
return [self.validate_chapter(c, structured_source, expected_language) for c in contents]
def validate_doc(self, contents: list[ChapterContent], structured_source) -> QAReport:
results = self.validate_document(contents, structured_source)
def validate_doc(
self, contents: list[ChapterContent], structured_source, expected_language: str = ""
) -> QAReport:
results = self.validate_document(contents, structured_source, expected_language)
passed_flags = [ok for ok, _ in results]
reports = [rep for _, rep in results]
failed = [c.chapter_id for c, ok in zip(contents, passed_flags) if not ok]
+6 -1
View File
@@ -7,7 +7,11 @@ from genesis.writer.models import ChapterSpec, GenerationContext
from genesis.writer.template_mapper import map_template
def build_contexts(structured_source: StructuredSource, samples_dir: str = "samples") -> list[GenerationContext]:
def build_contexts(
structured_source: StructuredSource,
samples_dir: str = "samples",
output_language: str = "auto",
) -> list[GenerationContext]:
specs: list[ChapterSpec] = map_template(structured_source.template)
rag = CannedRagService(samples_dir=samples_dir)
out: list[GenerationContext] = []
@@ -27,6 +31,7 @@ def build_contexts(structured_source: StructuredSource, samples_dir: str = "samp
template_styles=set(used),
prior_state=None,
impact_report=getattr(structured_source, "impact_report", None),
output_language=output_language,
)
)
return out
+104
View File
@@ -0,0 +1,104 @@
"""输出语言确定性检测与强制(步骤 A)。
设计要点:
- 日文标题多为纯汉字(如「DB設計」无假名),仅凭标题无法判定期望语言;
故 resolve_expected_language 采用两级推导:显式 > 标题假名 > 规则文档主导脚本。
- 检测仅基于「是否含日文假名」:CJK 汉字零假名视为中文(日文不可能不含假名地
使用汉字),反之中日混排含假名判日文。这是确定可机器验证的唯一稳健信号。
- find_language_violations 仅检正文类块(paragraph/note/list);heading 跟随模板、
table 照抄源 Excel 原文,二者均不检(design.md §7.2 内容准确性/可追溯性)。
- 短文本(<12 字)不误杀(如专有术语),阈值见 MIN_VIOLATION_LEN。
"""
from __future__ import annotations
from genesis.writer.models import ContentBlock
# 日文假名 Unicode 区间
_HIRAGANA = (0x3040, 0x309F)
_KATAKANA = (0x30A0, 0x30FF)
# 中日韩统一表意文字(CJK 汉字)
_CJK = (0x4E00, 0x9FFF)
# 受检的正文块类型(heading/table 不检)
_CHECKED_BLOCK_TYPES = {"paragraph", "note", "list"}
# 触发违规判定的最小正文长度(防短术语误杀)
MIN_VIOLATION_LEN = 12
def _in_range(ch: str, lo: int, hi: int) -> bool:
cp = ord(ch)
return lo <= cp <= hi
def has_kana(text: str) -> bool:
"""文本是否含日文假名(平假名/片假名)。"""
return any(_in_range(c, *_HIRAGANA) or _in_range(c, *_KATAKANA) for c in text)
def has_cjk(text: str) -> bool:
"""文本是否含 CJK 汉字。"""
return any(_in_range(c, *_CJK) for c in text)
def detect_script(text: str) -> str | None:
"""检测文本主导自然语言。
含假名 → "ja";含 CJK 汉字但零假名 → "zh";二者皆非(纯 ASCII 等)→ None。
"""
if not text:
return None
if has_kana(text):
return "ja"
if has_cjk(text):
return "zh"
return None
def resolve_expected_language(
explicit: str,
title: str = "",
fallback_texts: tuple[str, ...] | list[str] = (),
) -> str:
"""推导本章期望输出语言(单一事实来源,A 的重试校验与 C 的 QA 维度共用)。
- explicit 为 "zh"/"ja" → 直接采用(用户显式选择优先)
- 否则看标题是否含假名(仅假名可可靠判为日文;纯汉字标题对中/日均可能,不可信)
- 否则看 fallback_texts(如影响调查书/章节数据,通常日文)的主导脚本
- 均无法推导 → 返回 ""(不可验证,交由上层按 unverifiable 处理)
"""
if explicit in ("zh", "ja"):
return explicit
# 标题仅当含假名时可靠指示日文;纯汉字/ASCII 标题跳过,改看 fallback
if has_kana(title or ""):
return "ja"
for text in fallback_texts:
s = detect_script(text or "")
if s:
return s
return ""
def find_language_violations(blocks: list[ContentBlock], expected_language: str) -> list[str]:
"""返回违规正文块文本片段(期望语言非空时才有意义)。
违规判定:
- 期望 "ja":正文块含 CJK 汉字且零假名(即纯中文)且长度 ≥ 阈值
- 期望 "zh":正文块含日文假名
heading/table 块始终跳过(跟随模板 / 照抄源)。
"""
if expected_language not in ("zh", "ja"):
return []
violations: list[str] = []
for b in blocks:
if b.type not in _CHECKED_BLOCK_TYPES:
continue
text = b.text or ""
if len(text) < MIN_VIOLATION_LEN:
continue
if expected_language == "ja":
if has_cjk(text) and not has_kana(text):
violations.append(text)
else: # zh
if has_kana(text):
violations.append(text)
return violations
+40 -5
View File
@@ -109,6 +109,19 @@ class GenerationContext:
template_styles: set[str]
prior_state: object | None = None # WriterState,避免循环 import 用 object
impact_report: object | None = None # ImpactReport 影响调查书(生成主上下文)
output_language: str = "auto" # "auto" | "zh" | "ja"(步骤 1:用户可选输出语言)
def _language_instruction(self) -> str:
"""根据 output_language 生成【语言约束】段的具体指令(步骤 1)。"""
if self.output_language == "zh":
return "必须使用简体中文撰写(标题、正文与所有说明一律中文)。"
if self.output_language == "ja":
return "必ず日本語で記述すること(タイトル・本文・すべての説明は日本語)。"
# auto:沿用与标题语言一致的旧语义(向后兼容既有日文文档)
return (
f"必须与章节标题「{self.title}」所用自然语言保持一致:"
"标题为日文则用日文撰写,为中文则用中文撰写,依此类推。"
)
def to_vars(self) -> dict:
"""返回供 prompt 渲染的变量字典。"""
@@ -125,6 +138,7 @@ class GenerationContext:
f"- {h}" for h in (getattr(tm, "sub_headings", None) or [])
),
"prior_state": str(self.prior_state) if self.prior_state is not None else "",
"language_instruction": self._language_instruction(),
"data": _format_chapter_data(
self.structured_source,
CHAPTER_SHEET_TYPES.get(self.chapter_id, []),
@@ -132,6 +146,7 @@ class GenerationContext:
"impact": _format_impact(
self.impact_report,
CHAPTER_IMPACT_ELEMENT.get(self.chapter_id, None),
self.output_language,
),
}
@@ -177,11 +192,30 @@ def _format_chapter_data(structured_source: object | None, sheet_types: list[She
return "\n".join(lines).rstrip()
def _format_impact(report: object | None, element_type: ElementType | None = None) -> str:
# 影响调查标签本地化(步骤 B):auto/ja 默认日文,zh 中文
# 注:方括号标记 [..] 保留(中日通用),仅标签词本地化
_IMPACT_LABELS: dict[str, dict[str, str]] = {
"zh": {
"new": "新建", "modified": "变更", "deleted": "删除", "warning": "警告",
"affected": "受影响",
},
"ja": {
"new": "新規", "modified": "変更", "deleted": "削除", "warning": "警告",
"affected": "受影响",
},
}
def _format_impact(
report: object | None,
element_type: ElementType | None = None,
output_language: str = "auto",
) -> str:
"""将影响调查书格式化为 prompt 可读文本(无报告/无分析时为空串)。
element_type 非 None 时仅保留该类型要素(章节级定向,design.md §6.8 ①);
警告始终保留(不依赖要素类型)。
output_language 控制标签语言(步骤 B);auto 回落到 ja 标签。
"""
if report is None:
return ""
@@ -192,6 +226,7 @@ def _format_impact(report: object | None, element_type: ElementType | None = Non
def keep(el) -> bool:
return element_type is None or el.element_type == element_type.value
lab = _IMPACT_LABELS.get(output_language, _IMPACT_LABELS["ja"])
summary = getattr(report, "summary", {}) or {}
lines = [f"project_type={getattr(ca, 'project_type', '')}"]
lines.append(
@@ -204,15 +239,15 @@ def _format_impact(report: object | None, element_type: ElementType | None = Non
)
for el in getattr(ca, "new_elements", []) or []:
if keep(el):
lines.append(f"[新規] {el.element_id} {el.element_type} {el.name}")
lines.append(f"[{lab['new']}] {el.element_id} {el.element_type} {el.name}")
for el in getattr(ca, "modified_elements", []) or []:
if keep(el):
impacted = ", ".join(el.impacted_existing) or "-"
lines.append(f"[変更] {el.element_id} {el.element_type} {el.name}受影响: {impacted}")
lines.append(f"[{lab['modified']}] {el.element_id} {el.element_type} {el.name}{lab['affected']}: {impacted}")
for el in getattr(ca, "deleted_elements", []) or []:
if keep(el):
impacted = ", ".join(el.impacted_existing) or "-"
lines.append(f"[削除] {el.element_id} {el.element_type} {el.name}受影响: {impacted}")
lines.append(f"[{lab['deleted']}] {el.element_id} {el.element_type} {el.name}{lab['affected']}: {impacted}")
for w in getattr(ca, "warnings", []) or []:
lines.append(f"[警告] {w.element_id}: {w.issue}")
lines.append(f"[{lab['warning']}] {w.element_id}: {w.issue}")
return "\n".join(lines)
+2 -1
View File
@@ -56,6 +56,7 @@ class WriteOrchestrator:
template_path: str | None = None,
impact_report=None,
meta: dict | None = None,
output_language: str = "auto",
) -> list[ChapterContent]:
engine = engine or build_inference_engine()
prompt_registry = prompt_registry or PromptRegistry()
@@ -66,7 +67,7 @@ class WriteOrchestrator:
if impact_report is not None:
# 回填 structured_source,便于 QA/日志/后续下载
structured_source.impact_report = impact_report
ctxs = build_contexts(structured_source, samples_dir)
ctxs = build_contexts(structured_source, samples_dir, output_language=output_language)
_warn_unanchored(ctxs)
state = WriterState([c.chapter_id for c in ctxs])
agent = WriterAgent(session_id=session_id, engine=engine, prompt_registry=prompt_registry, state=state)
+26 -5
View File
@@ -13,6 +13,10 @@ 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 = (
@@ -33,10 +37,9 @@ WRITER_PROMPT_TEMPLATE = (
"严格以「参考资料(本章对应数据)」中的要件定义数据和「影响调查上下文」为核心依据;"
"禁止输出与本章无关的系统整体架构、通用设计说明等内容,禁止套用其他章节的主题。"
"若本章数据为空,则基于规则与影响调查上下文简要撰写,不得虚构数据。\n"
"【语言约束】章节正文(所有 block 的 text 字段)所使用的自然语言"
"必须与章节标题「{{title}}」所用语言保持一致:标题为日文则用日文撰写,"
"为中文则用中文撰写,依此类推。"
)
"【语言约束】章节正文(所有 block 的 text 字段)所使用的自然语言{{language_instruction}}\n"
)
CHAPTER_OUTPUT_SCHEMA = {
"type": "object",
@@ -70,7 +73,7 @@ class WriterAgent:
engine: InferenceEngine,
prompt_registry: PromptRegistry,
state: WriterState,
max_retries: int = 1,
max_retries: int = 2,
) -> None:
self.session_id = session_id
self.engine = engine
@@ -132,6 +135,15 @@ class WriterAgent:
def generate_chapter(self, context: GenerationContext) -> ChapterContent:
self._chunk_source(context.structured_source) # 分块可用性验证(真实拼回留待后续)
# 步骤 A:推导本章期望输出语言(仅依赖 context,循环前置,稳定)
v = context.to_vars()
expected = resolve_expected_language(
explicit=context.output_language,
title=context.title,
fallback_texts=[v.get("impact", "") or "", v.get("data", "") or ""],
)
last_err: Exception | None = None
for _ in range(max(1, self.max_retries)):
try:
@@ -148,6 +160,15 @@ class WriterAgent:
# 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}")