"""Phase 3 review checks for the Python core.""" from __future__ import annotations import json import re from datetime import datetime, timezone from pathlib import Path from typing import Any from scripts.runtime.artifacts import load_manifest, write_manifest from scripts.runtime.tasks import validate_packet def utc_now_iso() -> str: return datetime.now(timezone.utc).replace(microsecond=0).isoformat() def _source_ids_from_jsonl(path: Path) -> set[str]: ids: set[str] = set() if not path.exists(): return ids for line in path.read_text(encoding="utf-8").splitlines(): if not line.strip(): continue try: obj = json.loads(line) except json.JSONDecodeError: continue source_id = obj.get("id") or obj.get("source_id") if source_id: ids.add(str(source_id)) return ids def _draft_citations(drafts: list[Path]) -> set[str]: cited: set[str] = set() for draft in drafts: cited.update(re.findall(r"\[(src_[A-Za-z0-9_-]+)\]", draft.read_text(encoding="utf-8"))) return cited def _ready_packet_stems(project_root: Path) -> set[str]: ready: set[str] = set() for path in sorted((project_root / "phase2" / "packets").glob("*.json")): try: packet = json.loads(path.read_text(encoding="utf-8")) validate_packet(packet) except Exception: continue ready.add(path.stem) return ready def _read_text_if_exists(path: Path, *, max_chars: int | None = None) -> str: if not path.exists(): return "" text = path.read_text(encoding="utf-8", errors="ignore") return text[:max_chars] if max_chars is not None else text def _json_if_exists(path: Path, *, max_chars: int | None = None) -> str: if not path.exists(): return "" try: data = json.loads(path.read_text(encoding="utf-8")) text = json.dumps(data, ensure_ascii=False, indent=2) except Exception: text = path.read_text(encoding="utf-8", errors="ignore") return text[:max_chars] if max_chars is not None else text def _draft_quality_findings(drafts: list[Path]) -> list[dict[str, Any]]: findings: list[dict[str, Any]] = [] generic_markers = [ "需要进一步完善", "应当加强", "持续改进", "系统性", "闭环管理", "质量文化", ] for draft in drafts: text = draft.read_text(encoding="utf-8") zh_chars = sum(1 for char in text if "\u4e00" <= char <= "\u9fff") citations = re.findall(r"\[(src_[A-Za-z0-9_-]+)\]", text) evidence_table_present = "证据落点" in text and "待补证据" in text if zh_chars >= 1200 and len(set(citations)) < 5: findings.append({"severity": "P1", "message": f"{draft.name} 引用来源过少,可能未充分使用 evidence packet。"}) if zh_chars >= 1200 and not evidence_table_present: findings.append({"severity": "P1", "message": f"{draft.name} 缺少“证据落点与待补证据”小节,难以判断 evidence 是否真正落到纸面。"}) generic_count = sum(text.count(marker) for marker in generic_markers) if zh_chars >= 1200 and generic_count >= 18: findings.append({"severity": "P1", "message": f"{draft.name} 泛化管理表述过多,需要回炉为具体审计发现、风险影响和整改动作。"}) return findings def build_phase3_model_review_context(project_root: Path, *, max_chars: int = 650_000) -> str: """Build a structured, bounded context packet for an independent model review.""" deterministic_path = build_phase3_critique(project_root) deterministic_copy = project_root / "phase3" / "critique_deterministic.md" deterministic_copy.write_text(deterministic_path.read_text(encoding="utf-8"), encoding="utf-8") manifest = load_manifest(project_root) parts: list[str] = [ f"# Phase 3 Model Review Context: {manifest.get('topic', project_root.name)}", "", "## Review Contract", "", "- 这是给非 Codex 模型的独立总编审校上下文,不要求重写正文。", "- 请判断 Phase2 草稿能否进入 Phase4,或必须回炉补证据/重写。", "- 重点关注:证据是否落纸面、并发 packet 是否造成碎片化、法规/最佳实践覆盖是否足够、整改建议是否具体可执行。", "", "## Manifest", "", "```json", json.dumps(manifest, ensure_ascii=False, indent=2), "```", "", "## Deterministic Review Baseline", "", _read_text_if_exists(deterministic_copy), "", "## Phase 1 Framework", "", _read_text_if_exists(project_root / "phase1" / "framework.md", max_chars=50_000), "", "## Phase 1 Research Brief", "", _read_text_if_exists(project_root / "phase1" / "research_brief.md", max_chars=30_000), "", "## Phase 2 Brief Warnings", "", _json_if_exists(project_root / "phase2" / "brief_warnings.json", max_chars=30_000) or "无", "", "## Phase 2 Packet Errors", "", ] errors = sorted((project_root / "phase2" / "packet_errors").glob("*.json")) if errors: for path in errors[:40]: parts.extend([f"### {path.name}", "", _json_if_exists(path, max_chars=2_000), ""]) else: parts.append("无") parts.extend(["", "## Source Registry Summary", ""]) source_lines = [] sources_path = project_root / "phase2" / "sources.jsonl" if sources_path.exists(): for line in sources_path.read_text(encoding="utf-8").splitlines()[:260]: if not line.strip(): continue try: source = json.loads(line) except json.JSONDecodeError: continue source_lines.append( "- {id} | {tier} | {title} | {url} | cached={cached}".format( id=source.get("id", ""), tier=source.get("tier", ""), title=str(source.get("title", ""))[:120], url=source.get("url", ""), cached=source.get("cached_text_path", ""), ) ) parts.append("\n".join(source_lines) or "无") parts.extend(["", "## Compressed Findings", ""]) for path in sorted((project_root / "phase2" / "compressed_findings").glob("ch*.json")): parts.extend([f"### {path.name}", "", "```json", _json_if_exists(path, max_chars=35_000), "```", ""]) parts.extend(["", "## Chapter Drafts", ""]) for path in sorted((project_root / "phase2" / "drafts").glob("ch*.md")): parts.extend([f"### {path.name}", "", _read_text_if_exists(path, max_chars=55_000), ""]) context = "\n".join(parts) if len(context) > max_chars: context = context[:max_chars] + "\n\n[Context truncated by max_chars; review should flag if truncation limits confidence.]\n" out = project_root / "phase3" / "review_context_opus_4_7.md" out.parent.mkdir(parents=True, exist_ok=True) out.write_text(context, encoding="utf-8") return context def phase3_model_review_system_prompt() -> str: return ( "你是 Deep Research Phase 3 的独立总编审校模型,本次由 ZenMux Claude Opus 4.7 执行,用于避免 Codex/OpenAI 模型偏见。\n" "你的任务是审校,不是润色或重写。必须用中文输出,英文仅可保留 source title、URL、法规缩写和原文短摘录。\n" "请严格检查:1) 研究目标与 Phase1 框架是否契合;2) Phase2 并发 evidence packets 是否被章节真正吸收,还是造成碎片化;" "3) FDA/NMPA/EMA/ICH/WHO/EU GMP 等权威来源是否足以支撑关键判断;4) 用户材料是否被正确作为起点且被权威来源交叉验证;" "5) 运营管理与团队能力章节是否具体,不得泛泛咨询腔;6) CAPA 建议是否包含 owner、期限、关闭证据、QA verification、复核窗口和升级阈值;" "7) 引用链和 source_id 是否可追踪;8) 是否仍有明显 AI 味、中英文混杂或空泛表达。\n\n" "输出必须使用以下 Markdown 结构:\n" "# Phase 3 Opus 4.7 独立审校\n" "## 总体判定\n" "给出:通过 / 有条件通过 / 回炉 Phase2,并说明最核心理由。\n" "## P0/P1 阻断问题\n" "列出必须修复的问题;每条写明章节/文件、问题、为什么阻断、建议动作。\n" "## 章节级审校表\n" "用表格覆盖 ch01-ch11:主线质量、证据密度、法规覆盖、整改可执行性、是否需要回炉。\n" "## 证据与信源质量\n" "单独评价 FDA warning letters、ICH Q9/Q10、EU GMP Annex 1、本地缓存信源、第三方低质信源的使用情况。\n" "## 碎片化与叙事连贯性\n" "判断并发研究是否造成割裂,并给出具体整合建议。\n" "## Phase2 回炉任务清单\n" "如果需要回炉,列出可执行任务卡级别的补证据/重写要求。\n" "## Phase4 准入条件\n" "明确进入 final 前必须满足的条件。\n" ) def build_phase3_model_critique( project_root: Path, *, client: Any, model: str = "zenmux-anthropic/claude-opus-4-7", max_context_chars: int = 650_000, ) -> Path: context = build_phase3_model_review_context(project_root, max_chars=max_context_chars) content = client.chat_complete( model=model, system=phase3_model_review_system_prompt(), user=context, temperature=0.2, max_tokens=20_000, tag="phase3:opus-review", ) out = project_root / "phase3" / "critique.md" out.parent.mkdir(parents=True, exist_ok=True) out.write_text(content.rstrip() + "\n", encoding="utf-8") manifest = load_manifest(project_root) phase3 = manifest.setdefault("phase3", {}) phase3.update( { "status": "completed", "review_mode": "model", "review_model": model, "critique_path": "phase3/critique.md", "context_path": "phase3/review_context_opus_4_7.md", "deterministic_critique_path": "phase3/critique_deterministic.md", "updated_at": utc_now_iso(), } ) manifest["updated_at"] = utc_now_iso() write_manifest(project_root, manifest) return out def build_phase3_critique(project_root: Path) -> Path: manifest = load_manifest(project_root) drafts = sorted((project_root / "phase2" / "drafts").glob("ch*.md")) packets = sorted((project_root / "phase2" / "packets").glob("*.json")) ready_stems = _ready_packet_stems(project_root) packet_errors = [ path for path in sorted((project_root / "phase2" / "packet_errors").glob("*.json")) if path.stem not in ready_stems ] chapter_errors = sorted((project_root / "phase2" / "chapter_errors").glob("*.json")) sources = _source_ids_from_jsonl(project_root / "phase2" / "sources.jsonl") cited = _draft_citations(drafts) missing_sources = sorted(cited - sources) if sources else sorted(cited) uncited_sources = sorted(sources - cited) if cited else sorted(sources) findings: list[dict[str, Any]] = [] if not drafts: findings.append({"severity": "P1", "message": "Phase 2 drafts 缺失,尚不能进入 Phase 4 成稿。"}) if packet_errors: findings.append({"severity": "P1", "message": f"存在 {len(packet_errors)} 个 packet 失败,需要回炉补证据。"}) if chapter_errors: findings.append({"severity": "P1", "message": f"存在 {len(chapter_errors)} 个章节组装失败,需要修复引用或重写该章。"}) quality_holds = manifest.get("quality_holds") or [] if quality_holds: findings.append({"severity": "P1", "message": "存在质量暂停标记:" + ", ".join(quality_holds)}) findings.extend(_draft_quality_findings(drafts)) if missing_sources: findings.append({"severity": "P1", "message": f"正文引用未在 sources.jsonl 中登记:{', '.join(missing_sources)}"}) if not findings: findings.append({"severity": "P2", "message": "基础产物完整;仍需人工或大上下文模型审校逻辑链、反方证据和章节叙事。"}) lines = [ "# Phase 3 审校 critique", "", f"- 项目:{manifest.get('topic', project_root.name)}", f"- 运行时:python-core-v0.20", f"- drafts:{len(drafts)}", f"- packets:{len(packets)}", f"- sources:{len(sources)}", f"- cited_source_ids:{', '.join(sorted(cited)) if cited else '无'}", "", "## Findings", "", ] for item in findings: lines.append(f"- [{item['severity']}] {item['message']}") lines.extend( [ "", "## Residual Risks", "", "- 本 deterministic review 只做结构、引用和错误包检查;深层逻辑审校仍建议交给 `phase3_review` 角色执行。", "- 若 sources 为空,本审校会把所有正文引用视为待登记来源。", "", "## Next", "", "- 若存在 P1,先回到 Phase 2 修复 packet/chapter 错误。", "- 若仅有 P2,可进入 `dr.py finalize` 的中文原生成稿路径。", "", ] ) out = project_root / "phase3" / "critique.md" out.parent.mkdir(parents=True, exist_ok=True) out.write_text("\n".join(lines), encoding="utf-8") phase3 = manifest.setdefault("phase3", {}) phase3.update( { "status": "completed", "critique_path": "phase3/critique.md", "findings_total": len(findings), "missing_sources": missing_sources, "uncited_sources": uncited_sources, "updated_at": utc_now_iso(), } ) manifest["updated_at"] = utc_now_iso() write_manifest(project_root, manifest) return out