v0.20 alpha skill-driven python core
This commit is contained in:
@@ -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()
|
||||
@@ -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"]
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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]
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user