v0.21 alpha add research brief and compressed findings

This commit is contained in:
kai
2026-05-06 19:23:30 +08:00
parent db626f1d58
commit 0644a68ecc
13 changed files with 539 additions and 27 deletions
+11 -7
View File
@@ -64,14 +64,16 @@
### Phase 1:框架规划 ### Phase 1:框架规划
- **驱动命令**`uv run python scripts/dr.py init <topic>``uv run python scripts/dr.py frame <slug>``/dr-init``/dr-frame` 只是薄封装) - **驱动命令**`uv run python scripts/dr.py init <topic>``uv run python scripts/dr.py frame <slug>``/dr-init``/dr-frame` 只是薄封装)
- **主导入口**Python core 生成项目骨架与 frameworkdr-plan 可作为表层访谈增强 - **主导入口**Python core 生成项目骨架与 frameworkdr-plan 可作为表层访谈增强
- **产出**`projects/<slug>/phase1/framework.md`(记录 research_method、8-15 章大纲,每 section 带研究思路与字数配额) - **产出**`phase1/material_brief.md` + `phase1/framework.md` + `phase1/research_brief.md/json`
- **暂停点**:用户确认框架 - **暂停点**:用户确认材料解读、研究方法、任务切分、检索策略和章节框架
- **硬规则**clarification 不只是问范围;必须固化报告用途、基调、成功标准、任务拆分、每个任务轴的 prompt/skills/search route,让低成本 worker 也能独立执行
### Phase 2:深度研究 ### Phase 2:深度研究
- **驱动命令**`uv run python scripts/dr.py research <slug> --workers 6` - **驱动命令**`uv run python scripts/dr.py research <slug> --workers 6`
- **主导入口**Python core 生成 task cards 并控制并发 - **主导入口**Python core 生成 task cards 并控制并发
- **产出**`projects/<slug>/phase2/task_cards.json` + `packets/*.json` + `drafts/chXX.md` + `evidence/chXX-evidence.md` + `sources.jsonl` - **产出**`phase2/task_cards.json` + `packets/*.json` + `sources.jsonl` + `chapter_briefs/*.json` + `compressed_findings/*.json` + `drafts/chXX.md`
- **不暂停**:全自动跑完 - **不暂停**:全自动跑完
- **防碎片化规则**:并发 worker 只写 evidence packet`--build-briefs` 必须先收束为 chapter brief 和 compressed finding;章节正文必须从 compressed finding 写,不得把 packet 按顺序拼贴成报告
### Phase 3:总编审校 ### Phase 3:总编审校
- **驱动命令**`uv run python scripts/dr.py review <slug>``/dr-review` 只是薄封装) - **驱动命令**`uv run python scripts/dr.py review <slug>``/dr-review` 只是薄封装)
@@ -190,6 +192,7 @@ uv run python scripts/dr.py methods list
- 不把平台 subagent 当默认并发机制。 - 不把平台 subagent 当默认并发机制。
- 真实并发由 `scripts/runtime/workers.py` 的 worker pool 执行。 - 真实并发由 `scripts/runtime/workers.py` 的 worker pool 执行。
- 真实模型选择由 `configs/models.yaml``scripts/runtime/roles.py` 执行。 - 真实模型选择由 `configs/models.yaml``scripts/runtime/roles.py` 执行。
- 信息检索默认走 `scripts/search.py` / `SearchClient` / `search-gateway` skill;不得把 Tavily MCP、browser MCP 或平台 web search 作为默认路径,除非用户明确授权。
--- ---
@@ -215,10 +218,11 @@ uv run python scripts/dr.py methods list
1. `uv run python scripts/dr.py status <slug>` 能看到 phase 状态。 1. `uv run python scripts/dr.py status <slug>` 能看到 phase 状态。
2. Phase 2 存在 `phase2/task_cards.json` 2. Phase 2 存在 `phase2/task_cards.json`
3. `--execute-packets` 后存在 `phase2/packets/*.json` 和必要时的 `phase2/packet_errors/*.json` 3. Phase 1 存在 `phase1/research_brief.md``phase1/research_brief.json`
4. `--build-briefs` 后存在 `phase2/chapter_briefs/*.json` 4. `--execute-packets` 后存在 `phase2/packets/*.json` 和必要时的 `phase2/packet_errors/*.json`
5. `--assemble-chapters` 后存在 `phase2/drafts/chXX.md` 和必要时的 `phase2/chapter_errors/*.json` 5. `--build-briefs` 后存在 `phase2/chapter_briefs/*.json``phase2/compressed_findings/*.json`
6. `scripts/v020_regression.py` 输出 `v0.20 regression PASS` 6. `--assemble-chapters` 后存在 `phase2/drafts/chXX.md` 和必要时的 `phase2/chapter_errors/*.json`
7. `scripts/v020_regression.py` 输出 `v0.20 regression PASS`
--- ---
+15
View File
@@ -686,3 +686,18 @@ OpenCode 的坑:如果只是在主会话里装样子地写"让 X agent 做"
- fixture 项目必须覆盖:material brief -> research brief -> task cards -> enrichment round -> compressed findings -> chapter draft -> Phase3 score gate。 - fixture 项目必须覆盖:material brief -> research brief -> task cards -> enrichment round -> compressed findings -> chapter draft -> Phase3 score gate。
- 搜索测试必须验证 subagent prompt 中包含 `search-gateway`,且不会提及 Tavily MCP 作为默认路径。 - 搜索测试必须验证 subagent prompt 中包含 `search-gateway`,且不会提及 Tavily MCP 作为默认路径。
- 质量测试必须能让“泛泛咨询腔但有引用”的章节失败,让“具体审计发现+法规条款+整改动作+待补证据”的章节通过。 - 质量测试必须能让“泛泛咨询腔但有引用”的章节失败,让“具体审计发现+法规条款+整改动作+待补证据”的章节通过。
- 2026-05-06 v0.21-alpha implementation**Research Brief 与压缩发现先行落地**
**已落地**
- `scripts/runtime/phase1.py` 新增 `phase1/research_brief.md` 与 `phase1/research_brief.json`,在 `frame` 阶段把材料简报、研究方法、工作语言、写作基调、成功标准、任务切分原则、每个任务轴的 prompt brief / search route / required skills / stop conditions 固化为文件。
- `scripts/runtime/orchestrator.py` 生成 Phase2 task cards 时优先读取 `research_brief.json`,不再只依赖章节标题和 method axes。
- `scripts/runtime/tasks.py` 扩展 `TaskCard` schema`research_goal`、`research_method`、`prompt_brief`、`required_skills`、`allowed_materials`、`expected_evidence`、`stop_conditions`、`model_hint`;旧 task card 会自动补默认字段,保持 fixture 兼容。
- `scripts/runtime/assembly.py` 新增 `build_compressed_findings()` 与 `validate_compressed_finding()``--build-briefs` 会同步写入 `phase2/compressed_findings/chXX.json`。
- `--assemble-chapters` 改为从 `compressed_findings` 写中文章节,减少并发 packet 直接拼接造成的碎片化。
- `AGENTS.md` 已同步更新 Phase1/2 真实产物、search-gateway 默认路径、Python core 验证锚点。
**仍未完成**
- `phase2/enrichment_rounds/roundXX/coverage_gap.json` 还未实现;下一步应先做 deterministic coverage evaluator,再让补充 task cards 从 gap 生成。
- Phase3 evaluator rubrics 仍是计划项;当前 deterministic review 已能抓部分 draft 质量问题,但还没有分维度评分与 finalize gate。
- DOCX/PPTX/图片批量 OCR、表格抽取、材料 source registry 仍放入后续资料导入增强。
+2 -2
View File
@@ -183,7 +183,7 @@ uv run python scripts/search.py "dual-target siRNA GalNAc" --route patents
``` ```
deep_research/ deep_research/
├── PLAN.md # 完整方案(中断续接从此读起) ├── PLAN.md # 完整方案(中断续接从此读起)
├── AGENTS.md # 研究方法论与规则OpenCode 自动加载) ├── AGENTS.md # 跨平台研究方法论与运行规则
├── README.md # 本文件 ├── README.md # 本文件
├── secrets.env.example # 密钥模板 ├── secrets.env.example # 密钥模板
├── secrets.env # 你的密钥(gitignore ├── secrets.env # 你的密钥(gitignore
@@ -224,7 +224,7 @@ deep_research/
### 1. Python core 防止上下文污染 ### 1. Python core 防止上下文污染
OpenCode/Codex/Claude Code/Antigravity/Gemini CLI 的 subagent 或 agent thread 都可能把大上下文带入子会话。v0.20 起,默认并发由 Python runtime 的任务卡执行层控制:先生成 `phase2/task_cards.json`按任务卡产出 `phase2/packets/*.json`最后组装中文章节。平台 agent 只负责调用 CLI 和展示状态。 OpenCode/Codex/Claude Code/Antigravity/Gemini CLI 的 subagent 或 agent thread 都可能把大上下文带入子会话。v0.20 起,默认并发由 Python runtime 的任务卡执行层控制:先生成 `phase1/research_brief.json` 固化研究意图和任务切分,再生成 `phase2/task_cards.json`,按任务卡产出 `phase2/packets/*.json``phase2/compressed_findings/*.json` 收束后再组装中文章节。平台 agent 只负责调用 CLI 和展示状态。
### 2. 信源分级(Tier 1-4 + 黑名单) ### 2. 信源分级(Tier 1-4 + 黑名单)
+11 -3
View File
@@ -26,7 +26,7 @@ from scripts.lib.model_config import (
parse_model_overrides, parse_model_overrides,
resolve_model_profile, resolve_model_profile,
) )
from scripts.runtime.assembly import build_chapter_briefs, run_chapter_assembly_workers from scripts.runtime.assembly import build_chapter_briefs, build_compressed_findings, run_chapter_assembly_workers
from scripts.runtime.orchestrator import create_phase2_task_cards, write_placeholder_packets from scripts.runtime.orchestrator import create_phase2_task_cards, write_placeholder_packets
from scripts.runtime.methods import ResearchMethodRegistry from scripts.runtime.methods import ResearchMethodRegistry
from scripts.runtime.phase1 import create_project, render_framework, write_material_brief from scripts.runtime.phase1 import create_project, render_framework, write_material_brief
@@ -258,11 +258,14 @@ def cmd_research(args: argparse.Namespace) -> int:
packet_count = len(list((project_root / "phase2" / "packets").glob("*.json"))) packet_count = len(list((project_root / "phase2" / "packets").glob("*.json")))
brief_count = 0 brief_count = 0
chapter_count = 0 chapter_count = 0
compressed_count = 0
source_count = None source_count = None
if args.build_briefs or args.assemble_chapters: if args.build_briefs or args.assemble_chapters:
source_count = rebuild_sources_from_packets(project_root) source_count = rebuild_sources_from_packets(project_root)
briefs = build_chapter_briefs(project_root) briefs = build_chapter_briefs(project_root)
brief_count = len(briefs) brief_count = len(briefs)
compressed_findings = build_compressed_findings(project_root)
compressed_count = len(compressed_findings)
if args.assemble_chapters: if args.assemble_chapters:
from scripts.lib.zenmux_client import ZenMuxClient, load_secrets from scripts.lib.zenmux_client import ZenMuxClient, load_secrets
@@ -273,7 +276,7 @@ def cmd_research(args: argparse.Namespace) -> int:
chapter_count = run_chapter_assembly_workers( chapter_count = run_chapter_assembly_workers(
project_root=project_root, project_root=project_root,
briefs=briefs, briefs=compressed_findings,
runtime=runtime, runtime=runtime,
client_factory=chapter_client_factory, client_factory=chapter_client_factory,
workers=args.workers, workers=args.workers,
@@ -288,6 +291,7 @@ def cmd_research(args: argparse.Namespace) -> int:
print(f"Sources rebuilt: {source_count}") print(f"Sources rebuilt: {source_count}")
if args.build_briefs or args.assemble_chapters: if args.build_briefs or args.assemble_chapters:
print(f"Chapter briefs: {brief_count}") print(f"Chapter briefs: {brief_count}")
print(f"Compressed findings: {compressed_count}")
if args.assemble_chapters: if args.assemble_chapters:
print(f"Chapter drafts: {chapter_count}") print(f"Chapter drafts: {chapter_count}")
if args.dry_run: if args.dry_run:
@@ -298,7 +302,7 @@ def cmd_research(args: argparse.Namespace) -> int:
print("Wrote: phase2/task_cards.json and validated phase2/packets/*.json") print("Wrote: phase2/task_cards.json and validated phase2/packets/*.json")
print("Next: rerun with --build-briefs to aggregate packets into chapter briefs.") print("Next: rerun with --build-briefs to aggregate packets into chapter briefs.")
elif args.build_briefs: elif args.build_briefs:
print("Wrote: phase2/chapter_briefs/*.json") print("Wrote: phase2/chapter_briefs/*.json and phase2/compressed_findings/*.json")
print("Next: rerun with --assemble-chapters to write Chinese chapter drafts.") print("Next: rerun with --assemble-chapters to write Chinese chapter drafts.")
else: else:
print("Wrote: phase2/task_cards.json and phase2/packets/*.json") print("Wrote: phase2/task_cards.json and phase2/packets/*.json")
@@ -373,6 +377,8 @@ def cmd_status(args: argparse.Namespace) -> int:
drafts = sorted((project_root / "phase2" / "drafts").glob("ch*.md")) drafts = sorted((project_root / "phase2" / "drafts").glob("ch*.md"))
evidence = sorted((project_root / "phase2" / "evidence").glob("ch*-evidence.md")) evidence = sorted((project_root / "phase2" / "evidence").glob("ch*-evidence.md"))
task_cards = project_root / "phase2" / "task_cards.json" task_cards = project_root / "phase2" / "task_cards.json"
research_brief = project_root / "phase1" / "research_brief.json"
compressed_findings = sorted((project_root / "phase2" / "compressed_findings").glob("ch*.json"))
packet_counts = packet_state_counts(project_root) packet_counts = packet_state_counts(project_root)
sources = project_root / "phase2" / "sources.jsonl" sources = project_root / "phase2" / "sources.jsonl"
final_en = project_root / "phase4" / "final_en.md" final_en = project_root / "phase4" / "final_en.md"
@@ -399,6 +405,7 @@ def cmd_status(args: argparse.Namespace) -> int:
print() print()
print("Artifacts:") print("Artifacts:")
print(f" framework: {file_state(project_root / 'phase1' / 'framework.md')}") print(f" framework: {file_state(project_root / 'phase1' / 'framework.md')}")
print(f" research_brief.json: {file_state(research_brief)}")
print(f" task_cards.json: {file_state(task_cards)}") print(f" task_cards.json: {file_state(task_cards)}")
print( print(
" packets: " " packets: "
@@ -410,6 +417,7 @@ def cmd_status(args: argparse.Namespace) -> int:
f"total={packet_counts['total']}" f"total={packet_counts['total']}"
) )
print(f" drafts: {len(drafts)}") print(f" drafts: {len(drafts)}")
print(f" compressed findings: {len(compressed_findings)}")
print(f" evidence files: {len(evidence)}") print(f" evidence files: {len(evidence)}")
print(f" sources: {source_count}") print(f" sources: {source_count}")
print(f" final_en.md: {file_state(final_en)} ({en_words:,} words)") print(f" final_en.md: {file_state(final_en)} ({en_words:,} words)")
+109 -3
View File
@@ -41,8 +41,41 @@ def validate_chapter_brief(brief: dict) -> None:
raise ValueError("chapter brief requires 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: def validate_chapter_markdown_citations(markdown: str, brief: dict) -> None:
validate_chapter_brief(brief) 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)) cited = set(re.findall(r"\[(src_[A-Za-z0-9_-]+)\]", markdown))
allowed = set(brief.get("source_ids") or []) allowed = set(brief.get("source_ids") or [])
unknown = sorted(cited - allowed) unknown = sorted(cited - allowed)
@@ -103,9 +136,79 @@ def build_chapter_briefs(project_root: Path) -> list[dict]:
return briefs 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: def build_chapter_user_prompt(brief: dict) -> str:
return ( return (
"请根据以下 chapter brief 写一章正式中文 Markdown 正文。\n" "请根据以下 compressed finding / chapter brief 写一章正式中文 Markdown 正文。\n"
"目标是形成一个完整章节,而不是 packet 摘要。避免碎片化,按金字塔结构组织:章首先给结论,再用证据支撑。\n" "目标是形成一个完整章节,而不是 packet 摘要。避免碎片化,按金字塔结构组织:章首先给结论,再用证据支撑。\n"
"要求:标题必须是观点型判断;每个数字和事实保留 [src_xxx];纳入反方证据;不要出现调度元数据。\n" "要求:标题必须是观点型判断;每个数字和事实保留 [src_xxx];纳入反方证据;不要出现调度元数据。\n"
"禁止写空泛咨询腔。每个二级小节都必须至少落下 2 个具体审计发现、法规要求、SOP/记录/参数/现场观察或整改证据;不要只写原则。\n" "禁止写空泛咨询腔。每个二级小节都必须至少落下 2 个具体审计发现、法规要求、SOP/记录/参数/现场观察或整改证据;不要只写原则。\n"
@@ -142,7 +245,10 @@ class ChapterAssemblyWorker:
) )
def write_chapter(self, *, project_root: Path, brief: dict) -> Path: def write_chapter(self, *, project_root: Path, brief: dict) -> Path:
validate_chapter_brief(brief) if "key_findings" in brief:
validate_compressed_finding(brief)
else:
validate_chapter_brief(brief)
markdown = self.client.chat_complete( markdown = self.client.chat_complete(
model=self.role.model, model=self.role.model,
system=self._system_prompt(), system=self._system_prompt(),
+14 -2
View File
@@ -8,7 +8,7 @@ from pathlib import Path
from scripts.runtime.artifacts import ensure_phase_dirs, load_manifest, write_manifest from scripts.runtime.artifacts import ensure_phase_dirs, load_manifest, write_manifest
from scripts.runtime.methods import ResearchMethodRegistry from scripts.runtime.methods import ResearchMethodRegistry
from scripts.runtime.tasks import generate_task_cards, write_task_cards from scripts.runtime.tasks import generate_task_cards, generate_task_cards_from_research_brief, write_task_cards
def utc_now_iso() -> str: def utc_now_iso() -> str:
@@ -28,7 +28,19 @@ def create_phase2_task_cards(
if (project_root / "manifest.json").exists(): if (project_root / "manifest.json").exists():
method_key = load_manifest(project_root).get("research_method") method_key = load_manifest(project_root).get("research_method")
method = ResearchMethodRegistry().get(method_key) method = ResearchMethodRegistry().get(method_key)
cards = generate_task_cards(project_root.name, framework.read_text(encoding="utf-8"), axes=axes, method=method) research_brief_path = project_root / "phase1" / "research_brief.json"
framework_text = framework.read_text(encoding="utf-8")
if research_brief_path.exists():
research_brief = json.loads(research_brief_path.read_text(encoding="utf-8"))
cards = generate_task_cards_from_research_brief(
project_root.name,
framework_text,
research_brief,
axes=axes,
method=method,
)
else:
cards = generate_task_cards(project_root.name, framework_text, axes=axes, method=method)
if not dry_run: if not dry_run:
ensure_phase_dirs(project_root) ensure_phase_dirs(project_root)
write_task_cards(project_root / "phase2" / "task_cards.json", cards) write_task_cards(project_root / "phase2" / "task_cards.json", cards)
+132
View File
@@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
import hashlib import hashlib
import json
import re import re
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
@@ -11,6 +12,7 @@ from typing import Any
from scripts.runtime.artifacts import PROJECTS_DIR, ensure_phase_dirs, load_manifest, write_manifest from scripts.runtime.artifacts import PROJECTS_DIR, ensure_phase_dirs, load_manifest, write_manifest
from scripts.runtime.materials import ingest_input_materials, render_material_inventory from scripts.runtime.materials import ingest_input_materials, render_material_inventory
from scripts.runtime.methods import ResearchMethod, ResearchMethodRegistry from scripts.runtime.methods import ResearchMethod, ResearchMethodRegistry
from scripts.runtime.tasks import AXIS_ROUTES
def utc_now_iso() -> str: def utc_now_iso() -> str:
@@ -187,6 +189,132 @@ def write_material_brief(
return out return out
def _axis_prompt_brief(axis: str, method: ResearchMethod) -> str:
prompts = {
"input_material_findings": "从用户材料中提取现场事实、审计发现、复盘记录和内部答复,并标注原始材料位置。",
"nmpa_fda_ema_ich_who_baseline": "把 NMPA、FDA、EMA、ICH、WHO、药典或 Annex 1 等要求转化为可核验的法规基线。",
"quality_system_gap": "把现场发现映射到质量体系流程缺口,覆盖偏差、变更、CAPA、文件、培训和数据完整性。",
"manufacturing_process_risk": "围绕生产工艺、设施、公用系统、CPP/CQA、验证和无菌保障识别系统性风险。",
"operations_management_gap": "诊断运营管理、跨部门协同、会议机制、指标体系和交付节奏的结构性问题。",
"team_capability": "识别人员能力、岗位职责、质量文化和管理梯队方面的缺口与建设路径。",
"capa_roadmap": "把差距转化为短中长期 CAPA 组合,要求绑定 owner、期限、优先级、关闭证据和复核机制。",
"verification_evidence": "定义整改完成后可被审计接受的验证证据,包括记录、报告、趋势和管理评审输入。",
"counter": "主动寻找反方证据、限制条件和可能降低严重度或改变优先级的解释,避免单向论证。",
}
return prompts.get(axis, f"按照 `{method.key}` 方法,对 {axis} 轴进行证据收集、证伪和结构化归纳。")
def _material_paths(manifest: dict[str, Any]) -> list[dict[str, str]]:
materials: list[dict[str, str]] = []
for item in manifest.get("material_inventory") or []:
rel = item.get("ocr_extracted_to") or item.get("extracted_to") or item.get("copied_to")
if rel:
materials.append({"path": rel, "role": "input_material"})
return materials
def build_research_brief_payload(
project_root: Path,
manifest: dict[str, Any],
method: ResearchMethod,
) -> dict[str, Any]:
"""Create the file-backed Phase 1 research brief used by task-card generation."""
axes = list(method.task_axes)
return {
"version": "0.21-alpha",
"topic": manifest.get("topic", project_root.name),
"research_method": method.key,
"method_name": method.name,
"work_language": "zh",
"tone": "事实型、整改导向、面向管理层和质量/生产负责人;避免空泛咨询腔。",
"central_question": f"如何基于已提供材料和权威法规/最佳实践,系统诊断“{manifest.get('topic', project_root.name)}”并形成可执行整改路线图?",
"success_criteria": [
"每个核心判断都能回到用户材料、权威法规、最佳实践或反方证据。",
"短中长期整改建议必须绑定优先级、责任、关闭证据和复核机制。",
"章节写作必须先收束主线,再使用 evidence packet;不得按 packet 机械拼贴。",
],
"phase2_inputs": {
"material_brief_path": "phase1/material_brief.md",
"framework_path": "phase1/framework.md",
"research_brief_path": "phase1/research_brief.json",
},
"materials": _material_paths(manifest),
"task_planning": {
"chapter_source": "phase1/framework.md",
"axes": axes,
"required_skills": [
"deep-research",
"search-gateway",
"search-strategy",
"source-quality",
"evidence-table",
"citation-manager",
],
"search_routes_by_axis": {axis: AXIS_ROUTES.get(axis, ["general"]) for axis in axes},
"axis_prompt_briefs": {axis: _axis_prompt_brief(axis, method) for axis in axes},
"stop_conditions": [
"每张任务卡至少形成 3 条可追溯 evidence_items,且不得编造 candidate_sources 以外来源。",
"关键 claim 不足 2 个独立 Tier 1-2 信源时,必须写入 open_questions 和证据缺口。",
"必须包含 counter_evidence;找不到反方证据时记录检索路径和限制。",
],
"fragmentation_guard": "并发 worker 只生产 evidence packet;章节主线由 compressed_findings 收束,禁止直接把 packet 堆成正文。",
},
"clarification_notes": {
"requires_user_review": True,
"questions_source": "phase1/material_brief.md",
"decision_items": [
"确认报告用途、受众和脱敏边界。",
"确认研究方法是否适配当前场景;MECE 只是可选方法之一。",
"确认任务切分和检索策略是否足以让低成本模型独立执行。",
],
},
}
def write_research_brief(
project_root: Path,
manifest: dict[str, Any] | None = None,
method: ResearchMethod | None = None,
) -> tuple[Path, Path]:
manifest = manifest or load_manifest(project_root)
method = method or ResearchMethodRegistry().get(manifest.get("research_method"))
payload = build_research_brief_payload(project_root, manifest, method)
json_path = project_root / "phase1" / "research_brief.json"
md_path = project_root / "phase1" / "research_brief.md"
json_path.parent.mkdir(parents=True, exist_ok=True)
json_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
lines = [
f"# Phase 1 Research Brief{payload['topic']}",
"",
f"- research_method: {payload['research_method']}",
f"- work_language: {payload['work_language']}",
f"- tone: {payload['tone']}",
"",
"## 中心问题",
"",
payload["central_question"],
"",
"## 成功标准",
"",
]
lines.extend(f"- {item}" for item in payload["success_criteria"])
lines.extend(["", "## 任务切分原则", ""])
planning = payload["task_planning"]
lines.append(planning["fragmentation_guard"])
lines.append("")
for axis in planning["axes"]:
routes = "".join(planning["search_routes_by_axis"].get(axis, []))
prompt = planning["axis_prompt_briefs"].get(axis, "")
lines.append(f"- `{axis}`{prompt} 检索路径:{routes}")
lines.extend(["", "## 必读 Skills", ""])
lines.extend(f"- {name}" for name in planning["required_skills"])
lines.extend(["", "## 停止条件", ""])
lines.extend(f"- {item}" for item in planning["stop_conditions"])
lines.append("")
md_path.write_text("\n".join(lines), encoding="utf-8")
return md_path, json_path
CHAPTER_TEMPLATES: dict[str, list[str]] = { CHAPTER_TEMPLATES: dict[str, list[str]] = {
"mckinsey_market": [ "mckinsey_market": [
"核心结论先行界定市场机会与约束", "核心结论先行界定市场机会与约束",
@@ -329,10 +457,14 @@ def render_framework(project_root: Path, *, method_key: str | None = None, chapt
out = project_root / "phase1" / "framework.md" out = project_root / "phase1" / "framework.md"
out.parent.mkdir(parents=True, exist_ok=True) out.parent.mkdir(parents=True, exist_ok=True)
out.write_text("\n".join(lines), encoding="utf-8") out.write_text("\n".join(lines), encoding="utf-8")
research_brief_md, research_brief_json = write_research_brief(project_root, manifest, method)
manifest["phase1"] = { manifest["phase1"] = {
"status": "completed", "status": "completed",
"approved": False, "approved": False,
"framework_path": "phase1/framework.md", "framework_path": "phase1/framework.md",
"research_brief_path": str(research_brief_md.relative_to(project_root)),
"research_brief_json_path": str(research_brief_json.relative_to(project_root)),
"requires_user_interview": True,
"research_method": method.key, "research_method": method.key,
"updated_at": utc_now_iso(), "updated_at": utc_now_iso(),
} }
+130 -8
View File
@@ -63,6 +63,14 @@ class TaskCard:
preferred_model_role: str = "dr_analyst" preferred_model_role: str = "dr_analyst"
status: str = "pending" status: str = "pending"
dependencies: list[str] = field(default_factory=list) dependencies: list[str] = field(default_factory=list)
research_goal: str = ""
research_method: str = ""
prompt_brief: str = ""
required_skills: list[str] = field(default_factory=list)
allowed_materials: list[str] = field(default_factory=list)
expected_evidence: dict[str, Any] = field(default_factory=dict)
stop_conditions: list[str] = field(default_factory=list)
model_hint: str = ""
def to_dict(self) -> dict[str, Any]: def to_dict(self) -> dict[str, Any]:
return asdict(self) return asdict(self)
@@ -105,6 +113,66 @@ def _questions_for_axis(chapter: Chapter, axis: str) -> list[str]:
] ]
def _default_required_skills(axis: str) -> list[str]:
skills = ["search-gateway", "search-strategy", "source-quality", "evidence-table"]
if axis == "counter":
skills.append("mckinsey-method")
return skills
def _default_expected_evidence(axis: str) -> dict[str, Any]:
return {
"min_tier_1_2_sources": 2,
"must_include_counter_evidence": True,
"must_include_source_metadata": True,
"preferred_evidence_types": [
"regulatory_or_best_practice_requirement",
"site_or_material_finding",
"quantitative_fact_or_record",
"implementation_or_verification_evidence",
],
"axis": axis,
}
def _default_stop_conditions() -> list[str]:
return [
"已形成至少 3 条可追溯 evidence_items,且每条关键 claim 有 source_id。",
"已主动记录 counter_evidence 或明确说明未找到反方证据的检索路径。",
"candidate_sources 不足以支撑结论时停止写作,并把缺口写入 open_questions。",
]
def _task_card_for_chapter_axis(
*,
chapter: Chapter,
axis: str,
routes: list[str],
method_key: str,
required_skills: list[str] | None = None,
allowed_materials: list[str] | None = None,
prompt_brief: str | None = None,
stop_conditions: list[str] | None = None,
) -> TaskCard:
return TaskCard(
task_id=f"{chapter.chapter_id}-{axis}",
chapter_ids=[chapter.chapter_id],
topic_axis=axis,
questions=_questions_for_axis(chapter, axis),
search_routes=routes,
output_packet=f"phase2/packets/{chapter.chapter_id}-{axis}.json",
preferred_model_role="dr_verifier" if axis == "counter" else "dr_analyst",
research_goal=f"为《{chapter.title}》收集并验证 {axis} 轴证据,形成可写入章节的具体判断与证据落点。",
research_method=method_key,
prompt_brief=prompt_brief or f"围绕《{chapter.title}》的 {axis} 轴,优先形成可证伪、可引用、可落地的证据包。",
required_skills=required_skills or _default_required_skills(axis),
allowed_materials=allowed_materials or [],
expected_evidence=_default_expected_evidence(axis),
stop_conditions=stop_conditions or _default_stop_conditions(),
model_hint="use_cross_model_verifier" if axis == "counter" else "use_cost_effective_research_worker",
)
def generate_task_cards( def generate_task_cards(
slug: str, slug: str,
framework_text: str, framework_text: str,
@@ -120,14 +188,56 @@ def generate_task_cards(
for axis in selected_axes: for axis in selected_axes:
routes = AXIS_ROUTES.get(axis, ["general"]) routes = AXIS_ROUTES.get(axis, ["general"])
cards.append( cards.append(
TaskCard( _task_card_for_chapter_axis(
task_id=f"{chapter.chapter_id}-{axis}", chapter=chapter,
chapter_ids=[chapter.chapter_id], axis=axis,
topic_axis=axis, routes=routes,
questions=_questions_for_axis(chapter, axis), method_key=method.key if method else "",
search_routes=routes, )
output_packet=f"phase2/packets/{chapter.chapter_id}-{axis}.json", )
preferred_model_role="dr_verifier" if axis == "counter" else "dr_analyst", validate_task_cards(cards)
return cards
def generate_task_cards_from_research_brief(
slug: str,
framework_text: str,
research_brief: dict[str, Any],
*,
axes: list[str] | None = None,
method: ResearchMethod | None = None,
) -> list[TaskCard]:
del slug
chapters = parse_framework_chapters(framework_text)
planning = research_brief.get("task_planning") or {}
method_key = research_brief.get("research_method") or (method.key if method else "")
selected_axes = axes or (method.task_axes if method else None) or list(planning.get("search_routes_by_axis") or []) or DEFAULT_AXES
routes_by_axis = planning.get("search_routes_by_axis") or {}
prompt_by_axis = planning.get("axis_prompt_briefs") or {}
base_skills = list(planning.get("required_skills") or [])
stop_conditions = list(planning.get("stop_conditions") or [])
allowed_materials = [
str(item.get("path"))
for item in research_brief.get("materials", [])
if item.get("path")
]
cards: list[TaskCard] = []
for chapter in chapters:
for axis in selected_axes:
routes = list(routes_by_axis.get(axis) or AXIS_ROUTES.get(axis, ["general"]))
skills = base_skills or _default_required_skills(axis)
if "search-gateway" not in skills:
skills = ["search-gateway", *skills]
cards.append(
_task_card_for_chapter_axis(
chapter=chapter,
axis=axis,
routes=routes,
method_key=method_key,
required_skills=skills,
allowed_materials=allowed_materials,
prompt_brief=prompt_by_axis.get(axis),
stop_conditions=stop_conditions or None,
) )
) )
validate_task_cards(cards) validate_task_cards(cards)
@@ -157,6 +267,18 @@ def detect_dependency_cycles(cards: list[TaskCard]) -> None:
def validate_task_cards(cards: list[TaskCard]) -> None: def validate_task_cards(cards: list[TaskCard]) -> None:
seen: set[str] = set() seen: set[str] = set()
for card in cards: for card in cards:
if not card.research_goal:
card.research_goal = f"围绕 {card.topic_axis} 轴收集并验证结构化证据。"
if not card.prompt_brief:
card.prompt_brief = f"{card.topic_axis} 轴形成证据包,避免泛泛结论。"
if not card.required_skills:
card.required_skills = _default_required_skills(card.topic_axis)
if "search-gateway" not in card.required_skills:
card.required_skills = ["search-gateway", *card.required_skills]
if not card.expected_evidence:
card.expected_evidence = _default_expected_evidence(card.topic_axis)
if not card.stop_conditions:
card.stop_conditions = _default_stop_conditions()
if card.task_id in seen: if card.task_id in seen:
raise ValueError(f"duplicate task_id: {card.task_id}") raise ValueError(f"duplicate task_id: {card.task_id}")
seen.add(card.task_id) seen.add(card.task_id)
+29 -2
View File
@@ -34,6 +34,29 @@ def make_fixture(root: Path) -> Path:
return project return project
def write_fixture_packet(project: Path) -> None:
cards = json.loads((project / "phase2" / "task_cards.json").read_text(encoding="utf-8"))
for index, card in enumerate(cards, start=1):
source_a = f"src_{index:03d}_a"
source_b = f"src_{index:03d}_b"
packet = {
"task_id": card["task_id"],
"claims": [{"claim": f"{card['task_id']} 回归证据支持章节主线", "source_ids": [source_a]}],
"evidence_items": [{"source_id": source_a, "summary": "权威来源支持该判断"}],
"counter_evidence": [{"claim": "证据仍需更多来源交叉验证", "source_ids": [source_b]}],
"source_ids": [source_a, source_b],
"source_quality_notes": [f"{source_a} Tier 1", f"{source_b} Tier 2"],
"open_questions": ["需要在真实项目中补充更多来源。"],
"raw_quotes_or_notes": ["English raw note can remain here."],
"sources": [
{"id": source_a, "title": f"Regression Source {index}A", "url": f"https://example.com/source-{index}-a", "tier": 1},
{"id": source_b, "title": f"Regression Source {index}B", "url": f"https://example.com/source-{index}-b", "tier": 2},
],
}
packet_path = project / card["output_packet"]
packet_path.write_text(json.dumps(packet, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
def main() -> int: def main() -> int:
python = sys.executable python = sys.executable
run([python, "scripts/dr.py", "skills", "validate"]) run([python, "scripts/dr.py", "skills", "validate"])
@@ -56,9 +79,13 @@ def main() -> int:
) )
project = make_fixture(tmp_root) project = make_fixture(tmp_root)
run([python, "scripts/dr.py", "frame", str(project)]) run([python, "scripts/dr.py", "frame", str(project)])
assert (project / "phase1" / "research_brief.json").exists()
run([python, "scripts/dr.py", "approve", str(project)]) run([python, "scripts/dr.py", "approve", str(project)])
run([python, "scripts/dr.py", "research", str(project), "--workers", "2", "--dry-run"]) run([python, "scripts/dr.py", "research", str(project), "--workers", "2", "--axis", "literature", "--dry-run"])
run([python, "scripts/dr.py", "research", str(project), "--workers", "2"]) run([python, "scripts/dr.py", "research", str(project), "--workers", "2", "--axis", "literature"])
write_fixture_packet(project)
run([python, "scripts/dr.py", "research", str(project), "--workers", "2", "--axis", "literature", "--build-briefs"])
assert (project / "phase2" / "compressed_findings" / "ch01.json").exists()
run([python, "scripts/dr.py", "review", str(project)]) run([python, "scripts/dr.py", "review", str(project)])
run([python, "scripts/dr.py", "finalize", str(project), "--dry-run"]) run([python, "scripts/dr.py", "finalize", str(project), "--dry-run"])
print("v0.20 regression PASS") print("v0.20 regression PASS")
+14
View File
@@ -11,10 +11,12 @@ if str(REPO_ROOT) not in sys.path:
from scripts.runtime.assembly import ( from scripts.runtime.assembly import (
ChapterAssemblyWorker, ChapterAssemblyWorker,
build_chapter_briefs, build_chapter_briefs,
build_compressed_findings,
build_chapter_user_prompt, build_chapter_user_prompt,
run_chapter_assembly_workers, run_chapter_assembly_workers,
validate_chapter_markdown_citations, validate_chapter_markdown_citations,
validate_chapter_brief, validate_chapter_brief,
validate_compressed_finding,
) )
from scripts.runtime.roles import resolve_runtime_profile from scripts.runtime.roles import resolve_runtime_profile
@@ -104,6 +106,17 @@ def test_build_chapter_briefs_aggregates_packets_by_chapter(tmp_path: Path) -> N
assert "src_002" in brief["source_ids"] assert "src_002" in brief["source_ids"]
assert (project / "phase2/chapter_briefs/ch01.json").exists() assert (project / "phase2/chapter_briefs/ch01.json").exists()
compressed = build_compressed_findings(project)
assert len(compressed) == 1
finding = compressed[0]
validate_compressed_finding(finding)
assert finding["chapter_id"] == "ch01"
assert "chapter_thesis" in finding
assert "evidence_landings" in finding
assert "src_001" in finding["source_ids"]
assert (project / "phase2/compressed_findings/ch01.json").exists()
def test_chapter_prompt_contains_brief_and_fragmentation_guard() -> None: def test_chapter_prompt_contains_brief_and_fragmentation_guard() -> None:
brief = { brief = {
@@ -122,6 +135,7 @@ def test_chapter_prompt_contains_brief_and_fragmentation_guard() -> None:
assert "临床证据正在重塑需求判断" in prompt assert "临床证据正在重塑需求判断" in prompt
assert "避免碎片化" in prompt assert "避免碎片化" in prompt
assert "compressed finding" in prompt
assert "只输出 Markdown" in prompt assert "只输出 Markdown" in prompt
+11
View File
@@ -62,9 +62,20 @@ def test_framework_mentions_ingested_materials(tmp_path: Path) -> None:
render_framework(project, method_key="gmp_quality_operations_diagnosis") render_framework(project, method_key="gmp_quality_operations_diagnosis")
framework = (project / "phase1/framework.md").read_text(encoding="utf-8") framework = (project / "phase1/framework.md").read_text(encoding="utf-8")
research_brief_md = project / "phase1" / "research_brief.md"
research_brief_json = project / "phase1" / "research_brief.json"
brief = json.loads(research_brief_json.read_text(encoding="utf-8"))
assert "phase0/extracted/audit.md" in framework assert "phase0/extracted/audit.md" in framework
assert "NMPA、FDA、EMA、ICH、WHO" in framework assert "NMPA、FDA、EMA、ICH、WHO" in framework
assert "请先确认 `phase1/material_brief.md`" in framework assert "请先确认 `phase1/material_brief.md`" in framework
assert research_brief_md.exists()
assert "任务切分原则" in research_brief_md.read_text(encoding="utf-8")
assert brief["research_method"] == "gmp_quality_operations_diagnosis"
assert brief["work_language"] == "zh"
assert brief["task_planning"]["required_skills"]
assert brief["task_planning"]["search_routes_by_axis"]["counter"] == ["scholar", "general"]
assert brief["phase2_inputs"]["framework_path"] == "phase1/framework.md"
def test_pdf_requiring_ocr_uses_firered_and_records_result(tmp_path: Path, monkeypatch) -> None: def test_pdf_requiring_ocr_uses_firered_and_records_result(tmp_path: Path, monkeypatch) -> None:
+19
View File
@@ -45,6 +45,23 @@ def test_orchestrator_reads_research_method_from_manifest(tmp_path: Path) -> Non
"## 第1章 GMP 体系差距需要按法规和风险双轴定位\n\n研究思路。", "## 第1章 GMP 体系差距需要按法规和风险双轴定位\n\n研究思路。",
encoding="utf-8", encoding="utf-8",
) )
(project / "phase1" / "research_brief.json").write_text(
json.dumps(
{
"research_method": "gmp_gap_assessment",
"work_language": "zh",
"task_planning": {
"required_skills": ["search-gateway", "evidence-table"],
"search_routes_by_axis": {"regulatory_gap": ["general", "news"]},
"axis_prompt_briefs": {"regulatory_gap": "逐条映射法规基线与审计差距。"},
"stop_conditions": ["证据不足则回炉补证。"],
},
"materials": [{"path": "phase0/extracted/audit.md"}],
},
ensure_ascii=False,
),
encoding="utf-8",
)
cards = create_phase2_task_cards(project, dry_run=True) cards = create_phase2_task_cards(project, dry_run=True)
@@ -53,3 +70,5 @@ def test_orchestrator_reads_research_method_from_manifest(tmp_path: Path) -> Non
"risk_classification", "risk_classification",
"capa_design", "capa_design",
] ]
assert cards[0]["prompt_brief"] == "逐条映射法规基线与审计差距。"
assert cards[0]["allowed_materials"] == ["phase0/extracted/audit.md"]
+42
View File
@@ -17,6 +17,7 @@ from scripts.runtime.tasks import (
TaskCard, TaskCard,
detect_dependency_cycles, detect_dependency_cycles,
generate_task_cards, generate_task_cards,
generate_task_cards_from_research_brief,
validate_packet, validate_packet,
validate_task_cards, validate_task_cards,
) )
@@ -77,6 +78,47 @@ def test_generate_task_cards_from_chinese_framework() -> None:
"ch02-regulatory", "ch02-regulatory",
] ]
assert cards[0].output_packet == "phase2/packets/ch01-clinical.json" assert cards[0].output_packet == "phase2/packets/ch01-clinical.json"
assert cards[0].research_goal
assert "search-gateway" in cards[0].required_skills
assert cards[0].expected_evidence["min_tier_1_2_sources"] == 2
assert cards[0].stop_conditions
def test_generate_task_cards_from_research_brief_carries_prompt_and_skills() -> None:
brief = {
"research_method": "gmp_quality_operations_diagnosis",
"work_language": "zh",
"tone": "面向管理层的事实型整改诊断",
"task_planning": {
"required_skills": ["search-gateway", "evidence-table", "source-quality"],
"search_routes_by_axis": {
"quality_system_gap": ["general", "news"],
"counter": ["scholar", "general"],
},
"axis_prompt_briefs": {
"quality_system_gap": "把现场发现映射到质量体系流程缺口和法规要求。",
"counter": "主动寻找能削弱或限定结论的反方证据。",
},
"stop_conditions": ["每张卡至少形成 3 条可追溯证据。"],
},
"materials": [{"path": "phase0/extracted/audit.md", "role": "site_evidence"}],
}
framework = "## 第1章 质量体系闭环能力决定整改可信度\n\n研究思路。"
cards = generate_task_cards_from_research_brief(
"baifan-test",
framework,
brief,
axes=["quality_system_gap", "counter"],
)
assert [card.task_id for card in cards] == ["ch01-quality_system_gap", "ch01-counter"]
assert cards[0].prompt_brief == "把现场发现映射到质量体系流程缺口和法规要求。"
assert cards[0].research_method == "gmp_quality_operations_diagnosis"
assert cards[0].allowed_materials == ["phase0/extracted/audit.md"]
assert cards[0].preferred_model_role == "dr_analyst"
assert cards[1].preferred_model_role == "dr_verifier"
assert "search-gateway" in cards[0].required_skills
def test_task_card_validation_rejects_duplicates_and_cycles() -> None: def test_task_card_validation_rejects_duplicates_and_cycles() -> None: