v0.20 alpha skill-driven python core

This commit is contained in:
kai
2026-05-06 16:26:41 +08:00
parent d1169646b8
commit db626f1d58
87 changed files with 5213 additions and 2865 deletions
+51
View File
@@ -0,0 +1,51 @@
from __future__ import annotations
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))
from scripts.deploy_adapters import default_codex_home, deploy_codex
def test_default_codex_home_is_external_to_project(tmp_path: Path) -> None:
home = default_codex_home(env={}, user_home=tmp_path)
assert home == tmp_path / ".codex"
assert not home.is_relative_to(REPO_ROOT)
def test_deploy_codex_writes_adapter_to_external_home(tmp_path: Path) -> None:
target = tmp_path / "codex-home"
result = deploy_codex(target=target, force=True, repo_root=REPO_ROOT)
assert result.written
assert not (target / "config.toml").exists()
assert (target / "commands" / "dr-run.md").exists()
assert (target / "agents" / "dr-pm.toml").exists()
assert (target / "skills" / "search-strategy" / "SKILL.md").exists()
assert (target / "skills" / "search-gateway" / "SKILL.md").exists()
assert (target / "skills" / "document-ingest" / "SKILL.md").exists()
assert (target / "skills" / "deep-research" / "SKILL.md").exists()
assert result.target == target
def test_deploy_codex_config_is_explicit_opt_in(tmp_path: Path) -> None:
target = tmp_path / "codex-home"
result = deploy_codex(target=target, force=True, repo_root=REPO_ROOT, include_config=True)
assert result.written
assert (target / "config.toml").exists()
def test_deploy_codex_dry_run_does_not_write(tmp_path: Path) -> None:
target = tmp_path / "codex-home"
result = deploy_codex(target=target, force=True, repo_root=REPO_ROOT, dry_run=True)
assert result.planned
assert not target.exists()
+225
View File
@@ -0,0 +1,225 @@
from __future__ import annotations
import json
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))
from scripts.runtime.assembly import (
ChapterAssemblyWorker,
build_chapter_briefs,
build_chapter_user_prompt,
run_chapter_assembly_workers,
validate_chapter_markdown_citations,
validate_chapter_brief,
)
from scripts.runtime.roles import resolve_runtime_profile
class FakeClient:
def __init__(self, response: str) -> None:
self.response = response
self.calls: list[dict[str, object]] = []
def chat_complete(self, **kwargs) -> str:
self.calls.append(kwargs)
return self.response
class TaggedClient:
def __init__(self, responses_by_tag: dict[str, str]) -> None:
self.responses_by_tag = responses_by_tag
self.calls: list[dict[str, object]] = []
def chat_complete(self, **kwargs) -> str:
self.calls.append(kwargs)
return self.responses_by_tag[str(kwargs["tag"])]
def chapter_brief(chapter_id: str, source_ids: list[str]) -> dict:
return {
"chapter_id": chapter_id,
"chapter_title": "临床证据正在重塑需求判断",
"packet_ids": [f"{chapter_id}-clinical"],
"core_claims": [{"claim": "临床证据支持核心判断", "source_ids": source_ids[:1]}],
"evidence_items": [{"source_id": source_ids[0], "summary": "III 期数据支持主要终点"}],
"counter_evidence": [{"claim": "长期安全性仍需随访", "source_ids": source_ids[-1:]}],
"source_ids": source_ids,
"open_questions": [],
"assembly_notes": ["按金字塔结构组织。"],
}
def write_packet(path: Path, task_id: str, claim: str, source_id: str) -> None:
packet = {
"task_id": task_id,
"claims": [{"claim": claim, "source_ids": [source_id]}],
"evidence_items": [{"source_id": source_id, "summary": f"{claim} 的证据"}],
"counter_evidence": [{"claim": "仍需关注样本量和外推限制", "source_ids": ["src_counter"]}],
"source_ids": [source_id, "src_counter"],
"source_quality_notes": [f"{source_id} Tier 1"],
"open_questions": ["还需要补充中国市场数据"],
"raw_quotes_or_notes": ["English note can remain as source material."],
}
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(packet, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
def test_build_chapter_briefs_aggregates_packets_by_chapter(tmp_path: Path) -> None:
project = tmp_path / "project"
cards = [
{
"task_id": "ch01-clinical",
"chapter_ids": ["ch01"],
"topic_axis": "clinical",
"questions": ["q"],
"search_routes": ["scholar"],
"output_packet": "phase2/packets/ch01-clinical.json",
},
{
"task_id": "ch01-market",
"chapter_ids": ["ch01"],
"topic_axis": "market",
"questions": ["q"],
"search_routes": ["news"],
"output_packet": "phase2/packets/ch01-market.json",
},
]
(project / "phase2").mkdir(parents=True)
(project / "phase2" / "task_cards.json").write_text(json.dumps(cards, ensure_ascii=False), encoding="utf-8")
write_packet(project / "phase2/packets/ch01-clinical.json", "ch01-clinical", "临床证据支持核心判断", "src_001")
write_packet(project / "phase2/packets/ch01-market.json", "ch01-market", "市场数据支持需求增长", "src_002")
briefs = build_chapter_briefs(project)
assert len(briefs) == 1
brief = briefs[0]
validate_chapter_brief(brief)
assert brief["chapter_id"] == "ch01"
assert brief["packet_ids"] == ["ch01-clinical", "ch01-market"]
assert "src_001" in brief["source_ids"]
assert "src_002" in brief["source_ids"]
assert (project / "phase2/chapter_briefs/ch01.json").exists()
def test_chapter_prompt_contains_brief_and_fragmentation_guard() -> None:
brief = {
"chapter_id": "ch01",
"chapter_title": "临床证据正在重塑需求判断",
"packet_ids": ["ch01-clinical"],
"core_claims": [{"claim": "临床证据支持核心判断", "source_ids": ["src_001"]}],
"evidence_items": [{"source_id": "src_001", "summary": "III 期数据支持主要终点"}],
"counter_evidence": [{"claim": "长期安全性仍需随访", "source_ids": ["src_002"]}],
"source_ids": ["src_001", "src_002"],
"open_questions": [],
"assembly_notes": ["避免重复 packet 原文;按金字塔结构组织。"],
}
prompt = build_chapter_user_prompt(brief)
assert "临床证据正在重塑需求判断" in prompt
assert "避免碎片化" in prompt
assert "只输出 Markdown" in prompt
def test_chapter_assembly_worker_writes_markdown(tmp_path: Path) -> None:
runtime = resolve_runtime_profile(profile="medium")
role = runtime.role_for_task("chapter_assembly")
fake = FakeClient("# 第1章 临床证据正在重塑需求判断\n\n结论先行。[src_001]\n")
worker = ChapterAssemblyWorker(role=role, client=fake)
brief = {
"chapter_id": "ch01",
"chapter_title": "临床证据正在重塑需求判断",
"packet_ids": ["ch01-clinical"],
"core_claims": [{"claim": "临床证据支持核心判断", "source_ids": ["src_001"]}],
"evidence_items": [{"source_id": "src_001", "summary": "III 期数据支持主要终点"}],
"counter_evidence": [{"claim": "长期安全性仍需随访", "source_ids": ["src_002"]}],
"source_ids": ["src_001", "src_002"],
"open_questions": [],
"assembly_notes": ["按金字塔结构组织。"],
}
output = worker.write_chapter(project_root=tmp_path, brief=brief)
assert output == tmp_path / "phase2/drafts/ch01.md"
assert "结论先行" in output.read_text(encoding="utf-8")
assert fake.calls[0]["model"] == role.model
def test_validate_chapter_markdown_rejects_unknown_source_ids() -> None:
brief = {
"chapter_id": "ch01",
"chapter_title": "临床证据正在重塑需求判断",
"packet_ids": ["ch01-clinical"],
"core_claims": [{"claim": "临床证据支持核心判断", "source_ids": ["src_001"]}],
"evidence_items": [{"source_id": "src_001", "summary": "III 期数据支持主要终点"}],
"counter_evidence": [{"claim": "长期安全性仍需随访", "source_ids": ["src_002"]}],
"source_ids": ["src_001", "src_002"],
"open_questions": [],
"assembly_notes": ["按金字塔结构组织。"],
}
try:
validate_chapter_markdown_citations("结论引用了不存在的来源。[src_fake]", brief)
except ValueError as exc:
assert "unknown citation ids" in str(exc)
assert "src_fake" in str(exc)
else:
raise AssertionError("unknown source id should fail validation")
def test_chapter_assembly_worker_refuses_to_write_unknown_citations(tmp_path: Path) -> None:
runtime = resolve_runtime_profile(profile="medium")
role = runtime.role_for_task("chapter_assembly")
fake = FakeClient("# 第1章 临床证据正在重塑需求判断\n\n结论先行。[src_fake]\n")
worker = ChapterAssemblyWorker(role=role, client=fake)
brief = {
"chapter_id": "ch01",
"chapter_title": "临床证据正在重塑需求判断",
"packet_ids": ["ch01-clinical"],
"core_claims": [{"claim": "临床证据支持核心判断", "source_ids": ["src_001"]}],
"evidence_items": [{"source_id": "src_001", "summary": "III 期数据支持主要终点"}],
"counter_evidence": [{"claim": "长期安全性仍需随访", "source_ids": ["src_002"]}],
"source_ids": ["src_001", "src_002"],
"open_questions": [],
"assembly_notes": ["按金字塔结构组织。"],
}
try:
worker.write_chapter(project_root=tmp_path, brief=brief)
except ValueError as exc:
assert "unknown citation ids" in str(exc)
else:
raise AssertionError("chapter with unknown citation should not be written")
assert not (tmp_path / "phase2/drafts/ch01.md").exists()
def test_run_chapter_assembly_workers_records_errors_without_aborting_batch(tmp_path: Path) -> None:
runtime = resolve_runtime_profile(profile="medium")
fake = TaggedClient(
{
"chapter:ch01": "# 第1章 临床证据正在重塑需求判断\n\n结论先行。[src_001]\n",
"chapter:ch02": "# 第2章 临床证据存在不确定性\n\n错误引用。[src_fake]\n",
}
)
count = run_chapter_assembly_workers(
project_root=tmp_path,
briefs=[chapter_brief("ch01", ["src_001", "src_002"]), chapter_brief("ch02", ["src_003", "src_004"])],
runtime=runtime,
client_factory=lambda _role: fake,
workers=2,
)
assert count == 1
assert (tmp_path / "phase2/drafts/ch01.md").exists()
assert not (tmp_path / "phase2/drafts/ch02.md").exists()
error_path = tmp_path / "phase2/chapter_errors/ch02.json"
assert error_path.exists()
error = json.loads(error_path.read_text(encoding="utf-8"))
assert error["chapter_id"] == "ch02"
assert error["status"] == "failed"
assert "src_fake" in error["error"]
+100
View File
@@ -0,0 +1,100 @@
from __future__ import annotations
import json
import sys
from pathlib import Path
import scripts.runtime.materials as materials
from reportlab.pdfgen import canvas
REPO_ROOT = Path(__file__).resolve().parents[1]
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))
from scripts.runtime.phase1 import create_project, render_framework
def make_text_pdf(path: Path, text: str) -> None:
c = canvas.Canvas(str(path))
c.drawString(72, 720, text)
c.save()
def test_init_ingests_pdf_material_into_phase0(tmp_path: Path) -> None:
pdf = tmp_path / "audit.pdf"
make_text_pdf(pdf, "GMP audit finding: deviation management is incomplete.")
project = create_project(
topic="白帆生物 GMP 与运营诊断",
slug="baifan-test",
projects_dir=tmp_path / "projects",
method_key="gmp_quality_operations_diagnosis",
input_materials=[str(pdf), "补充说明:运营团队需要同步诊断"],
)
manifest = json.loads((project / "manifest.json").read_text(encoding="utf-8"))
inventory = manifest["material_inventory"]
assert inventory[0]["kind"] == "pdf"
assert inventory[0]["copied_to"] == "phase0/inputs/audit.pdf"
assert inventory[0]["extracted_to"] == "phase0/extracted/audit.md"
assert inventory[0]["ocr_required"] is False
assert "deviation management" in (project / "phase0/extracted/audit.md").read_text(encoding="utf-8")
assert inventory[1]["kind"] == "note"
material_brief = project / "phase1" / "material_brief.md"
assert material_brief.exists()
assert "Phase 0 材料简报" in material_brief.read_text(encoding="utf-8")
assert "待用户确认" in material_brief.read_text(encoding="utf-8")
assert manifest["phase1"]["requires_user_interview"] is True
def test_framework_mentions_ingested_materials(tmp_path: Path) -> None:
pdf = tmp_path / "audit.pdf"
make_text_pdf(pdf, "Quality system audit.")
project = create_project(
topic="白帆生物 GMP 与运营诊断",
slug="baifan-test",
projects_dir=tmp_path / "projects",
method_key="gmp_quality_operations_diagnosis",
input_materials=[str(pdf)],
)
render_framework(project, method_key="gmp_quality_operations_diagnosis")
framework = (project / "phase1/framework.md").read_text(encoding="utf-8")
assert "phase0/extracted/audit.md" in framework
assert "NMPA、FDA、EMA、ICH、WHO" in framework
assert "请先确认 `phase1/material_brief.md`" in framework
def test_pdf_requiring_ocr_uses_firered_and_records_result(tmp_path: Path, monkeypatch) -> None:
pdf = tmp_path / "scan.pdf"
c = canvas.Canvas(str(pdf))
c.showPage()
c.save()
def fake_ocr_pdf(*, pdf_path: Path, output_dir: Path, endpoint: str, max_pages: int) -> materials.OcrResult:
assert pdf_path == pdf
assert endpoint == materials.DEFAULT_FIRERED_OCR_ENDPOINT
out = output_dir / "scan.ocr.md"
out.write_text("# OCR\n\n扫描审计发现:偏差管理未闭环。\n", encoding="utf-8")
return materials.OcrResult(text="扫描审计发现:偏差管理未闭环。", pages_processed=1, output_path=out)
monkeypatch.setattr(materials, "ocr_pdf_with_firered", fake_ocr_pdf)
project = create_project(
topic="白帆生物 GMP 与运营诊断",
slug="baifan-test",
projects_dir=tmp_path / "projects",
method_key="gmp_quality_operations_diagnosis",
input_materials=[str(pdf)],
)
manifest = json.loads((project / "manifest.json").read_text(encoding="utf-8"))
item = manifest["material_inventory"][0]
assert item["ocr_required"] is True
assert item["ocr_status"] == "completed"
assert item["ocr_text_chars"] > 0
assert item["ocr_extracted_to"] == "phase0/extracted/scan.ocr.md"
assert "扫描审计发现" in (project / "phase0/extracted/scan.md").read_text(encoding="utf-8")
+34
View File
@@ -0,0 +1,34 @@
from __future__ import annotations
import json
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))
from scripts.reporting.fonts import resolve_quarto_fonts
from scripts.reporting.references import build_references_block
def test_build_references_block_uses_only_cited_sources(tmp_path: Path) -> None:
sources = tmp_path / "sources.jsonl"
rows = [
{"id": "src_001", "authors": ["A"], "year": 2024, "title": "Used", "venue": "NEJM", "url": "https://example.com/1"},
{"id": "src_002", "authors": ["B"], "year": 2023, "title": "Unused", "venue": "Lancet", "url": "https://example.com/2"},
]
sources.write_text("\n".join(json.dumps(row, ensure_ascii=False) for row in rows), encoding="utf-8")
block = build_references_block(sources, "正文引用 [src_001]。")
assert "Used" in block
assert "Unused" not in block
def test_resolve_quarto_fonts_returns_stable_defaults_for_missing_dir(tmp_path: Path) -> None:
fonts = resolve_quarto_fonts(tmp_path / "missing")
assert fonts.main_font == "Source Han Serif CN"
assert fonts.sans_font == "Source Han Sans CN"
assert fonts.requires_system_fonts is True
+55
View File
@@ -0,0 +1,55 @@
from __future__ import annotations
import json
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))
from scripts.runtime.methods import ResearchMethodRegistry
from scripts.runtime.orchestrator import create_phase2_task_cards
from scripts.runtime.tasks import generate_task_cards
def test_method_registry_loads_market_and_gmp_methods() -> None:
registry = ResearchMethodRegistry()
names = registry.list_names()
assert "mckinsey_market" in names
assert "gmp_gap_assessment" in names
assert registry.get("gmp_gap_assessment").task_axes[0] == "regulatory_gap"
def test_task_cards_use_method_axes() -> None:
framework = "## 第1章 GMP 审计差距决定整改优先级\n\n研究思路:法规、风险、CAPA。"
registry = ResearchMethodRegistry()
method = registry.get("gmp_gap_assessment")
cards = generate_task_cards("gmp-test", framework, method=method)
assert [card.topic_axis for card in cards] == method.task_axes
assert cards[0].task_id == "ch01-regulatory_gap"
def test_orchestrator_reads_research_method_from_manifest(tmp_path: Path) -> None:
project = tmp_path / "gmp-project"
(project / "phase1").mkdir(parents=True)
(project / "manifest.json").write_text(
json.dumps({"research_method": "gmp_gap_assessment", "phase2": {}}, ensure_ascii=False),
encoding="utf-8",
)
(project / "phase1" / "framework.md").write_text(
"## 第1章 GMP 体系差距需要按法规和风险双轴定位\n\n研究思路。",
encoding="utf-8",
)
cards = create_phase2_task_cards(project, dry_run=True)
assert [card["topic_axis"] for card in cards][:3] == [
"regulatory_gap",
"risk_classification",
"capa_design",
]
+115
View File
@@ -0,0 +1,115 @@
from __future__ import annotations
import json
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))
from scripts.runtime.roles import resolve_runtime_profile
from scripts.runtime.sources import append_packet_sources, rebuild_sources_from_packets
from scripts.runtime.tasks import TaskCard
from scripts.runtime.workers import PacketWorker, build_search_context
class FakeSearchProvider:
def search(self, *, query: str, route: str, num_results: int):
return [
{
"title": f"{route} result for {query}",
"url": f"https://example.com/{route}",
"snippet": "候选证据摘要",
"route": route,
}
][:num_results]
class FakeClient:
def __init__(self, response: dict) -> None:
self.response = response
self.calls: list[dict[str, object]] = []
def chat_complete(self, **kwargs) -> str:
self.calls.append(kwargs)
return json.dumps(self.response, ensure_ascii=False)
def sample_card() -> TaskCard:
return TaskCard(
task_id="ch01-literature",
chapter_ids=["ch01"],
topic_axis="literature",
questions=["围绕临床证据提炼结论。"],
search_routes=["scholar", "general"],
output_packet="phase2/packets/ch01-literature.json",
)
def test_build_search_context_assigns_stable_source_ids() -> None:
context = build_search_context(sample_card(), FakeSearchProvider(), num_results_per_route=1)
assert [source["id"] for source in context["candidate_sources"]] == [
"src_ch01_literature_001",
"src_ch01_literature_002",
]
assert context["routes_used"] == ["scholar", "general"]
def test_packet_worker_includes_search_context_in_prompt() -> None:
context = build_search_context(sample_card(), FakeSearchProvider(), num_results_per_route=1)
response = {
"task_id": "ch01-literature",
"claims": [{"claim": "候选证据支持判断", "source_ids": ["src_ch01_literature_001"]}],
"evidence_items": [{"source_id": "src_ch01_literature_001", "summary": "摘要"}],
"counter_evidence": [{"claim": "仍需更多数据", "source_ids": ["src_ch01_literature_002"]}],
"source_ids": ["src_ch01_literature_001", "src_ch01_literature_002"],
"sources": context["candidate_sources"],
"source_quality_notes": ["候选来源需要后续评级"],
"open_questions": [],
"raw_quotes_or_notes": [],
}
role = resolve_runtime_profile(profile="medium").role_for_task("evidence_packet")
fake = FakeClient(response)
packet = PacketWorker(role=role, client=fake, search_provider=FakeSearchProvider()).run(sample_card())
assert packet["sources"][0]["url"] == "https://example.com/scholar"
assert "candidate_sources" in fake.calls[0]["user"]
def test_append_packet_sources_dedupes_by_url(tmp_path: Path) -> None:
packet = {
"sources": [
{"id": "src_a", "title": "A", "url": "https://example.com/a", "tier": "Tier 2", "score": 7},
{"id": "src_b", "title": "B", "url": "https://example.com/a", "tier": "Tier 2", "score": 7},
]
}
written = append_packet_sources(tmp_path / "sources.jsonl", packet)
assert written == 1
assert len((tmp_path / "sources.jsonl").read_text(encoding="utf-8").splitlines()) == 1
def test_rebuild_sources_from_packets_dedupes_manual_packets(tmp_path: Path) -> None:
project = tmp_path / "project"
packets = project / "phase2" / "packets"
packets.mkdir(parents=True)
packet = {
"sources": [
{"id": "src_001", "title": "A", "url": "https://example.com/a"},
{"id": "src_002", "title": "A duplicate", "url": "https://example.com/a"},
{"id": "src_003", "title": "Local", "url": "phase0/extracted/local.md"},
]
}
(packets / "ch01-a.json").write_text(json.dumps(packet, ensure_ascii=False), encoding="utf-8")
count = rebuild_sources_from_packets(project)
lines = (project / "phase2" / "sources.jsonl").read_text(encoding="utf-8").splitlines()
assert count == 2
assert len(lines) == 2
assert "src_001" in lines[0]
assert "src_003" in lines[1]
+225
View File
@@ -0,0 +1,225 @@
from __future__ import annotations
import sys
import json
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))
import scripts.dr as dr
def test_parser_exposes_python_core_commands() -> None:
parser = dr.build_parser()
assert parser.parse_args(["init", "ADC 市场研究"]).cmd == "init"
assert parser.parse_args(["approve", "demo"]).cmd == "approve"
assert parser.parse_args(["frame", "demo"]).cmd == "frame"
assert parser.parse_args(["review", "demo"]).cmd == "review"
assert parser.parse_args(["skills", "list"]).cmd == "skills"
assert parser.parse_args(["methods", "list"]).cmd == "methods"
assert parser.parse_args(["research", "demo", "--workers", "6", "--dry-run"]).cmd == "research"
assert parser.parse_args(["research", "demo", "--execute-packets"]).execute_packets is True
assert parser.parse_args(["research", "demo", "--allow-search-fallback"]).allow_search_fallback is True
assert parser.parse_args(["research", "demo", "--build-briefs"]).build_briefs is True
assert parser.parse_args(["research", "demo", "--assemble-chapters"]).assemble_chapters is True
assert parser.parse_args(["run", "demo", "--dry-run"]).cmd == "run"
assert parser.parse_args(["finalize", "demo", "--legacy-translate", "--dry-run"]).legacy_translate is True
def test_models_json_includes_task_types(capsys) -> None:
args = dr.build_parser().parse_args(["models", "--profile", "medium", "--json"])
assert dr.cmd_models(args) == 0
out = capsys.readouterr().out
assert '"task_types"' in out
assert '"evidence_packet"' in out
def test_prompt_uses_canonical_codex_template(capsys) -> None:
args = dr.build_parser().parse_args(["prompt", "dr-run", "demo"])
assert dr.cmd_prompt(args) == 0
out = capsys.readouterr().out
assert "surface adapter for v0.20" in out
assert "Do not spawn Codex subagents" in out
def test_research_build_briefs_does_not_overwrite_existing_packets(tmp_path: Path) -> None:
project = tmp_path / "project"
(project / "phase1").mkdir(parents=True)
(project / "phase2/packets").mkdir(parents=True)
(project / "manifest.json").write_text(
'{"research_method": "mckinsey_market", "phase1": {"approved": true}, "phase2": {}}\n',
encoding="utf-8",
)
(project / "phase1/framework.md").write_text(
"## 第1章 临床证据正在重塑需求判断\n\n研究思路。",
encoding="utf-8",
)
packet_path = project / "phase2/packets/ch01-literature.json"
packet = {
"task_id": "ch01-literature",
"claims": [{"claim": "真实证据不能被 skeleton 覆盖", "source_ids": ["src_001"]}],
"evidence_items": [{"source_id": "src_001", "summary": "证据"}],
"counter_evidence": [{"claim": "限制", "source_ids": ["src_002"]}],
"source_ids": ["src_001", "src_002"],
"source_quality_notes": ["src_001 Tier 1"],
"open_questions": [],
"raw_quotes_or_notes": [],
}
packet_path.write_text(json.dumps(packet, ensure_ascii=False), encoding="utf-8")
args = dr.build_parser().parse_args(["research", str(project), "--build-briefs"])
assert dr.cmd_research(args) == 0
assert "真实证据不能被 skeleton 覆盖" in packet_path.read_text(encoding="utf-8")
def test_packet_state_counts_ignores_stale_errors_for_ready_packets(tmp_path: Path) -> None:
project = tmp_path / "project"
(project / "phase2/packets").mkdir(parents=True)
(project / "phase2/packet_errors").mkdir(parents=True)
packet = {
"task_id": "ch01-literature",
"claims": [{"claim": "已补齐", "source_ids": ["src_001"]}],
"evidence_items": [{"source_id": "src_001", "summary": "证据"}],
"counter_evidence": [{"claim": "限制", "source_ids": ["src_001"]}],
"source_ids": ["src_001"],
"source_quality_notes": ["Tier 1"],
"open_questions": [],
"raw_quotes_or_notes": [],
"sources": [{"id": "src_001", "title": "来源", "url": "https://example.com"}],
}
(project / "phase2/packets/ch01-literature.json").write_text(
json.dumps(packet, ensure_ascii=False),
encoding="utf-8",
)
(project / "phase2/packet_errors/ch01-literature.json").write_text(
'{"status":"failed"}\n',
encoding="utf-8",
)
counts = dr.packet_state_counts(project)
assert counts["ready"] == 1
assert counts["errors"] == 0
assert counts["stale_errors"] == 1
def test_run_existing_project_delegates_to_research_without_missing_args(tmp_path: Path) -> None:
project = tmp_path / "project"
(project / "phase1").mkdir(parents=True)
(project / "manifest.json").write_text(
'{"research_method": "mckinsey_market", "phase1": {"approved": true}, "phase2": {}}\n',
encoding="utf-8",
)
(project / "phase1/framework.md").write_text(
"## 第1章 临床证据正在重塑需求判断\n\n研究思路。",
encoding="utf-8",
)
args = dr.build_parser().parse_args(["run", str(project), "--workers", "2"])
assert dr.cmd_run(args) == 0
assert (project / "phase2/task_cards.json").exists()
def test_research_requires_phase1_approval_unless_overridden(tmp_path: Path) -> None:
project = tmp_path / "project"
(project / "phase1").mkdir(parents=True)
(project / "manifest.json").write_text(
'{"research_method": "mckinsey_market", "phase1": {"approved": false}, "phase2": {}}\n',
encoding="utf-8",
)
(project / "phase1/framework.md").write_text(
"## 第1章 临床证据正在重塑需求判断\n\n研究思路。",
encoding="utf-8",
)
args = dr.build_parser().parse_args(["research", str(project)])
try:
dr.cmd_research(args)
except SystemExit as exc:
assert "Phase 1 is not approved" in str(exc)
else:
raise AssertionError("research should require phase1 approval by default")
override = dr.build_parser().parse_args(["research", str(project), "--force"])
assert dr.cmd_research(override) == 0
def test_init_and_frame_create_executable_python_core_project(tmp_path: Path) -> None:
args = dr.build_parser().parse_args(
[
"init",
"ADC 全球竞争格局",
"--slug",
"adc-global-landscape",
"--method",
"mckinsey_market",
"--projects-dir",
str(tmp_path),
]
)
assert dr.cmd_init(args) == 0
project = tmp_path / "adc-global-landscape"
manifest = json.loads((project / "manifest.json").read_text(encoding="utf-8"))
assert manifest["topic"] == "ADC 全球竞争格局"
assert manifest["research_method"] == "mckinsey_market"
assert manifest["work_language"] == "zh"
frame_args = dr.build_parser().parse_args(["frame", str(project)])
assert dr.cmd_frame(frame_args) == 0
framework = (project / "phase1/framework.md").read_text(encoding="utf-8")
assert "research_method: mckinsey_market" in framework
assert "## 第1章" in framework
assert "中文" in framework
def test_review_writes_phase3_critique(tmp_path: Path) -> None:
project = tmp_path / "project"
(project / "phase1").mkdir(parents=True)
(project / "phase2/drafts").mkdir(parents=True)
(project / "manifest.json").write_text(
'{"topic": "测试项目", "research_method": "mckinsey_market", "phase3": {}}\n',
encoding="utf-8",
)
(project / "phase2/drafts/ch01.md").write_text("## 观点标题\n\n正文。[src_001]\n", encoding="utf-8")
(project / "phase2/sources.jsonl").write_text('{"id":"src_001","title":"来源"}\n', encoding="utf-8")
args = dr.build_parser().parse_args(["review", str(project)])
assert dr.cmd_review(args) == 0
critique = project / "phase3/critique.md"
assert critique.exists()
text = critique.read_text(encoding="utf-8")
assert "Phase 3 审校" in text
assert "src_001" in text
def test_run_new_topic_initializes_and_frames_project(tmp_path: Path) -> None:
args = dr.build_parser().parse_args(
[
"run",
"GMP 整改咨询",
"--slug",
"gmp-remediation",
"--method",
"gmp_gap_assessment",
"--projects-dir",
str(tmp_path),
]
)
assert dr.cmd_run(args) == 0
project = tmp_path / "gmp-remediation"
assert (project / "manifest.json").exists()
assert (project / "phase1/framework.md").exists()
+142
View File
@@ -0,0 +1,142 @@
from __future__ import annotations
import json
import sys
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parents[1]
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))
from scripts.lib.model_config import resolve_model_profile
from scripts.runtime.roles import resolve_runtime_profile
from scripts.runtime.skills import SkillRegistry
from scripts.runtime.tasks import (
TaskCard,
detect_dependency_cycles,
generate_task_cards,
validate_packet,
validate_task_cards,
)
def test_skill_registry_uses_agents_skills_as_canonical() -> None:
registry = SkillRegistry()
names = registry.list_names()
assert "search-strategy" in names
assert "search-gateway" in names
assert "source-quality" in names
assert "document-ingest" in names
assert "deep-research" in names
assert registry.validate()["ok"] is True
def test_model_profile_exposes_task_types_and_role_defaults() -> None:
resolved = resolve_model_profile(profile="medium")
assert resolved["roles"]["dr_pm"]
assert resolved["task_types"]["source_discovery"] == "dr_searcher"
assert resolved["task_types"]["chapter_assembly"] == "dr_analyst"
def test_runtime_profile_resolves_task_model_and_skills() -> None:
runtime = resolve_runtime_profile(profile="medium")
worker = runtime.role_for_task("evidence_packet")
assert worker.name == "dr_analyst"
assert worker.model
assert "evidence-table" in worker.skills
assert "search-gateway" in worker.skills
assert worker.max_concurrency >= 1
def test_generate_task_cards_from_chinese_framework() -> None:
framework = """
# 研究框架
## 第1章 GLP-1 产业链的增量来自适应症扩张
研究思路:围绕临床、监管、竞争格局和生产供应链展开。
## 第2章 供应链瓶颈决定国产替代窗口
研究思路:围绕专利、上游原料、产能和中国市场展开。
"""
cards = generate_task_cards("glp1-test", framework, axes=["clinical", "regulatory"])
assert [card.task_id for card in cards] == [
"ch01-clinical",
"ch01-regulatory",
"ch02-clinical",
"ch02-regulatory",
]
assert cards[0].output_packet == "phase2/packets/ch01-clinical.json"
def test_task_card_validation_rejects_duplicates_and_cycles() -> None:
cards = [
TaskCard(task_id="a", chapter_ids=["ch01"], topic_axis="clinical", questions=["q"], search_routes=["scholar"], output_packet="phase2/packets/a.json", dependencies=["b"]),
TaskCard(task_id="b", chapter_ids=["ch01"], topic_axis="regulatory", questions=["q"], search_routes=["general"], output_packet="phase2/packets/b.json", dependencies=["a"]),
]
with pytest.raises(ValueError, match="dependency cycle"):
detect_dependency_cycles(cards)
with pytest.raises(ValueError, match="duplicate task_id"):
validate_task_cards([cards[0], cards[0]])
def test_packet_validation_requires_sources_and_counter_evidence() -> None:
packet = {
"task_id": "ch01-clinical",
"claims": [{"claim": "结论", "source_ids": ["src_001"]}],
"evidence_items": [{"source_id": "src_001", "summary": "证据"}],
"counter_evidence": [],
"source_ids": ["src_001"],
"source_quality_notes": ["Tier 1"],
"open_questions": [],
"raw_quotes_or_notes": ["Original English excerpt allowed."],
}
with pytest.raises(ValueError, match="counter_evidence"):
validate_packet(packet)
packet["counter_evidence"] = [{"claim": "限制", "source_ids": ["src_002"]}]
with pytest.raises(ValueError, match="not declared"):
validate_packet(packet)
packet["source_ids"].append("src_002")
validate_packet(packet)
def test_skill_sync_copies_to_adapter_dirs(tmp_path: Path) -> None:
canonical = tmp_path / "skills"
target = tmp_path / "adapter" / "skills"
source_skill = canonical / "demo"
source_skill.mkdir(parents=True)
(source_skill / "SKILL.md").write_text("---\nname: demo\n---\n\nBody\n", encoding="utf-8")
registry = SkillRegistry(canonical_dir=canonical)
copied = registry.sync_to([target])
assert copied == 1
assert (target / "demo" / "SKILL.md").read_text(encoding="utf-8").endswith("Body\n")
def test_skill_sync_skips_canonical_dir_to_avoid_deleting_source(tmp_path: Path) -> None:
canonical = tmp_path / "skills"
source_skill = canonical / "demo"
source_skill.mkdir(parents=True)
(source_skill / "SKILL.md").write_text("---\nname: demo\n---\n\nBody\n", encoding="utf-8")
registry = SkillRegistry(canonical_dir=canonical)
copied = registry.sync_to([canonical])
assert copied == 0
assert (source_skill / "SKILL.md").exists()
+165
View File
@@ -0,0 +1,165 @@
from __future__ import annotations
import json
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))
from scripts.runtime.roles import resolve_runtime_profile
from scripts.runtime.tasks import TaskCard, validate_packet
from scripts.runtime.workers import PacketWorker, build_packet_user_prompt, run_packet_workers
class FakeClient:
def __init__(self, response: dict) -> None:
self.response = response
self.calls: list[dict[str, object]] = []
def chat_complete(self, **kwargs) -> str:
self.calls.append(kwargs)
return json.dumps(self.response, ensure_ascii=False)
class SequenceClient:
def __init__(self, responses: list[str]) -> None:
self.responses = responses
self.calls: list[dict[str, object]] = []
def chat_complete(self, **kwargs) -> str:
self.calls.append(kwargs)
if self.responses:
return self.responses.pop(0)
return "not json"
class TaggedClient:
def __init__(self, responses_by_tag: dict[str, str]) -> None:
self.responses_by_tag = responses_by_tag
self.calls: list[dict[str, object]] = []
def chat_complete(self, **kwargs) -> str:
self.calls.append(kwargs)
return self.responses_by_tag.get(str(kwargs.get("tag")), "not json")
def sample_card() -> TaskCard:
return TaskCard(
task_id="ch01-clinical",
chapter_ids=["ch01"],
topic_axis="clinical",
questions=["围绕临床证据提炼结论。"],
search_routes=["scholar", "general"],
output_packet="phase2/packets/ch01-clinical.json",
)
def second_card() -> TaskCard:
return TaskCard(
task_id="ch02-market",
chapter_ids=["ch02"],
topic_axis="market",
questions=["围绕市场证据提炼结论。"],
search_routes=["news", "general"],
output_packet="phase2/packets/ch02-market.json",
)
def valid_response() -> dict:
return {
"task_id": "ch01-clinical",
"claims": [{"claim": "临床证据支持需求增长", "source_ids": ["src_001"]}],
"evidence_items": [{"source_id": "src_001", "summary": "III 期结果支持主要终点。"}],
"counter_evidence": [{"claim": "长期安全性仍需随访", "source_ids": ["src_002"]}],
"source_ids": ["src_001", "src_002"],
"source_quality_notes": ["src_001 Tier 1; src_002 Tier 2"],
"open_questions": [],
"raw_quotes_or_notes": ["Original English evidence note is allowed."],
}
def test_build_packet_prompt_contains_card_and_chinese_policy() -> None:
prompt = build_packet_user_prompt(sample_card())
assert "ch01-clinical" in prompt
assert "中文" in prompt
assert "scholar" in prompt
def test_packet_worker_generates_valid_packet_with_fake_client(tmp_path: Path) -> None:
runtime = resolve_runtime_profile(profile="medium")
role = runtime.role_for_task("evidence_packet")
fake = FakeClient(valid_response())
worker = PacketWorker(role=role, client=fake)
packet = worker.run(sample_card())
validate_packet(packet)
assert fake.calls[0]["model"] == role.model
assert "search-strategy" in fake.calls[0]["system"]
def test_run_packet_workers_writes_packet_files(tmp_path: Path) -> None:
project = tmp_path / "project"
fake = FakeClient(valid_response())
runtime = resolve_runtime_profile(profile="medium")
count = run_packet_workers(
project_root=project,
cards=[sample_card()],
runtime=runtime,
client_factory=lambda _role: fake,
workers=2,
)
packet_path = project / "phase2/packets/ch01-clinical.json"
assert count == 1
assert packet_path.exists()
validate_packet(json.loads(packet_path.read_text(encoding="utf-8")))
def test_packet_worker_repairs_malformed_json_once() -> None:
runtime = resolve_runtime_profile(profile="medium")
role = runtime.role_for_task("evidence_packet")
fake = SequenceClient(["这里是说明,不是 JSON", json.dumps(valid_response(), ensure_ascii=False)])
worker = PacketWorker(role=role, client=fake)
packet = worker.run(sample_card())
validate_packet(packet)
assert len(fake.calls) == 2
assert "修复" in str(fake.calls[1]["user"])
assert fake.calls[1]["temperature"] == 0
assert fake.calls[1]["tag"] == "packet-repair:ch01-clinical"
def test_run_packet_workers_writes_error_file_without_aborting_batch(tmp_path: Path) -> None:
project = tmp_path / "project"
runtime = resolve_runtime_profile(profile="medium")
valid = json.dumps(valid_response(), ensure_ascii=False)
fake = TaggedClient(
{
"packet:ch01-clinical": valid,
"packet:ch02-market": "not json",
"packet-repair:ch02-market": "still not json",
}
)
count = run_packet_workers(
project_root=project,
cards=[sample_card(), second_card()],
runtime=runtime,
client_factory=lambda _role: fake,
workers=2,
)
assert count == 1
assert (project / "phase2/packets/ch01-clinical.json").exists()
error_path = project / "phase2/packet_errors/ch02-market.json"
assert error_path.exists()
error = json.loads(error_path.read_text(encoding="utf-8"))
assert error["task_id"] == "ch02-market"
assert error["status"] == "failed"
assert "worker response does not contain a JSON object" in error["error"]
+68
View File
@@ -0,0 +1,68 @@
from scripts.lib.zenmux_client import ZenMuxClient, normalize_zenmux_model
def test_normalize_zenmux_adapter_models() -> None:
assert normalize_zenmux_model("zenmux/openai/gpt-5.4-mini") == "openai/gpt-5.4-mini"
assert (
normalize_zenmux_model("zenmux/google/gemini-3.1-pro-preview")
== "google/gemini-3.1-pro-preview"
)
assert (
normalize_zenmux_model("zenmux-anthropic/claude-sonnet-4-6")
== "anthropic/claude-sonnet-4.6"
)
assert (
normalize_zenmux_model("anthropic/claude-opus-4.7")
== "anthropic/claude-opus-4.7"
)
class _FakeResponse:
status_code = 200
text = '{"choices":[{"message":{"content":"OK"}}]}'
def json(self) -> dict:
return {"choices": [{"message": {"content": "OK"}}], "usage": {}}
class _RecordingClient:
def __init__(self) -> None:
self.bodies: list[dict] = []
def post(self, _url: str, *, json: dict, headers: dict) -> _FakeResponse:
self.bodies.append(json)
return _FakeResponse()
def test_opus_47_probe_omits_deprecated_temperature_param() -> None:
client = ZenMuxClient(api_key="test")
recorder = _RecordingClient()
client._client = recorder # type: ignore[assignment]
client.chat_complete(
model="zenmux-anthropic/claude-opus-4-7",
system="Health check.",
user="Reply OK.",
temperature=0,
max_tokens=16,
)
assert recorder.bodies[0]["model"] == "anthropic/claude-opus-4.7"
assert "temperature" not in recorder.bodies[0]
def test_sonnet_keeps_temperature_param() -> None:
client = ZenMuxClient(api_key="test")
recorder = _RecordingClient()
client._client = recorder # type: ignore[assignment]
client.chat_complete(
model="zenmux-anthropic/claude-sonnet-4-6",
system="Health check.",
user="Reply OK.",
temperature=0.2,
max_tokens=16,
)
assert recorder.bodies[0]["model"] == "anthropic/claude-sonnet-4.6"
assert recorder.bodies[0]["temperature"] == 0.2