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:
+15
-1
@@ -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":
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}")
|
||||
|
||||
Reference in New Issue
Block a user