309 lines
13 KiB
Python
309 lines
13 KiB
Python
"""Chapter brief aggregation and Chinese chapter assembly."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
from pathlib import Path
|
|
from typing import Callable
|
|
|
|
from scripts.runtime.roles import RoleDefinition, RuntimeProfile
|
|
from scripts.runtime.skills import SkillRegistry
|
|
from scripts.runtime.tasks import load_task_cards, validate_packet
|
|
from scripts.runtime.workers import ChatClient
|
|
|
|
|
|
def validate_chapter_brief(brief: dict) -> None:
|
|
required = {
|
|
"chapter_id",
|
|
"chapter_title",
|
|
"packet_ids",
|
|
"core_claims",
|
|
"evidence_items",
|
|
"counter_evidence",
|
|
"source_ids",
|
|
"open_questions",
|
|
"assembly_notes",
|
|
}
|
|
missing = sorted(required - set(brief))
|
|
if missing:
|
|
raise ValueError(f"chapter brief missing fields: {missing}")
|
|
if not brief["chapter_id"]:
|
|
raise ValueError("chapter_id required")
|
|
if not brief["packet_ids"]:
|
|
raise ValueError("chapter brief requires at least one packet")
|
|
if not brief["core_claims"]:
|
|
raise ValueError("chapter brief requires core_claims")
|
|
if not brief["evidence_items"]:
|
|
raise ValueError("chapter brief requires evidence_items")
|
|
if not brief["counter_evidence"]:
|
|
raise ValueError("chapter brief requires counter_evidence")
|
|
|
|
|
|
def validate_compressed_finding(finding: dict) -> None:
|
|
required = {
|
|
"chapter_id",
|
|
"chapter_title",
|
|
"packet_ids",
|
|
"chapter_thesis",
|
|
"key_findings",
|
|
"evidence_landings",
|
|
"counter_evidence",
|
|
"source_ids",
|
|
"open_questions",
|
|
"writing_plan",
|
|
}
|
|
missing = sorted(required - set(finding))
|
|
if missing:
|
|
raise ValueError(f"compressed finding missing fields: {missing}")
|
|
if not finding["chapter_id"]:
|
|
raise ValueError("chapter_id required")
|
|
if not finding["packet_ids"]:
|
|
raise ValueError("compressed finding requires packet_ids")
|
|
if not finding["chapter_thesis"]:
|
|
raise ValueError("compressed finding requires chapter_thesis")
|
|
if not finding["key_findings"]:
|
|
raise ValueError("compressed finding requires key_findings")
|
|
if not finding["evidence_landings"]:
|
|
raise ValueError("compressed finding requires evidence_landings")
|
|
if not finding["counter_evidence"]:
|
|
raise ValueError("compressed finding requires counter_evidence")
|
|
|
|
|
|
def validate_chapter_markdown_citations(markdown: str, brief: dict) -> None:
|
|
if "key_findings" in brief:
|
|
validate_compressed_finding(brief)
|
|
else:
|
|
validate_chapter_brief(brief)
|
|
cited = set(re.findall(r"\[(src_[A-Za-z0-9_-]+)\]", markdown))
|
|
allowed = set(brief.get("source_ids") or [])
|
|
unknown = sorted(cited - allowed)
|
|
if unknown:
|
|
raise ValueError(f"unknown citation ids in {brief['chapter_id']}: {unknown}")
|
|
|
|
|
|
def _chapter_title_from_id(chapter_id: str) -> str:
|
|
try:
|
|
index = int(chapter_id.replace("ch", ""))
|
|
return f"第{index}章"
|
|
except ValueError:
|
|
return chapter_id
|
|
|
|
|
|
def build_chapter_briefs(project_root: Path) -> list[dict]:
|
|
cards = load_task_cards(project_root / "phase2" / "task_cards.json")
|
|
grouped: dict[str, list[tuple[str, dict]]] = {}
|
|
for card in cards:
|
|
packet_path = project_root / card.output_packet
|
|
if not packet_path.exists():
|
|
continue
|
|
packet = json.loads(packet_path.read_text(encoding="utf-8"))
|
|
validate_packet(packet)
|
|
for chapter_id in card.chapter_ids:
|
|
grouped.setdefault(chapter_id, []).append((card.task_id, packet))
|
|
|
|
briefs: list[dict] = []
|
|
out_dir = project_root / "phase2" / "chapter_briefs"
|
|
out_dir.mkdir(parents=True, exist_ok=True)
|
|
for chapter_id in sorted(grouped):
|
|
packet_pairs = sorted(grouped[chapter_id], key=lambda item: item[0])
|
|
packet_ids = [item[0] for item in packet_pairs]
|
|
packets = [item[1] for item in packet_pairs]
|
|
source_ids = sorted({sid for packet in packets for sid in packet.get("source_ids", [])})
|
|
brief = {
|
|
"chapter_id": chapter_id,
|
|
"chapter_title": _chapter_title_from_id(chapter_id),
|
|
"packet_ids": packet_ids,
|
|
"core_claims": [claim for packet in packets for claim in packet.get("claims", [])],
|
|
"evidence_items": [item for packet in packets for item in packet.get("evidence_items", [])],
|
|
"counter_evidence": [item for packet in packets for item in packet.get("counter_evidence", [])],
|
|
"source_ids": source_ids,
|
|
"open_questions": [q for packet in packets for q in packet.get("open_questions", [])],
|
|
"assembly_notes": [
|
|
"用中文写正式章节,英文仅保留在必要的来源标题、原文摘录、DOI/URL 中。",
|
|
"避免碎片化:不要按 packet 逐段堆砌,要先提炼本章主线,再组织证据。",
|
|
"每个事实、数字和关键判断都必须保留 [src_xxx] 引用。",
|
|
"必须纳入 counter_evidence,并说明它如何影响结论置信度。",
|
|
],
|
|
}
|
|
validate_chapter_brief(brief)
|
|
(out_dir / f"{chapter_id}.json").write_text(
|
|
json.dumps(brief, ensure_ascii=False, indent=2) + "\n",
|
|
encoding="utf-8",
|
|
)
|
|
briefs.append(brief)
|
|
return briefs
|
|
|
|
|
|
def _source_ids_from_item(item: dict) -> list[str]:
|
|
if item.get("source_ids"):
|
|
return list(item.get("source_ids") or [])
|
|
if item.get("source_id"):
|
|
return [item["source_id"]]
|
|
return []
|
|
|
|
|
|
def build_compressed_findings(project_root: Path) -> list[dict]:
|
|
"""Compress packet-level evidence into chapter-level writing inputs.
|
|
|
|
This is intentionally deterministic: it does not invent a better narrative,
|
|
but it forces a chapter-level evidence map before any model writes prose.
|
|
"""
|
|
brief_dir = project_root / "phase2" / "chapter_briefs"
|
|
if not brief_dir.exists() or not list(brief_dir.glob("ch*.json")):
|
|
briefs = build_chapter_briefs(project_root)
|
|
else:
|
|
briefs = [
|
|
json.loads(path.read_text(encoding="utf-8"))
|
|
for path in sorted(brief_dir.glob("ch*.json"))
|
|
]
|
|
out_dir = project_root / "phase2" / "compressed_findings"
|
|
out_dir.mkdir(parents=True, exist_ok=True)
|
|
findings: list[dict] = []
|
|
for brief in briefs:
|
|
validate_chapter_brief(brief)
|
|
core_claims = brief.get("core_claims") or []
|
|
evidence_items = brief.get("evidence_items") or []
|
|
first_claim = core_claims[0] if core_claims else {}
|
|
chapter_thesis = first_claim.get("claim") or f"{brief['chapter_title']} 需要以证据为中心重写。"
|
|
finding = {
|
|
"chapter_id": brief["chapter_id"],
|
|
"chapter_title": brief["chapter_title"],
|
|
"packet_ids": brief["packet_ids"],
|
|
"chapter_thesis": chapter_thesis,
|
|
"key_findings": [
|
|
{
|
|
"finding": claim.get("claim") or claim.get("summary") or str(claim),
|
|
"source_ids": _source_ids_from_item(claim),
|
|
"confidence": claim.get("confidence", "medium"),
|
|
}
|
|
for claim in core_claims
|
|
],
|
|
"evidence_landings": [
|
|
{
|
|
"evidence": item.get("summary") or item.get("finding") or item.get("quote") or str(item),
|
|
"source_ids": _source_ids_from_item(item),
|
|
"landing_hint": item.get("landing_hint", "用于支撑本章关键判断或整改动作。"),
|
|
}
|
|
for item in evidence_items
|
|
],
|
|
"counter_evidence": brief["counter_evidence"],
|
|
"source_ids": brief["source_ids"],
|
|
"open_questions": brief["open_questions"],
|
|
"writing_plan": [
|
|
"先写本章判断,不按 packet 顺序堆砌。",
|
|
"每个二级小节至少落下具体审计发现、法规要求、记录/参数或整改证据。",
|
|
"正文末尾必须保留“证据落点与待补证据”表。",
|
|
],
|
|
}
|
|
validate_compressed_finding(finding)
|
|
(out_dir / f"{brief['chapter_id']}.json").write_text(
|
|
json.dumps(finding, ensure_ascii=False, indent=2) + "\n",
|
|
encoding="utf-8",
|
|
)
|
|
findings.append(finding)
|
|
return findings
|
|
|
|
|
|
def build_chapter_user_prompt(brief: dict) -> str:
|
|
return (
|
|
"请根据以下 compressed finding / chapter brief 写一章正式中文 Markdown 正文。\n"
|
|
"目标是形成一个完整章节,而不是 packet 摘要。避免碎片化,按金字塔结构组织:章首先给结论,再用证据支撑。\n"
|
|
"要求:标题必须是观点型判断;每个数字和事实保留 [src_xxx];纳入反方证据;不要出现调度元数据。\n"
|
|
"禁止写空泛咨询腔。每个二级小节都必须至少落下 2 个具体审计发现、法规要求、SOP/记录/参数/现场观察或整改证据;不要只写原则。\n"
|
|
"正文末尾必须增加“证据落点与待补证据”小节,用表格列出:关键判断、已使用证据 source_id、已落地整改动作、仍缺证据。若证据不足,直接标注需回炉 Phase 2,不要用泛泛表述补齐。\n"
|
|
"只输出 Markdown,不要输出解释。\n\n"
|
|
f"{json.dumps(brief, ensure_ascii=False, indent=2)}"
|
|
)
|
|
|
|
|
|
class ChapterAssemblyWorker:
|
|
def __init__(
|
|
self,
|
|
*,
|
|
role: RoleDefinition,
|
|
client: ChatClient,
|
|
skill_registry: SkillRegistry | None = None,
|
|
) -> None:
|
|
self.role = role
|
|
self.client = client
|
|
self.skill_registry = skill_registry or SkillRegistry()
|
|
|
|
def _system_prompt(self) -> str:
|
|
skill_texts = []
|
|
for name in self.role.skills:
|
|
try:
|
|
skill_texts.append(f"# Skill: {name}\n\n{self.skill_registry.read(name)}")
|
|
except FileNotFoundError:
|
|
skill_texts.append(f"# Skill: {name}\n\n[missing skill: {name}]")
|
|
return (
|
|
"你是 Deep Research v0.20 的中文章节组装 worker。\n"
|
|
"你的职责是把结构化证据包收束成连贯章节,解决并发研究造成的碎片化。\n"
|
|
"不得编造来源,不得删除关键反方证据。\n\n"
|
|
+ "\n\n".join(skill_texts)
|
|
)
|
|
|
|
def write_chapter(self, *, project_root: Path, brief: dict) -> Path:
|
|
if "key_findings" in brief:
|
|
validate_compressed_finding(brief)
|
|
else:
|
|
validate_chapter_brief(brief)
|
|
markdown = self.client.chat_complete(
|
|
model=self.role.model,
|
|
system=self._system_prompt(),
|
|
user=build_chapter_user_prompt(brief),
|
|
temperature=self.role.temperature,
|
|
max_tokens=self.role.max_tokens,
|
|
tag=f"chapter:{brief['chapter_id']}",
|
|
)
|
|
validate_chapter_markdown_citations(markdown, brief)
|
|
out = project_root / "phase2" / "drafts" / f"{brief['chapter_id']}.md"
|
|
out.parent.mkdir(parents=True, exist_ok=True)
|
|
out.write_text(markdown.rstrip() + "\n", encoding="utf-8")
|
|
return out
|
|
|
|
|
|
def _write_chapter_error(project_root: Path, brief: dict, error: Exception) -> None:
|
|
path = project_root / "phase2" / "chapter_errors" / f"{brief.get('chapter_id', 'unknown')}.json"
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
payload = {
|
|
"chapter_id": brief.get("chapter_id"),
|
|
"status": "failed",
|
|
"error": str(error),
|
|
}
|
|
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
|
|
|
|
def run_chapter_assembly_workers(
|
|
*,
|
|
project_root: Path,
|
|
briefs: list[dict],
|
|
runtime: RuntimeProfile,
|
|
client_factory: Callable[[RoleDefinition], ChatClient],
|
|
workers: int,
|
|
) -> int:
|
|
role = runtime.role_for_task("chapter_assembly")
|
|
max_workers = max(1, min(workers, role.max_concurrency))
|
|
|
|
def run_one(brief: dict) -> tuple[dict, Path | None, Exception | None]:
|
|
try:
|
|
worker = ChapterAssemblyWorker(role=role, client=client_factory(role))
|
|
return brief, worker.write_chapter(project_root=project_root, brief=brief), None
|
|
except Exception as error:
|
|
return brief, None, error
|
|
|
|
written = 0
|
|
with ThreadPoolExecutor(max_workers=max_workers) as pool:
|
|
futures = [pool.submit(run_one, brief) for brief in briefs]
|
|
for future in as_completed(futures):
|
|
brief, path, error = future.result()
|
|
if error is not None:
|
|
_write_chapter_error(project_root, brief, error)
|
|
continue
|
|
if path is None:
|
|
_write_chapter_error(project_root, brief, RuntimeError("chapter worker returned no output path"))
|
|
continue
|
|
written += 1
|
|
return written
|