Files
deep_research/scripts/runtime/review.py
T

329 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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