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.methods import ResearchMethodRegistry from scripts.runtime.phase1 import build_chapter_planning from scripts.runtime.skills import SkillRegistry from scripts.runtime.tasks import ( TaskCard, detect_dependency_cycles, generate_task_cards, generate_task_cards_from_research_brief, 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 "antigravity-surface-adapter" in names assert "method-selection" in names assert "research-quality-gates" 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" assert cards[0].chapter_title == "GLP-1 产业链的增量来自适应症扩张" 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_gmp_task_cards_include_fda_enforcement_route() -> None: brief = { "research_method": "gmp_quality_operations_diagnosis", "task_planning": { "search_routes_by_axis": { "quality_system_gap": ["fda", "general"], }, }, } framework = "## 第1章 偏差和 CAPA 闭环能力决定质量体系可信度\n\n研究思路。" cards = generate_task_cards_from_research_brief( "baifan-test", framework, brief, axes=["quality_system_gap"], ) assert cards[0].search_routes == ["fda", "general"] assert "FDA Warning Letters" in " ".join(cards[0].questions) assert "fda_warning_letter_or_meeting_record" in cards[0].expected_evidence["preferred_evidence_types"] def test_integrated_chapter_mode_is_method_driven_not_gmp_hardcoded() -> None: brief = { "research_method": "mckinsey_market", "phase2_mode": "chapter_integrated", "task_planning": {}, } framework = "## 第1章 市场需求正在被支付政策重塑\n\n研究思路。" cards = generate_task_cards_from_research_brief( "market-test", framework, brief, ) assert [card.task_id for card in cards] == ["ch01-chapter_integrated"] assert "literature evidence" in " ".join(cards[0].questions) assert "FDA Warning Letters" not in " ".join(cards[0].questions) def test_integrated_task_card_uses_phase1_chapter_planning() -> None: brief = { "research_method": "gmp_quality_operations_diagnosis", "phase2_mode": "chapter_integrated", "chapter_planning": [ { "chapter_id": "ch01", "title": "审计发现应先转化为商业化阶段门缺口", "core_question": "本章要判断审计发现是否反映阶段门缺口。", "bold_hypothesis": "大胆假设:风险项计数低估了商业化 readiness 缺口。", "verification_plan": ["提取现场材料原文", "检索官方法规和执法案例"], "evidence_lanes": ["site audit findings", "official baseline"], "minimum_evidence": {"local_material_quotes": 2}, "phase2_prompt_context": "章节:ch01\n必须围绕阶段门缺口求证。", } ], "task_planning": {"phase2_mode": "chapter_integrated"}, } framework = "## 第1章 审计发现应先转化为商业化阶段门缺口\n\n研究思路。" cards = generate_task_cards_from_research_brief("baifan-test", framework, brief) assert cards[0].prompt_brief == "章节:ch01\n必须围绕阶段门缺口求证。" assert cards[0].research_goal == "本章要判断审计发现是否反映阶段门缺口。" assert "大胆假设:风险项计数低估了商业化 readiness 缺口。" in cards[0].questions assert cards[0].expected_evidence["phase1_minimum_evidence"] == {"local_material_quotes": 2} assert cards[0].expected_evidence["must_address_phase1_hypothesis"] is True assert any("Phase1 的大胆假设" in item for item in cards[0].stop_conditions) def test_phase1_gmp_hypotheses_are_not_title_restatements(tmp_path: Path) -> None: project = tmp_path / "baifan" project.mkdir() (project / "phase0/extracted").mkdir(parents=True) material = project / "phase0/extracted/audit.md" material.write_text( "人员培训记录齐全,但无菌操作动作违反 First Air 原则,需要进一步培训。\n" "复盘显示 owner、关闭证据和问题升级机制仍需补齐。\n", encoding="utf-8", ) manifest = { "topic": "白帆生物 GMP 与运营诊断", "material_inventory": [{"extracted_to": "phase0/extracted/audit.md"}], } method = ResearchMethodRegistry().get("gmp_quality_operations_diagnosis") plans = build_chapter_planning( project, manifest, method, ["人员能力:培训有效性比培训记录更关键", "运营节奏:从临时协调转向管理系统"], quota=2000, ) assert "关键解释变量" not in plans[0]["bold_hypothesis"] assert "不缺培训台账" in plans[0]["bold_hypothesis"] assert "固定节奏和可视化管理系统" in plans[1]["bold_hypothesis"] assert plans[0]["core_question"] != plans[0]["title"] assert "章节成稿应围绕这一观点展开" not in plans[1]["writing_claim"] def test_research_brief_without_materials_falls_back_to_material_digest() -> None: brief = { "research_method": "mckinsey_market", "phase2_mode": "chapter_integrated", "phase1_inputs": {"material_digest": "phase1/material_digest.md"}, } framework = "## 第1章 市场需求正在被支付政策重塑\n\n研究思路。" cards = generate_task_cards_from_research_brief("market-test", framework, brief) assert cards[0].allowed_materials == ["phase1/material_digest.md"] 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") packet["sources"] = [ {"id": "src_001", "title": "来源1", "url": "https://example.com/1"}, {"id": "src_002", "title": "来源2", "url": "https://example.com/2"}, ] 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()