"""Task-card and evidence-packet primitives for v0.20 Phase 2.""" from __future__ import annotations import json import re from dataclasses import asdict, dataclass, field from pathlib import Path from typing import Any from scripts.runtime.methods import ResearchMethod VALID_ROUTES = {"general", "evidence", "scholar", "patents", "news", "fda"} DEFAULT_AXES = ["literature", "regulatory", "patents", "market", "counter"] AXIS_ROUTES = { "literature": ["scholar", "evidence", "general"], "clinical": ["scholar", "evidence", "general"], "regulatory": ["fda", "evidence", "general", "news"], "patents": ["patents", "evidence", "general"], "market": ["news", "general"], "china": ["news", "general"], "counter": ["fda", "scholar", "evidence", "general"], "regulatory_gap": ["fda", "evidence", "general", "news"], "risk_classification": ["evidence", "general", "scholar"], "capa_design": ["evidence", "general", "news"], "ownership_timeline": ["general"], "verification_evidence": ["fda", "evidence", "general", "scholar"], "process_flow": ["scholar", "evidence", "general"], "cqa_cpp": ["scholar", "evidence", "general"], "scale_up_risk": ["scholar", "evidence", "general"], "control_strategy": ["scholar", "evidence", "general"], "supply_chain": ["news", "general"], "scientific_rationale": ["scholar", "evidence", "general"], "poc_evidence": ["scholar", "evidence", "general"], "ip_fto": ["patents", "evidence", "general"], "development_path": ["scholar", "evidence", "general"], "commercial_window": ["news", "general"], "current_state": ["evidence", "general"], "capability_gap": ["evidence", "general"], "operating_model": ["evidence", "general"], "governance": ["evidence", "general"], "implementation_roadmap": ["evidence", "general"], "nmpa_fda_ema_ich_who_baseline": ["fda", "evidence", "general", "news"], "quality_system_gap": ["fda", "evidence", "general"], "manufacturing_process_risk": ["fda", "scholar", "evidence", "general"], "operations_management_gap": ["fda", "evidence", "general"], "team_capability": ["evidence", "general", "news"], "capa_roadmap": ["fda", "evidence", "general"], "input_material_findings": ["evidence", "general"], "fda_enforcement_precedents": ["fda"], "chapter_integrated": ["fda", "scholar", "evidence", "general"], } @dataclass class Chapter: chapter_id: str index: int title: str notes: str = "" @dataclass class TaskCard: task_id: str chapter_ids: list[str] topic_axis: str questions: list[str] search_routes: list[str] output_packet: str chapter_title: str = "" preferred_model_role: str = "dr_analyst" status: str = "pending" dependencies: list[str] = field(default_factory=list) research_goal: str = "" research_method: str = "" prompt_brief: str = "" required_skills: list[str] = field(default_factory=list) allowed_materials: list[str] = field(default_factory=list) expected_evidence: dict[str, Any] = field(default_factory=dict) stop_conditions: list[str] = field(default_factory=list) model_hint: str = "" def to_dict(self) -> dict[str, Any]: return asdict(self) def parse_framework_chapters(framework_text: str) -> list[Chapter]: """Extract Chinese or English chapter headings from a framework markdown.""" lines = framework_text.splitlines() chapters: list[Chapter] = [] current: Chapter | None = None note_lines: list[str] = [] heading_re = re.compile( r"^#{1,3}\s*(?:第\s*)?(\d{1,2})\s*(?:章|[.)、:-])?\s*(.+?)\s*$", re.IGNORECASE, ) english_re = re.compile(r"^#{1,3}\s*chapter\s+(\d{1,2})[:.)\s-]+(.+?)\s*$", re.IGNORECASE) for line in lines: match = heading_re.match(line.strip()) or english_re.match(line.strip()) if match: if current: current.notes = "\n".join(note_lines).strip() chapters.append(current) index = int(match.group(1)) title = match.group(2).strip(" #") current = Chapter(chapter_id=f"ch{index:02d}", index=index, title=title) note_lines = [] elif current: note_lines.append(line) if current: current.notes = "\n".join(note_lines).strip() chapters.append(current) return chapters def _questions_for_axis(chapter: Chapter, axis: str, method: ResearchMethod | None = None) -> list[str]: if axis == "chapter_integrated": lanes = ";".join(method.integrated_lanes if method else []) return [ f"围绕《{chapter.title}》形成章节级综合证据包,不再拆成孤立小轴。", f"必须按当前 research_method 的 evidence lanes 组织证据:{lanes or '本地材料、权威来源、反方证据、可执行建议'}。", "若项目有用户材料,必须先读取本地材料证据并提取原文;再用本方法适用的权威来源交叉验证。", "必须形成:材料/事实基线、外部权威证据、差距或机会判断、反方/限制条件、可执行建议和待补证据。", ] questions = [ f"围绕《{chapter.title}》从 {axis} 角度提炼可证伪的核心结论。", "至少寻找两个 Tier 1-2 来源支撑主要结论;不足时标注待验证。", "主动检索反方证据、限制条件或失败案例。", ] if axis in { "nmpa_fda_ema_ich_who_baseline", "quality_system_gap", "manufacturing_process_risk", "operations_management_gap", "capa_roadmap", "verification_evidence", "counter", "fda_enforcement_precedents", }: questions.append( "必须检索并优先评估 FDA Warning Letters、inspection/enforcement 页面、会议纪要或 meeting materials,作为 GMP 缺陷严重度和整改优先级的佐证。" ) return questions def _default_required_skills(axis: str) -> list[str]: skills = ["search-gateway", "search-strategy", "source-quality", "evidence-table"] if axis == "counter": skills.append("mckinsey-method") return skills def _default_expected_evidence(axis: str) -> dict[str, Any]: expected = { "min_tier_1_2_sources": 2, "must_include_counter_evidence": True, "must_include_source_metadata": True, "preferred_evidence_types": [ "regulatory_or_best_practice_requirement", "fda_warning_letter_or_meeting_record", "site_or_material_finding", "quantitative_fact_or_record", "implementation_or_verification_evidence", ], "axis": axis, } if axis == "chapter_integrated": expected.update( { "min_local_material_evidence": 2, "min_official_sources": 2, "min_fda_or_regulatory_precedents": 1, "min_capa_actions": 3, "preferred_evidence_types": [ "local_audit_or_recap_quote", "official_regulatory_requirement", "fda_warning_letter_or_meeting_record", "gap_analysis", "capa_action_with_owner_and_verification", "counter_evidence_or_boundary_condition", ], } ) return expected def _default_stop_conditions() -> list[str]: return [ "已形成至少 3 条可追溯 evidence_items,且每条关键 claim 有 source_id。", "已主动记录 counter_evidence 或明确说明未找到反方证据的检索路径。", "candidate_sources 不足以支撑结论时停止写作,并把缺口写入 open_questions。", ] def _integrated_prompt_brief(chapter: Chapter, method: ResearchMethod | None) -> str: lanes = ";".join(method.integrated_lanes if method else []) return ( f"本任务是《{chapter.title}》的章节级综合证据包。不要把多条窄轴 packet 机械拼贴;" f"必须围绕当前研究方法的 lanes 一次性收束主线:{lanes or '事实材料、权威证据、反方证据、行动建议'}。" "输出必须让章节作者能直接写出判断、证据落点和可执行建议。" ) def _task_card_for_chapter_axis( *, chapter: Chapter, axis: str, routes: list[str], method_key: str, required_skills: list[str] | None = None, allowed_materials: list[str] | None = None, prompt_brief: str | None = None, questions: list[str] | None = None, research_goal: str | None = None, expected_evidence: dict[str, Any] | None = None, stop_conditions: list[str] | None = None, method: ResearchMethod | None = None, ) -> TaskCard: return TaskCard( task_id=f"{chapter.chapter_id}-{axis}", chapter_ids=[chapter.chapter_id], topic_axis=axis, questions=questions or _questions_for_axis(chapter, axis, method), search_routes=routes, output_packet=f"phase2/packets/{chapter.chapter_id}-{axis}.json", chapter_title=chapter.title, preferred_model_role="dr_verifier" if axis == "counter" else "dr_analyst", research_goal=research_goal or f"为《{chapter.title}》收集并验证 {axis} 轴证据,形成可写入章节的具体判断与证据落点。", research_method=method_key, prompt_brief=prompt_brief or (_integrated_prompt_brief(chapter, method) if axis == "chapter_integrated" else f"围绕《{chapter.title}》的 {axis} 轴,优先形成可证伪、可引用、可落地的证据包。"), required_skills=required_skills or _default_required_skills(axis), allowed_materials=allowed_materials or [], expected_evidence=expected_evidence or _default_expected_evidence(axis), stop_conditions=stop_conditions or _default_stop_conditions(), model_hint="use_cross_model_verifier" if axis == "counter" else "use_cost_effective_research_worker", ) def generate_task_cards( slug: str, framework_text: str, *, axes: list[str] | None = None, method: ResearchMethod | None = None, ) -> list[TaskCard]: del slug # slug is kept for call-site clarity and future namespacing. chapters = parse_framework_chapters(framework_text) selected_axes = axes or (method.task_axes if method else DEFAULT_AXES) cards: list[TaskCard] = [] for chapter in chapters: for axis in selected_axes: routes = AXIS_ROUTES.get(axis, ["general"]) cards.append( _task_card_for_chapter_axis( chapter=chapter, axis=axis, routes=routes, method_key=method.key if method else "", method=method, ) ) validate_task_cards(cards) return cards def generate_task_cards_from_research_brief( slug: str, framework_text: str, research_brief: dict[str, Any], *, axes: list[str] | None = None, method: ResearchMethod | None = None, ) -> list[TaskCard]: del slug chapters = parse_framework_chapters(framework_text) planning = research_brief.get("task_planning") or {} method_key = research_brief.get("research_method") or (method.key if method else "") if method is None and method_key: from scripts.runtime.methods import ResearchMethodRegistry method = ResearchMethodRegistry().get(method_key) phase2_mode = planning.get("phase2_mode") or research_brief.get("phase2_mode") if axes: selected_axes = axes elif phase2_mode == "chapter_integrated": selected_axes = ["chapter_integrated"] else: selected_axes = (method.task_axes if method else None) or list(planning.get("search_routes_by_axis") or []) or DEFAULT_AXES routes_by_axis = planning.get("search_routes_by_axis") or {} prompt_by_axis = planning.get("axis_prompt_briefs") or {} base_skills = list(planning.get("required_skills") or []) stop_conditions = list(planning.get("stop_conditions") or []) allowed_materials = [ str(item.get("path")) for item in research_brief.get("materials", []) if item.get("path") ] if not allowed_materials: material_digest = (research_brief.get("phase1_inputs") or {}).get("material_digest") if material_digest: allowed_materials.append(str(material_digest)) chapter_plan_by_id = { str(item.get("chapter_id")): item for item in research_brief.get("chapter_planning", []) if item.get("chapter_id") } cards: list[TaskCard] = [] for chapter in chapters: for axis in selected_axes: routes = list(routes_by_axis.get(axis) or AXIS_ROUTES.get(axis, ["general"])) skills = base_skills or _default_required_skills(axis) if "search-gateway" not in skills: skills = ["search-gateway", *skills] chapter_plan = chapter_plan_by_id.get(chapter.chapter_id) if axis == "chapter_integrated" else None prompt_brief = prompt_by_axis.get(axis) questions = None research_goal = None expected_evidence = None card_stop_conditions = stop_conditions or None if chapter_plan: prompt_brief = chapter_plan.get("phase2_prompt_context") or prompt_brief research_goal = chapter_plan.get("core_question") questions = [ chapter_plan.get("core_question", ""), chapter_plan.get("bold_hypothesis", ""), "按 Phase1 求证计划逐条收集支持证据、反方证据和待补证据。", "不得绕开 Phase1 主基调另起炉灶;若证据推翻假设,必须明确写出修正建议。", ] questions.extend(str(item) for item in chapter_plan.get("verification_plan", [])) expected_evidence = _default_expected_evidence(axis) expected_evidence.update( { "phase1_minimum_evidence": chapter_plan.get("minimum_evidence") or {}, "evidence_lanes": chapter_plan.get("evidence_lanes") or [], "must_address_phase1_hypothesis": True, } ) card_stop_conditions = [ *(stop_conditions or _default_stop_conditions()), "已经逐条回应 Phase1 的大胆假设:支持、修正或推翻,并说明依据。", "已经把本地材料原文、外部证据、反方边界和行动建议分开记录。", ] cards.append( _task_card_for_chapter_axis( chapter=chapter, axis=axis, routes=routes, method_key=method_key, required_skills=skills, allowed_materials=allowed_materials, prompt_brief=prompt_brief, questions=questions, research_goal=research_goal, expected_evidence=expected_evidence, stop_conditions=card_stop_conditions, method=method, ) ) validate_task_cards(cards) return cards def detect_dependency_cycles(cards: list[TaskCard]) -> None: graph = {card.task_id: card.dependencies for card in cards} visiting: set[str] = set() visited: set[str] = set() def visit(node: str) -> None: if node in visiting: raise ValueError(f"dependency cycle detected at {node}") if node in visited: return visiting.add(node) for dep in graph.get(node, []): visit(dep) visiting.remove(node) visited.add(node) for task_id in graph: visit(task_id) def validate_task_cards(cards: list[TaskCard]) -> None: seen: set[str] = set() for card in cards: if not card.research_goal: card.research_goal = f"围绕 {card.topic_axis} 轴收集并验证结构化证据。" if not card.prompt_brief: card.prompt_brief = f"按 {card.topic_axis} 轴形成证据包,避免泛泛结论。" if not card.required_skills: card.required_skills = _default_required_skills(card.topic_axis) if "search-gateway" not in card.required_skills: card.required_skills = ["search-gateway", *card.required_skills] if not card.expected_evidence: card.expected_evidence = _default_expected_evidence(card.topic_axis) if not card.stop_conditions: card.stop_conditions = _default_stop_conditions() if card.task_id in seen: raise ValueError(f"duplicate task_id: {card.task_id}") seen.add(card.task_id) if not card.chapter_ids: raise ValueError(f"{card.task_id}: chapter_ids required") if not card.chapter_title: card.chapter_title = card.chapter_ids[0] if not card.questions: raise ValueError(f"{card.task_id}: questions required") if not card.output_packet.endswith(".json"): raise ValueError(f"{card.task_id}: output_packet must be json") invalid_routes = sorted(set(card.search_routes) - VALID_ROUTES) if invalid_routes: raise ValueError(f"{card.task_id}: invalid search_routes {invalid_routes}") missing_deps = sorted({dep for card in cards for dep in card.dependencies} - seen) if missing_deps: raise ValueError(f"unknown dependencies: {missing_deps}") detect_dependency_cycles(cards) def write_task_cards(path: Path, cards: list[TaskCard]) -> None: path.parent.mkdir(parents=True, exist_ok=True) path.write_text( json.dumps([card.to_dict() for card in cards], ensure_ascii=False, indent=2) + "\n", encoding="utf-8", ) def load_task_cards(path: Path) -> list[TaskCard]: data = json.loads(path.read_text(encoding="utf-8")) cards = [TaskCard(**item) for item in data] validate_task_cards(cards) return cards def validate_packet(packet: dict[str, Any]) -> None: required = { "task_id", "claims", "evidence_items", "counter_evidence", "source_ids", "source_quality_notes", "open_questions", "raw_quotes_or_notes", } missing = sorted(required - set(packet)) if missing: raise ValueError(f"packet missing fields: {missing}") if not packet["claims"]: raise ValueError("packet claims must not be empty") if not packet["evidence_items"]: raise ValueError("packet evidence_items must not be empty") if not packet["counter_evidence"]: raise ValueError("packet counter_evidence must not be empty") declared = set(packet.get("source_ids") or []) referenced: set[str] = set() for section in ("claims", "counter_evidence"): for item in packet.get(section) or []: referenced.update(item.get("source_ids") or []) for item in packet.get("evidence_items") or []: if item.get("source_id"): referenced.add(item["source_id"]) undeclared = sorted(referenced - declared) if undeclared: raise ValueError(f"packet source_ids referenced but not declared: {undeclared}") packet_sources = packet.get("sources") or [] if not packet_sources: raise ValueError("packet sources must not be empty") known_source_ids = {source.get("id") for source in packet_sources} missing_sources = sorted(declared - known_source_ids) if missing_sources: raise ValueError(f"packet source_ids missing source metadata: {missing_sources}")