"""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 _load_source_registry(sources_path: Path, source_ids: list[str]) -> list[dict]: wanted = set(source_ids) if not sources_path.exists() or not wanted: return [] rows: list[dict] = [] for line in sources_path.read_text(encoding="utf-8").splitlines(): if not line.strip(): continue try: row = json.loads(line) except json.JSONDecodeError: continue if row.get("id") in wanted: rows.append(row) return rows def _cached_source_excerpts(project_root: Path, cached_paths: list[str], *, max_sources: int = 5, max_chars: int = 1400) -> list[dict]: excerpts: list[dict] = [] for rel in cached_paths[:max_sources]: path = project_root / rel if not path.exists(): continue text = path.read_text(encoding="utf-8", errors="ignore").strip() excerpts.append({"path": rel, "excerpt": text[:max_chars]}) return excerpts 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]]] = {} skipped_packets: list[dict[str, str]] = [] for card in cards: packet_path = project_root / card.output_packet if not packet_path.exists(): skipped_packets.append({"task_id": card.task_id, "reason": "packet file missing"}) continue packet = json.loads(packet_path.read_text(encoding="utf-8")) try: validate_packet(packet) except Exception as exc: skipped_packets.append({"task_id": card.task_id, "reason": str(exc)}) continue 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) if skipped_packets: (project_root / "phase2" / "brief_warnings.json").write_text( json.dumps(skipped_packets, ensure_ascii=False, indent=2) + "\n", encoding="utf-8", ) for chapter_id in sorted(grouped): packet_pairs = grouped[chapter_id] 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", [])}) source_registry = _load_source_registry(project_root / "phase2" / "sources.jsonl", source_ids) cached_paths = [ source["cached_text_path"] for source in source_registry if source.get("cached_text_path") ] chapter_title = next((card.chapter_title for card in cards if chapter_id in card.chapter_ids and card.chapter_title), None) brief = { "chapter_id": chapter_id, "chapter_title": chapter_title or _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, "cached_source_paths": cached_paths, "cached_source_excerpts": _cached_source_excerpts(project_root, cached_paths), "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"], "cached_source_paths": brief.get("cached_source_paths", []), "cached_source_excerpts": brief.get("cached_source_excerpts", []), "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" "如 brief 中包含 cached_source_paths,说明这些是已抓取到本地的核心一手/权威信源快照;优先使用 packet 已摘录的原文,并在证据不足时标记需要从本地快照补摘录,不要重新联网检索。\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 ( f"{self.role.identity}\n\n" "你是 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