"""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", "scholar", "patents", "news"} DEFAULT_AXES = ["literature", "regulatory", "patents", "market", "counter"] AXIS_ROUTES = { "literature": ["scholar", "general"], "clinical": ["scholar", "general"], "regulatory": ["general", "news"], "patents": ["patents", "general"], "market": ["news", "general"], "china": ["news", "general"], "counter": ["scholar", "general"], "regulatory_gap": ["general", "news"], "risk_classification": ["general", "scholar"], "capa_design": ["general", "news"], "ownership_timeline": ["general"], "verification_evidence": ["general", "scholar"], "process_flow": ["scholar", "general"], "cqa_cpp": ["scholar", "general"], "scale_up_risk": ["scholar", "general"], "control_strategy": ["scholar", "general"], "supply_chain": ["news", "general"], "scientific_rationale": ["scholar", "general"], "poc_evidence": ["scholar", "general"], "ip_fto": ["patents", "general"], "development_path": ["scholar", "general"], "commercial_window": ["news", "general"], "current_state": ["general"], "capability_gap": ["general"], "operating_model": ["general"], "governance": ["general"], "implementation_roadmap": ["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 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) -> list[str]: return [ f"围绕《{chapter.title}》从 {axis} 角度提炼可证伪的核心结论。", "至少寻找两个 Tier 1-2 来源支撑主要结论;不足时标注待验证。", "主动检索反方证据、限制条件或失败案例。", ] 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]: return { "min_tier_1_2_sources": 2, "must_include_counter_evidence": True, "must_include_source_metadata": True, "preferred_evidence_types": [ "regulatory_or_best_practice_requirement", "site_or_material_finding", "quantitative_fact_or_record", "implementation_or_verification_evidence", ], "axis": axis, } def _default_stop_conditions() -> list[str]: return [ "已形成至少 3 条可追溯 evidence_items,且每条关键 claim 有 source_id。", "已主动记录 counter_evidence 或明确说明未找到反方证据的检索路径。", "candidate_sources 不足以支撑结论时停止写作,并把缺口写入 open_questions。", ] 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, stop_conditions: list[str] | None = None, ) -> TaskCard: return TaskCard( task_id=f"{chapter.chapter_id}-{axis}", chapter_ids=[chapter.chapter_id], topic_axis=axis, questions=_questions_for_axis(chapter, axis), search_routes=routes, output_packet=f"phase2/packets/{chapter.chapter_id}-{axis}.json", preferred_model_role="dr_verifier" if axis == "counter" else "dr_analyst", research_goal=f"为《{chapter.title}》收集并验证 {axis} 轴证据,形成可写入章节的具体判断与证据落点。", research_method=method_key, prompt_brief=prompt_brief or f"围绕《{chapter.title}》的 {axis} 轴,优先形成可证伪、可引用、可落地的证据包。", required_skills=required_skills or _default_required_skills(axis), allowed_materials=allowed_materials or [], expected_evidence=_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 "", ) ) 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 "") selected_axes = axes or (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") ] 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] 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_by_axis.get(axis), stop_conditions=stop_conditions or None, ) ) 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.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 packet_sources: 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}")