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"], "sources": [ {"id": "src_001", "title": "来源1", "url": "https://example.com/1"}, {"id": "src_002", "title": "来源2", "url": "https://example.com/2"}, ], "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 "章节证据分析师" in fake.calls[0]["system"] 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"]