352 lines
13 KiB
Python
352 lines
13 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)
|
|
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}")
|