v0.21 alpha add research brief and compressed findings

This commit is contained in:
kai
2026-05-06 19:23:30 +08:00
parent db626f1d58
commit 0644a68ecc
13 changed files with 539 additions and 27 deletions
+109 -3
View File
@@ -41,8 +41,41 @@ def validate_chapter_brief(brief: dict) -> None:
raise ValueError("chapter brief requires counter_evidence")
def validate_compressed_finding(finding: dict) -> None:
required = {
"chapter_id",
"chapter_title",
"packet_ids",
"chapter_thesis",
"key_findings",
"evidence_landings",
"counter_evidence",
"source_ids",
"open_questions",
"writing_plan",
}
missing = sorted(required - set(finding))
if missing:
raise ValueError(f"compressed finding missing fields: {missing}")
if not finding["chapter_id"]:
raise ValueError("chapter_id required")
if not finding["packet_ids"]:
raise ValueError("compressed finding requires packet_ids")
if not finding["chapter_thesis"]:
raise ValueError("compressed finding requires chapter_thesis")
if not finding["key_findings"]:
raise ValueError("compressed finding requires key_findings")
if not finding["evidence_landings"]:
raise ValueError("compressed finding requires evidence_landings")
if not finding["counter_evidence"]:
raise ValueError("compressed finding requires counter_evidence")
def validate_chapter_markdown_citations(markdown: str, brief: dict) -> None:
validate_chapter_brief(brief)
if "key_findings" in brief:
validate_compressed_finding(brief)
else:
validate_chapter_brief(brief)
cited = set(re.findall(r"\[(src_[A-Za-z0-9_-]+)\]", markdown))
allowed = set(brief.get("source_ids") or [])
unknown = sorted(cited - allowed)
@@ -103,9 +136,79 @@ def build_chapter_briefs(project_root: Path) -> list[dict]:
return briefs
def _source_ids_from_item(item: dict) -> list[str]:
if item.get("source_ids"):
return list(item.get("source_ids") or [])
if item.get("source_id"):
return [item["source_id"]]
return []
def build_compressed_findings(project_root: Path) -> list[dict]:
"""Compress packet-level evidence into chapter-level writing inputs.
This is intentionally deterministic: it does not invent a better narrative,
but it forces a chapter-level evidence map before any model writes prose.
"""
brief_dir = project_root / "phase2" / "chapter_briefs"
if not brief_dir.exists() or not list(brief_dir.glob("ch*.json")):
briefs = build_chapter_briefs(project_root)
else:
briefs = [
json.loads(path.read_text(encoding="utf-8"))
for path in sorted(brief_dir.glob("ch*.json"))
]
out_dir = project_root / "phase2" / "compressed_findings"
out_dir.mkdir(parents=True, exist_ok=True)
findings: list[dict] = []
for brief in briefs:
validate_chapter_brief(brief)
core_claims = brief.get("core_claims") or []
evidence_items = brief.get("evidence_items") or []
first_claim = core_claims[0] if core_claims else {}
chapter_thesis = first_claim.get("claim") or f"{brief['chapter_title']} 需要以证据为中心重写。"
finding = {
"chapter_id": brief["chapter_id"],
"chapter_title": brief["chapter_title"],
"packet_ids": brief["packet_ids"],
"chapter_thesis": chapter_thesis,
"key_findings": [
{
"finding": claim.get("claim") or claim.get("summary") or str(claim),
"source_ids": _source_ids_from_item(claim),
"confidence": claim.get("confidence", "medium"),
}
for claim in core_claims
],
"evidence_landings": [
{
"evidence": item.get("summary") or item.get("finding") or item.get("quote") or str(item),
"source_ids": _source_ids_from_item(item),
"landing_hint": item.get("landing_hint", "用于支撑本章关键判断或整改动作。"),
}
for item in evidence_items
],
"counter_evidence": brief["counter_evidence"],
"source_ids": brief["source_ids"],
"open_questions": brief["open_questions"],
"writing_plan": [
"先写本章判断,不按 packet 顺序堆砌。",
"每个二级小节至少落下具体审计发现、法规要求、记录/参数或整改证据。",
"正文末尾必须保留“证据落点与待补证据”表。",
],
}
validate_compressed_finding(finding)
(out_dir / f"{brief['chapter_id']}.json").write_text(
json.dumps(finding, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
findings.append(finding)
return findings
def build_chapter_user_prompt(brief: dict) -> str:
return (
"请根据以下 chapter brief 写一章正式中文 Markdown 正文。\n"
"请根据以下 compressed finding / chapter brief 写一章正式中文 Markdown 正文。\n"
"目标是形成一个完整章节,而不是 packet 摘要。避免碎片化,按金字塔结构组织:章首先给结论,再用证据支撑。\n"
"要求:标题必须是观点型判断;每个数字和事实保留 [src_xxx];纳入反方证据;不要出现调度元数据。\n"
"禁止写空泛咨询腔。每个二级小节都必须至少落下 2 个具体审计发现、法规要求、SOP/记录/参数/现场观察或整改证据;不要只写原则。\n"
@@ -142,7 +245,10 @@ class ChapterAssemblyWorker:
)
def write_chapter(self, *, project_root: Path, brief: dict) -> Path:
validate_chapter_brief(brief)
if "key_findings" in brief:
validate_compressed_finding(brief)
else:
validate_chapter_brief(brief)
markdown = self.client.chat_complete(
model=self.role.model,
system=self._system_prompt(),
+14 -2
View File
@@ -8,7 +8,7 @@ from pathlib import Path
from scripts.runtime.artifacts import ensure_phase_dirs, load_manifest, write_manifest
from scripts.runtime.methods import ResearchMethodRegistry
from scripts.runtime.tasks import generate_task_cards, write_task_cards
from scripts.runtime.tasks import generate_task_cards, generate_task_cards_from_research_brief, write_task_cards
def utc_now_iso() -> str:
@@ -28,7 +28,19 @@ def create_phase2_task_cards(
if (project_root / "manifest.json").exists():
method_key = load_manifest(project_root).get("research_method")
method = ResearchMethodRegistry().get(method_key)
cards = generate_task_cards(project_root.name, framework.read_text(encoding="utf-8"), axes=axes, method=method)
research_brief_path = project_root / "phase1" / "research_brief.json"
framework_text = framework.read_text(encoding="utf-8")
if research_brief_path.exists():
research_brief = json.loads(research_brief_path.read_text(encoding="utf-8"))
cards = generate_task_cards_from_research_brief(
project_root.name,
framework_text,
research_brief,
axes=axes,
method=method,
)
else:
cards = generate_task_cards(project_root.name, framework_text, axes=axes, method=method)
if not dry_run:
ensure_phase_dirs(project_root)
write_task_cards(project_root / "phase2" / "task_cards.json", cards)
+132
View File
@@ -3,6 +3,7 @@
from __future__ import annotations
import hashlib
import json
import re
from datetime import datetime, timezone
from pathlib import Path
@@ -11,6 +12,7 @@ from typing import Any
from scripts.runtime.artifacts import PROJECTS_DIR, ensure_phase_dirs, load_manifest, write_manifest
from scripts.runtime.materials import ingest_input_materials, render_material_inventory
from scripts.runtime.methods import ResearchMethod, ResearchMethodRegistry
from scripts.runtime.tasks import AXIS_ROUTES
def utc_now_iso() -> str:
@@ -187,6 +189,132 @@ def write_material_brief(
return out
def _axis_prompt_brief(axis: str, method: ResearchMethod) -> str:
prompts = {
"input_material_findings": "从用户材料中提取现场事实、审计发现、复盘记录和内部答复,并标注原始材料位置。",
"nmpa_fda_ema_ich_who_baseline": "把 NMPA、FDA、EMA、ICH、WHO、药典或 Annex 1 等要求转化为可核验的法规基线。",
"quality_system_gap": "把现场发现映射到质量体系流程缺口,覆盖偏差、变更、CAPA、文件、培训和数据完整性。",
"manufacturing_process_risk": "围绕生产工艺、设施、公用系统、CPP/CQA、验证和无菌保障识别系统性风险。",
"operations_management_gap": "诊断运营管理、跨部门协同、会议机制、指标体系和交付节奏的结构性问题。",
"team_capability": "识别人员能力、岗位职责、质量文化和管理梯队方面的缺口与建设路径。",
"capa_roadmap": "把差距转化为短中长期 CAPA 组合,要求绑定 owner、期限、优先级、关闭证据和复核机制。",
"verification_evidence": "定义整改完成后可被审计接受的验证证据,包括记录、报告、趋势和管理评审输入。",
"counter": "主动寻找反方证据、限制条件和可能降低严重度或改变优先级的解释,避免单向论证。",
}
return prompts.get(axis, f"按照 `{method.key}` 方法,对 {axis} 轴进行证据收集、证伪和结构化归纳。")
def _material_paths(manifest: dict[str, Any]) -> list[dict[str, str]]:
materials: list[dict[str, str]] = []
for item in manifest.get("material_inventory") or []:
rel = item.get("ocr_extracted_to") or item.get("extracted_to") or item.get("copied_to")
if rel:
materials.append({"path": rel, "role": "input_material"})
return materials
def build_research_brief_payload(
project_root: Path,
manifest: dict[str, Any],
method: ResearchMethod,
) -> dict[str, Any]:
"""Create the file-backed Phase 1 research brief used by task-card generation."""
axes = list(method.task_axes)
return {
"version": "0.21-alpha",
"topic": manifest.get("topic", project_root.name),
"research_method": method.key,
"method_name": method.name,
"work_language": "zh",
"tone": "事实型、整改导向、面向管理层和质量/生产负责人;避免空泛咨询腔。",
"central_question": f"如何基于已提供材料和权威法规/最佳实践,系统诊断“{manifest.get('topic', project_root.name)}”并形成可执行整改路线图?",
"success_criteria": [
"每个核心判断都能回到用户材料、权威法规、最佳实践或反方证据。",
"短中长期整改建议必须绑定优先级、责任、关闭证据和复核机制。",
"章节写作必须先收束主线,再使用 evidence packet;不得按 packet 机械拼贴。",
],
"phase2_inputs": {
"material_brief_path": "phase1/material_brief.md",
"framework_path": "phase1/framework.md",
"research_brief_path": "phase1/research_brief.json",
},
"materials": _material_paths(manifest),
"task_planning": {
"chapter_source": "phase1/framework.md",
"axes": axes,
"required_skills": [
"deep-research",
"search-gateway",
"search-strategy",
"source-quality",
"evidence-table",
"citation-manager",
],
"search_routes_by_axis": {axis: AXIS_ROUTES.get(axis, ["general"]) for axis in axes},
"axis_prompt_briefs": {axis: _axis_prompt_brief(axis, method) for axis in axes},
"stop_conditions": [
"每张任务卡至少形成 3 条可追溯 evidence_items,且不得编造 candidate_sources 以外来源。",
"关键 claim 不足 2 个独立 Tier 1-2 信源时,必须写入 open_questions 和证据缺口。",
"必须包含 counter_evidence;找不到反方证据时记录检索路径和限制。",
],
"fragmentation_guard": "并发 worker 只生产 evidence packet;章节主线由 compressed_findings 收束,禁止直接把 packet 堆成正文。",
},
"clarification_notes": {
"requires_user_review": True,
"questions_source": "phase1/material_brief.md",
"decision_items": [
"确认报告用途、受众和脱敏边界。",
"确认研究方法是否适配当前场景;MECE 只是可选方法之一。",
"确认任务切分和检索策略是否足以让低成本模型独立执行。",
],
},
}
def write_research_brief(
project_root: Path,
manifest: dict[str, Any] | None = None,
method: ResearchMethod | None = None,
) -> tuple[Path, Path]:
manifest = manifest or load_manifest(project_root)
method = method or ResearchMethodRegistry().get(manifest.get("research_method"))
payload = build_research_brief_payload(project_root, manifest, method)
json_path = project_root / "phase1" / "research_brief.json"
md_path = project_root / "phase1" / "research_brief.md"
json_path.parent.mkdir(parents=True, exist_ok=True)
json_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
lines = [
f"# Phase 1 Research Brief{payload['topic']}",
"",
f"- research_method: {payload['research_method']}",
f"- work_language: {payload['work_language']}",
f"- tone: {payload['tone']}",
"",
"## 中心问题",
"",
payload["central_question"],
"",
"## 成功标准",
"",
]
lines.extend(f"- {item}" for item in payload["success_criteria"])
lines.extend(["", "## 任务切分原则", ""])
planning = payload["task_planning"]
lines.append(planning["fragmentation_guard"])
lines.append("")
for axis in planning["axes"]:
routes = "".join(planning["search_routes_by_axis"].get(axis, []))
prompt = planning["axis_prompt_briefs"].get(axis, "")
lines.append(f"- `{axis}`{prompt} 检索路径:{routes}")
lines.extend(["", "## 必读 Skills", ""])
lines.extend(f"- {name}" for name in planning["required_skills"])
lines.extend(["", "## 停止条件", ""])
lines.extend(f"- {item}" for item in planning["stop_conditions"])
lines.append("")
md_path.write_text("\n".join(lines), encoding="utf-8")
return md_path, json_path
CHAPTER_TEMPLATES: dict[str, list[str]] = {
"mckinsey_market": [
"核心结论先行界定市场机会与约束",
@@ -329,10 +457,14 @@ def render_framework(project_root: Path, *, method_key: str | None = None, chapt
out = project_root / "phase1" / "framework.md"
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text("\n".join(lines), encoding="utf-8")
research_brief_md, research_brief_json = write_research_brief(project_root, manifest, method)
manifest["phase1"] = {
"status": "completed",
"approved": False,
"framework_path": "phase1/framework.md",
"research_brief_path": str(research_brief_md.relative_to(project_root)),
"research_brief_json_path": str(research_brief_json.relative_to(project_root)),
"requires_user_interview": True,
"research_method": method.key,
"updated_at": utc_now_iso(),
}
+130 -8
View File
@@ -63,6 +63,14 @@ class TaskCard:
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)
@@ -105,6 +113,66 @@ def _questions_for_axis(chapter: Chapter, axis: str) -> list[str]:
]
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,
@@ -120,14 +188,56 @@ def generate_task_cards(
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",
_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)
@@ -157,6 +267,18 @@ def detect_dependency_cycles(cards: list[TaskCard]) -> None:
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)