Files
deep_research/scripts/runtime/tasks.py
T

230 lines
8.0 KiB
Python

"""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)
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 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(
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",
)
)
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 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}")