release: v0.20 Codex-ready skill-driven core
This commit is contained in:
@@ -6,7 +6,7 @@
|
||||
- 可选:--extra terms.txt(每行一个英文术语,补充进来一起核查)
|
||||
|
||||
流程(每个术语独立可并行):
|
||||
1. 用 SearchClient(Exa > Tavily)搜一次(query = "<term> <domain hint>")
|
||||
1. 用 SearchClient(Tavily > Exa > Brave)搜一次(query = "<term> <domain hint>")
|
||||
2. 把 top 3-5 snippet 喂给 Haiku,让模型返回 {zh, en_full, confidence, issue}
|
||||
3. 合并回 glossary,字段扩展:
|
||||
{
|
||||
|
||||
+32
-1
@@ -243,6 +243,14 @@ def prepare_qmd(
|
||||
if end != -1:
|
||||
md_text = md_text[end + 4:].lstrip("\n")
|
||||
|
||||
# Quarto already renders the title from YAML; drop a duplicated leading H1.
|
||||
md_text = re.sub(
|
||||
rf"^#\s+{re.escape(title)}\s*\n+",
|
||||
"",
|
||||
md_text,
|
||||
count=1,
|
||||
)
|
||||
|
||||
# Replace TOC placeholder
|
||||
md_text = re.sub(
|
||||
r"\[TOC will be generated.*?\]",
|
||||
@@ -276,13 +284,36 @@ def prepare_qmd(
|
||||
"\\usepackage{longtable}\n"
|
||||
"\\usepackage{booktabs}\n"
|
||||
"\\usepackage{array}\n"
|
||||
"\\usepackage{xcolor}\n"
|
||||
"\\usepackage{titlesec}\n"
|
||||
"\\definecolor{DRBlue}{HTML}{1E3A8A}\n"
|
||||
"\\definecolor{DRSlate}{HTML}{374151}\n"
|
||||
"\\definecolor{DRMuted}{HTML}{6B7280}\n"
|
||||
# Use lscape instead of pdflscape to avoid \LS@makefcolumn recursion
|
||||
# which exhausts TeX param_size on large longtables.
|
||||
# lscape rotates content without changing page media box (reader must rotate).
|
||||
"\\usepackage{lscape}\n"
|
||||
"\\setlength{\\LTpre}{6pt}\n"
|
||||
"\\setlength{\\LTpost}{6pt}\n"
|
||||
"\\setlength{\\tabcolsep}{3pt}\n",
|
||||
"\\setlength{\\tabcolsep}{3pt}\n"
|
||||
"\\linespread{1.18}\n"
|
||||
"\\setlength{\\parindent}{2em}\n"
|
||||
"\\setlength{\\parskip}{0.25em}\n"
|
||||
"\\newcommand{\\sectionbreak}{\\clearpage}\n"
|
||||
"\\titleformat{\\section}[display]\n"
|
||||
" {\\centering\\Large\\bfseries\\sffamily\\color{DRBlue}}\n"
|
||||
" {}{0pt}{}\n"
|
||||
"\\titlespacing*{\\section}{0pt}{0pt}{1.1em}\n"
|
||||
"\\titleformat{\\subsection}\n"
|
||||
" {\\large\\bfseries\\sffamily\\color{DRBlue}}\n"
|
||||
" {}{0pt}{}\n"
|
||||
"\\titlespacing*{\\subsection}{0pt}{1.1em}{0.45em}\n"
|
||||
"\\titleformat{\\subsubsection}\n"
|
||||
" {\\normalsize\\bfseries\\sffamily\\color{DRSlate}}\n"
|
||||
" {}{0pt}{}\n"
|
||||
"\\titlespacing*{\\subsubsection}{0pt}{0.9em}{0.35em}\n"
|
||||
"\\renewcommand{\\contentsname}{目录}\n"
|
||||
"\\setcounter{tocdepth}{2}\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
+170
-15
@@ -30,11 +30,12 @@ from scripts.runtime.assembly import build_chapter_briefs, build_compressed_find
|
||||
from scripts.runtime.orchestrator import create_phase2_task_cards, write_placeholder_packets
|
||||
from scripts.runtime.methods import ResearchMethodRegistry
|
||||
from scripts.runtime.phase1 import create_project, render_framework, write_material_brief
|
||||
from scripts.runtime.review import build_phase3_critique
|
||||
from scripts.runtime.review import build_phase3_critique, build_phase3_model_critique
|
||||
from scripts.runtime.roles import resolve_runtime_profile
|
||||
from scripts.runtime.source_cache import cache_sources
|
||||
from scripts.runtime.sources import rebuild_sources_from_packets
|
||||
from scripts.runtime.skills import SkillRegistry, default_adapter_skill_dirs
|
||||
from scripts.runtime.tasks import TaskCard
|
||||
from scripts.runtime.tasks import TaskCard, load_task_cards, validate_packet, write_task_cards
|
||||
from scripts.runtime.workers import run_packet_workers
|
||||
|
||||
|
||||
@@ -156,7 +157,12 @@ def cmd_frame(args: argparse.Namespace) -> int:
|
||||
print(f"Research method: {method.key}")
|
||||
print(f"Chapters: {args.chapters}")
|
||||
return 0
|
||||
path = render_framework(project_root, method_key=args.method, chapter_count=args.chapters)
|
||||
path = render_framework(
|
||||
project_root,
|
||||
method_key=args.method,
|
||||
chapter_count=args.chapters,
|
||||
preserve_existing_outline=args.preserve_existing_outline,
|
||||
)
|
||||
print(f"Project: {project_root.name}")
|
||||
print(f"Wrote: {path.relative_to(project_root)}")
|
||||
print("Pause: review and approve the framework before Phase 2.")
|
||||
@@ -222,12 +228,62 @@ def cmd_research(args: argparse.Namespace) -> int:
|
||||
"Phase 1 is not approved. Review phase1/material_brief.md and phase1/framework.md, "
|
||||
"then run `uv run python scripts/dr.py approve <project>` or pass --force."
|
||||
)
|
||||
runtime = resolve_runtime_profile(profile=args.profile)
|
||||
card_dicts = create_phase2_task_cards(
|
||||
project_root,
|
||||
axes=args.axis,
|
||||
dry_run=args.dry_run,
|
||||
if args.profile == "codex_native" and (args.execute_packets or args.assemble_chapters):
|
||||
raise SystemExit(
|
||||
"`codex_native` cannot be used for Python-core model execution: scripts/dr.py currently calls external "
|
||||
"API clients, not Codex App built-in models. Use a clearly external profile such as `medium`, or run "
|
||||
"Codex-native execution through the surface adapter/manual task workflow."
|
||||
)
|
||||
runtime = resolve_runtime_profile(
|
||||
profile=args.profile,
|
||||
overrides=parse_model_overrides(args.model_override),
|
||||
)
|
||||
if args.append_task_cards:
|
||||
generated = create_phase2_task_cards(
|
||||
project_root,
|
||||
axes=args.axis,
|
||||
dry_run=True,
|
||||
)
|
||||
existing_path = project_root / "phase2" / "task_cards.json"
|
||||
existing_cards = load_task_cards(existing_path) if existing_path.exists() else []
|
||||
seen = {card.task_id for card in existing_cards}
|
||||
appended_cards = [TaskCard(**item) for item in generated if item["task_id"] not in seen]
|
||||
runnable_existing_cards: list[TaskCard] = []
|
||||
if args.execute_packets and args.axis:
|
||||
axis_set = set(args.axis)
|
||||
for card in existing_cards:
|
||||
if card.topic_axis not in axis_set:
|
||||
continue
|
||||
packet_path = project_root / card.output_packet
|
||||
try:
|
||||
validate_packet(json.loads(packet_path.read_text(encoding="utf-8")))
|
||||
except Exception:
|
||||
runnable_existing_cards.append(card)
|
||||
merged_cards = [*existing_cards, *appended_cards]
|
||||
if not args.dry_run:
|
||||
write_task_cards(existing_path, merged_cards)
|
||||
phase2 = manifest.setdefault("phase2", {})
|
||||
phase2.update(
|
||||
{
|
||||
"status": "in_progress",
|
||||
"runtime": "python-core-v0.20",
|
||||
"task_cards_path": "phase2/task_cards.json",
|
||||
"task_cards_total": len(merged_cards),
|
||||
"task_cards_appended": len(appended_cards),
|
||||
"updated_at": datetime.now(timezone.utc).replace(microsecond=0).isoformat(),
|
||||
}
|
||||
)
|
||||
(project_root / "manifest.json").write_text(
|
||||
json.dumps(manifest, ensure_ascii=False, indent=2) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
card_dicts = [card.to_dict() for card in [*appended_cards, *runnable_existing_cards]]
|
||||
else:
|
||||
card_dicts = create_phase2_task_cards(
|
||||
project_root,
|
||||
axes=args.axis,
|
||||
dry_run=args.dry_run,
|
||||
)
|
||||
if args.execute_packets and args.dry_run:
|
||||
raise SystemExit("--execute-packets cannot be combined with --dry-run")
|
||||
if args.assemble_chapters and args.dry_run:
|
||||
@@ -350,7 +406,9 @@ def cmd_run(args: argparse.Namespace) -> int:
|
||||
workers=args.workers,
|
||||
axis=None,
|
||||
profile=args.profile,
|
||||
model_override=[],
|
||||
execute_packets=False,
|
||||
append_task_cards=False,
|
||||
allow_search_fallback=False,
|
||||
build_briefs=False,
|
||||
assemble_chapters=False,
|
||||
@@ -362,7 +420,26 @@ def cmd_run(args: argparse.Namespace) -> int:
|
||||
|
||||
def cmd_review(args: argparse.Namespace) -> int:
|
||||
project_root = resolve_project(args.project)
|
||||
path = build_phase3_critique(project_root)
|
||||
if args.model_review:
|
||||
if args.dry_run:
|
||||
print(f"Project: {project_root.name}")
|
||||
print("Phase 3 model review plan:")
|
||||
print(f" model: {args.model}")
|
||||
print(" context: phase3/review_context_opus_4_7.md")
|
||||
print(" output: phase3/critique.md")
|
||||
return 0
|
||||
from scripts.lib.zenmux_client import ZenMuxClient, load_secrets
|
||||
|
||||
load_secrets()
|
||||
with ZenMuxClient(log_file=project_root / "phase3" / "logs" / "review.jsonl") as client:
|
||||
path = build_phase3_model_critique(
|
||||
project_root,
|
||||
client=client,
|
||||
model=args.model,
|
||||
max_context_chars=args.max_context_chars,
|
||||
)
|
||||
else:
|
||||
path = build_phase3_critique(project_root)
|
||||
print(f"Project: {project_root.name}")
|
||||
print(f"Wrote: {path.relative_to(project_root)}")
|
||||
print("Pause: review critique before Phase 4.")
|
||||
@@ -482,12 +559,31 @@ def cmd_finalize(args: argparse.Namespace) -> int:
|
||||
roles = resolved["roles"]
|
||||
|
||||
if not args.legacy_translate:
|
||||
final_input = args.input
|
||||
if args.number_citations and not args.dry_run:
|
||||
rc = run_cmd(
|
||||
[
|
||||
sys.executable,
|
||||
str(REPO_ROOT / "scripts" / "number_citations.py"),
|
||||
str(project_root),
|
||||
"--input",
|
||||
args.input,
|
||||
"--output",
|
||||
args.numbered_output,
|
||||
],
|
||||
dry_run=False,
|
||||
)
|
||||
if rc != 0:
|
||||
return rc
|
||||
final_input = args.numbered_output
|
||||
elif args.number_citations:
|
||||
final_input = args.numbered_output
|
||||
cmd: list[str] = [
|
||||
sys.executable,
|
||||
str(REPO_ROOT / "scripts" / "build_report.py"),
|
||||
str(project_root),
|
||||
"--input",
|
||||
args.input,
|
||||
final_input,
|
||||
]
|
||||
if args.report_engine:
|
||||
cmd += ["--engine", args.report_engine]
|
||||
@@ -497,6 +593,21 @@ def cmd_finalize(args: argparse.Namespace) -> int:
|
||||
cmd.append("--no-pdf")
|
||||
if args.dry_run:
|
||||
print("Chinese-native finalize plan:")
|
||||
if args.number_citations:
|
||||
print(
|
||||
"$ "
|
||||
+ " ".join(
|
||||
[
|
||||
sys.executable,
|
||||
str(REPO_ROOT / "scripts" / "number_citations.py"),
|
||||
str(project_root),
|
||||
"--input",
|
||||
args.input,
|
||||
"--output",
|
||||
args.numbered_output,
|
||||
]
|
||||
)
|
||||
)
|
||||
print("$ " + " ".join(cmd))
|
||||
if args.polish:
|
||||
print(
|
||||
@@ -506,8 +617,8 @@ def cmd_finalize(args: argparse.Namespace) -> int:
|
||||
sys.executable,
|
||||
str(REPO_ROOT / "scripts" / "polish.py"),
|
||||
str(project_root),
|
||||
"--input",
|
||||
args.input,
|
||||
"--source",
|
||||
final_input,
|
||||
"--workers",
|
||||
str(args.polish_workers),
|
||||
"--model",
|
||||
@@ -522,8 +633,8 @@ def cmd_finalize(args: argparse.Namespace) -> int:
|
||||
sys.executable,
|
||||
str(REPO_ROOT / "scripts" / "polish.py"),
|
||||
str(project_root),
|
||||
"--input",
|
||||
args.input,
|
||||
"--source",
|
||||
final_input,
|
||||
"--workers",
|
||||
str(args.polish_workers),
|
||||
"--model",
|
||||
@@ -633,6 +744,30 @@ def cmd_models(args: argparse.Namespace) -> int:
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_sources(args: argparse.Namespace) -> int:
|
||||
project_root = resolve_project(args.project)
|
||||
if args.sources_cmd == "cache":
|
||||
if args.dry_run:
|
||||
print(f"Project: {project_root.name}")
|
||||
print(f"Would cache sources from: {args.sources}")
|
||||
print(f"Important only: {not args.all}")
|
||||
print(f"Limit: {args.limit}")
|
||||
return 0
|
||||
results = cache_sources(
|
||||
project_root,
|
||||
sources_rel=args.sources,
|
||||
important_only=not args.all,
|
||||
limit=args.limit,
|
||||
force=args.force,
|
||||
)
|
||||
print(f"Project: {project_root.name}")
|
||||
print(f"Cached source snapshots: {len(results)}")
|
||||
print("Wrote: phase2/source_cache/md/*.md")
|
||||
print("Updated: phase2/sources.jsonl")
|
||||
return 0
|
||||
raise SystemExit(f"unknown sources command: {args.sources_cmd}")
|
||||
|
||||
|
||||
def cmd_apply_models(args: argparse.Namespace) -> int:
|
||||
cmd = [
|
||||
sys.executable,
|
||||
@@ -672,6 +807,7 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
frame.add_argument("project", help="Project slug or path")
|
||||
frame.add_argument("--method", help="Override research method key")
|
||||
frame.add_argument("--chapters", type=int, default=10)
|
||||
frame.add_argument("--preserve-existing-outline", action="store_true", help="Keep current framework chapter titles and enrich Phase 1 planning")
|
||||
frame.add_argument("--dry-run", action="store_true")
|
||||
frame.set_defaults(func=cmd_frame)
|
||||
|
||||
@@ -694,7 +830,9 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
research.add_argument("--workers", type=int, default=6)
|
||||
research.add_argument("--axis", action="append", help="Restrict generated task axes; repeatable")
|
||||
research.add_argument("--profile", help="Model profile name from configs/models.yaml")
|
||||
research.add_argument("--model-override", action="append", default=[], metavar="ROLE=MODEL", help="Override a role model for this run; repeatable")
|
||||
research.add_argument("--execute-packets", action="store_true", help="Call model workers to fill evidence packets")
|
||||
research.add_argument("--append-task-cards", action="store_true", help="Append newly generated task cards instead of replacing phase2/task_cards.json")
|
||||
research.add_argument("--allow-search-fallback", action="store_true", help="Allow generic search fallback for specialized routes")
|
||||
research.add_argument("--build-briefs", action="store_true", help="Aggregate packets into chapter briefs")
|
||||
research.add_argument("--assemble-chapters", action="store_true", help="Call model workers to write Chinese chapter drafts")
|
||||
@@ -721,8 +859,12 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
status.add_argument("project", nargs="?", help="Project slug or path")
|
||||
status.set_defaults(func=cmd_status)
|
||||
|
||||
review = sub.add_parser("review", help="Run deterministic Phase 3 review")
|
||||
review = sub.add_parser("review", help="Run Phase 3 review")
|
||||
review.add_argument("project", help="Project slug or path")
|
||||
review.add_argument("--model-review", action="store_true", help="Run independent model-based Phase 3 review")
|
||||
review.add_argument("--model", default="zenmux-anthropic/claude-opus-4-7", help="Model for --model-review")
|
||||
review.add_argument("--max-context-chars", type=int, default=650_000, help="Bounded context size for model review")
|
||||
review.add_argument("--dry-run", action="store_true")
|
||||
review.set_defaults(func=cmd_review)
|
||||
|
||||
prompt = sub.add_parser("prompt", help="Print a Codex command prompt template")
|
||||
@@ -745,6 +887,8 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
finalize.add_argument("--input", default="phase4/final_zh.md", help="Chinese Markdown source for default v0.20 finalization")
|
||||
finalize.add_argument("--legacy-translate", action="store_true", help="Use legacy final_en -> translate -> polish pipeline")
|
||||
finalize.add_argument("--polish", action="store_true", help="Run optional Chinese polish step before rendering")
|
||||
finalize.add_argument("--number-citations", action="store_true", help="Convert [src_xxx] citations to numeric references before rendering")
|
||||
finalize.add_argument("--numbered-output", default="phase4/final_zh_numbered.md", help="Output path for numeric citation Markdown")
|
||||
finalize.add_argument("--report-engine", choices=["reportlab", "quarto"], default=None)
|
||||
finalize.add_argument("--no-docx", action="store_true")
|
||||
finalize.add_argument("--no-pdf", action="store_true")
|
||||
@@ -781,6 +925,17 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
models.add_argument("--json", action="store_true", help="Emit JSON")
|
||||
models.set_defaults(func=cmd_models)
|
||||
|
||||
sources = sub.add_parser("sources", help="Manage source snapshots and source registry")
|
||||
sources_sub = sources.add_subparsers(dest="sources_cmd", required=True)
|
||||
sources_cache = sources_sub.add_parser("cache", help="Cache important sources as local Markdown snapshots")
|
||||
sources_cache.add_argument("project", help="Project slug or path")
|
||||
sources_cache.add_argument("--sources", default="phase2/sources.jsonl", help="Source registry path relative to project")
|
||||
sources_cache.add_argument("--all", action="store_true", help="Cache all remote sources, not only important official/Tier 1 sources")
|
||||
sources_cache.add_argument("--limit", type=int, help="Maximum sources to cache in this run")
|
||||
sources_cache.add_argument("--force", action="store_true", help="Refetch even if cached_text_path already exists")
|
||||
sources_cache.add_argument("--dry-run", action="store_true")
|
||||
sources_cache.set_defaults(func=cmd_sources)
|
||||
|
||||
apply_models = sub.add_parser("apply-models", help="Apply profile to agent files")
|
||||
apply_models.add_argument("--profile", required=True, help="Profile name from configs/models.yaml")
|
||||
apply_models.add_argument("--target", choices=["opencode", "codex", "both"], default="both")
|
||||
|
||||
+165
-10
@@ -1,11 +1,12 @@
|
||||
"""通用搜索客户端(Serper / Exa / Tavily 路由)。
|
||||
"""通用搜索客户端(Tavily / Exa / Brave / Serper 路由)。
|
||||
|
||||
为 build_glossary.py 这类术语核查场景服务。
|
||||
|
||||
关键设计:
|
||||
- `trust_env=False` 绕开系统 socks 代理(Clash on macOS 配 socks5 时 httpx 会 TLS EOF)
|
||||
- 专利 / Scholar / News 优先 Serper,保证 Google Patents / Google Scholar 路径被真正调用
|
||||
- 通用网页 Exa 优先,Tavily fallback
|
||||
- 通用网页 Tavily 优先,Exa/Brave fallback
|
||||
- 证据发现 Exa 优先,用 highlights/text 摘录喂给 evidence packet
|
||||
- 遇到配额问题自动降级或返回 empty
|
||||
- 不做深度 crawl,只要摘要
|
||||
"""
|
||||
@@ -47,13 +48,29 @@ class ExaClient:
|
||||
def __exit__(self, *_args: Any) -> None:
|
||||
self.close()
|
||||
|
||||
def search(self, query: str, *, num_results: int = 5) -> list[SearchHit]:
|
||||
def search(
|
||||
self,
|
||||
query: str,
|
||||
*,
|
||||
num_results: int = 5,
|
||||
search_type: str = "auto",
|
||||
category: str | None = None,
|
||||
use_highlights: bool = False,
|
||||
max_characters: int = 800,
|
||||
) -> list[SearchHit]:
|
||||
body = {
|
||||
"query": query,
|
||||
"numResults": num_results,
|
||||
"type": "auto",
|
||||
"contents": {"text": {"maxCharacters": 800}},
|
||||
"type": search_type,
|
||||
"contents": {"text": {"maxCharacters": max_characters}},
|
||||
}
|
||||
if category:
|
||||
body["category"] = category
|
||||
if use_highlights:
|
||||
body["contents"]["highlights"] = {
|
||||
"numSentences": 2,
|
||||
"highlightsPerUrl": 3,
|
||||
}
|
||||
r = self._client.post(
|
||||
"https://api.exa.ai/search",
|
||||
json=body,
|
||||
@@ -64,11 +81,15 @@ class ExaClient:
|
||||
data = r.json()
|
||||
out: list[SearchHit] = []
|
||||
for item in data.get("results", [])[:num_results]:
|
||||
highlights = item.get("highlights") or []
|
||||
text = item.get("text") or item.get("snippet") or ""
|
||||
if highlights:
|
||||
text = " | ".join(str(h).strip() for h in highlights if str(h).strip())
|
||||
out.append(
|
||||
SearchHit(
|
||||
title=(item.get("title") or "")[:200],
|
||||
url=item.get("url") or "",
|
||||
snippet=(item.get("text") or item.get("snippet") or "")[:600],
|
||||
snippet=text[:1000],
|
||||
)
|
||||
)
|
||||
return out
|
||||
@@ -115,10 +136,51 @@ class TavilyClient:
|
||||
return out
|
||||
|
||||
|
||||
class BraveClient:
|
||||
def __init__(self, api_key: str | None = None, timeout: float = 30.0) -> None:
|
||||
self.api_key = api_key or os.environ.get("BRAVE_API_KEY")
|
||||
if not self.api_key:
|
||||
raise SearchError("BRAVE_API_KEY not set")
|
||||
self._client = httpx.Client(trust_env=False, timeout=timeout)
|
||||
|
||||
def close(self) -> None:
|
||||
self._client.close()
|
||||
|
||||
def __enter__(self) -> "BraveClient":
|
||||
return self
|
||||
|
||||
def __exit__(self, *_args: Any) -> None:
|
||||
self.close()
|
||||
|
||||
def search(self, query: str, *, num_results: int = 5) -> list[SearchHit]:
|
||||
r = self._client.get(
|
||||
"https://api.search.brave.com/res/v1/web/search",
|
||||
params={"q": query, "count": min(max(num_results, 1), 20)},
|
||||
headers={
|
||||
"X-Subscription-Token": self.api_key,
|
||||
"Accept": "application/json",
|
||||
},
|
||||
)
|
||||
if r.status_code != 200:
|
||||
raise SearchError(f"Brave HTTP {r.status_code}: {r.text[:200]}")
|
||||
data = r.json()
|
||||
out: list[SearchHit] = []
|
||||
for item in (data.get("web") or {}).get("results", [])[:num_results]:
|
||||
out.append(
|
||||
SearchHit(
|
||||
title=(item.get("title") or "")[:200],
|
||||
url=item.get("url") or "",
|
||||
snippet=(item.get("description") or "")[:600],
|
||||
)
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
class SearchClient:
|
||||
"""统一搜索门面,支持多路由:
|
||||
|
||||
- `search(query)`:通用网页搜索,优先 Exa → 降级 Tavily
|
||||
- `search(query)`:通用网页搜索,优先 Tavily → Exa → Brave
|
||||
- `evidence(query)`:证据发现,优先 Exa highlights → Tavily → Brave
|
||||
- `patents(query)`:专利检索,走 Serper(Google Patents);失败则通用搜索补刀
|
||||
- `scholar(query)`:学术论文,走 Serper Scholar;失败则通用搜索补刀
|
||||
- `news(query)`:新闻检索,走 Serper News;失败则通用搜索补刀
|
||||
@@ -129,6 +191,7 @@ class SearchClient:
|
||||
def __init__(self, *, strict_specialized: bool = True) -> None:
|
||||
self._exa: ExaClient | None = None
|
||||
self._tavily: TavilyClient | None = None
|
||||
self._brave: BraveClient | None = None
|
||||
self._serper = None # 惰性实例化
|
||||
self.strict_specialized = strict_specialized
|
||||
try:
|
||||
@@ -139,9 +202,13 @@ class SearchClient:
|
||||
self._tavily = TavilyClient()
|
||||
except SearchError:
|
||||
pass
|
||||
try:
|
||||
self._brave = BraveClient()
|
||||
except SearchError:
|
||||
pass
|
||||
self._has_serper_key = bool(os.environ.get("SERPER_API_KEY") or os.environ.get("SERPAPI_KEY"))
|
||||
if not (self._exa or self._tavily or self._has_serper_key):
|
||||
raise SearchError("no search API key available: set SERPER_API_KEY, SERPAPI_KEY, EXA_API_KEY, or TAVILY_API_KEY")
|
||||
if not (self._exa or self._tavily or self._brave or self._has_serper_key):
|
||||
raise SearchError("no search API key available: set SERPER_API_KEY, SERPAPI_KEY, EXA_API_KEY, TAVILY_API_KEY, or BRAVE_API_KEY")
|
||||
|
||||
def _get_serper(self):
|
||||
"""惰性创建 SerperClient。没 key 时返回 None。"""
|
||||
@@ -161,6 +228,8 @@ class SearchClient:
|
||||
self._exa.close()
|
||||
if self._tavily:
|
||||
self._tavily.close()
|
||||
if self._brave:
|
||||
self._brave.close()
|
||||
if self._serper and self._serper is not False:
|
||||
self._serper.close()
|
||||
|
||||
@@ -171,17 +240,60 @@ class SearchClient:
|
||||
self.close()
|
||||
|
||||
def search(self, query: str, *, num_results: int = 5) -> list[SearchHit]:
|
||||
"""通用网页搜索。Exa 首选,Tavily 备选。"""
|
||||
"""通用网页搜索。Tavily 首选,Exa/Brave 备选。"""
|
||||
if self._tavily:
|
||||
try:
|
||||
return self._tavily.search(query, num_results=num_results)
|
||||
except SearchError:
|
||||
pass
|
||||
if self._exa:
|
||||
try:
|
||||
return self._exa.search(query, num_results=num_results)
|
||||
except SearchError:
|
||||
pass
|
||||
if self._brave:
|
||||
try:
|
||||
return self._brave.search(query, num_results=num_results)
|
||||
except SearchError:
|
||||
pass
|
||||
return []
|
||||
|
||||
def evidence(
|
||||
self,
|
||||
query: str,
|
||||
*,
|
||||
num_results: int = 10,
|
||||
category: str | None = None,
|
||||
) -> list[SearchHit]:
|
||||
"""Evidence discovery route.
|
||||
|
||||
Exa is better suited for agent-facing evidence discovery because it can
|
||||
return concise highlights/text per URL. Results are still candidate
|
||||
sources only; downstream packets must score and trace important hits
|
||||
back to original Tier 1-2 sources before making final claims.
|
||||
"""
|
||||
if self._exa:
|
||||
try:
|
||||
return self._exa.search(
|
||||
query,
|
||||
num_results=num_results,
|
||||
search_type="auto",
|
||||
category=category,
|
||||
use_highlights=True,
|
||||
max_characters=1200,
|
||||
)
|
||||
except SearchError:
|
||||
pass
|
||||
if self._tavily:
|
||||
try:
|
||||
return self._tavily.search(query, num_results=num_results)
|
||||
except SearchError:
|
||||
pass
|
||||
if self._brave:
|
||||
try:
|
||||
return self._brave.search(query, num_results=num_results)
|
||||
except SearchError:
|
||||
pass
|
||||
return []
|
||||
|
||||
def patents(self, query: str, *, num_results: int = 10) -> list[SearchHit]:
|
||||
@@ -253,6 +365,49 @@ class SearchClient:
|
||||
raise SearchError("serper unavailable for news route; refusing silent fallback")
|
||||
return self.search(query, num_results=num_results)
|
||||
|
||||
def fda(self, query: str, *, num_results: int = 10) -> list[SearchHit]:
|
||||
"""FDA-focused discovery for warning letters and meeting records.
|
||||
|
||||
FDA enforcement examples are often more useful for GMP remediation than
|
||||
generic web pages, so this route biases discovery toward warning
|
||||
letters, inspection/enforcement pages, and meeting materials/minutes.
|
||||
"""
|
||||
def fda_only(hits: list[SearchHit]) -> list[SearchHit]:
|
||||
return [hit for hit in hits if "fda.gov" in (hit.url or "").lower()]
|
||||
|
||||
focused_queries = [
|
||||
f'site:fda.gov "Warning Letter" GMP pharmaceutical {query}',
|
||||
f'site:fda.gov/inspections-compliance-enforcement-and-criminal-investigations "Warning Letter" {query}',
|
||||
f'site:fda.gov "meeting materials" "pharmaceutical quality" {query}',
|
||||
f'site:fda.gov "meeting minutes" FDA pharmaceutical quality {query}',
|
||||
]
|
||||
hits: list[SearchHit] = []
|
||||
seen: set[str] = set()
|
||||
per_query = max(2, min(num_results, 4))
|
||||
for focused_query in focused_queries:
|
||||
route_hits: list[SearchHit] = []
|
||||
serper = self._get_serper()
|
||||
if serper:
|
||||
try:
|
||||
route_hits = [
|
||||
SearchHit(h.title, h.url, h.snippet)
|
||||
for h in serper.search(focused_query, num_results=per_query)
|
||||
]
|
||||
except Exception as exc:
|
||||
if self.strict_specialized:
|
||||
raise SearchError(f"serper FDA search failed: {exc}") from exc
|
||||
if not route_hits and not self.strict_specialized:
|
||||
route_hits = self.search(focused_query, num_results=per_query)
|
||||
for hit in fda_only(route_hits):
|
||||
key = hit.url or hit.title
|
||||
if not key or key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
hits.append(hit)
|
||||
if len(hits) >= num_results:
|
||||
return hits
|
||||
return hits
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
from scripts.lib.zenmux_client import load_secrets
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Convert Deep Research source IDs into numeric citations for final output."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
SRC_CITE_RE = re.compile(r"\[((?:src_[A-Za-z0-9_-]+)(?:\s*,\s*src_[A-Za-z0-9_-]+)*)\]")
|
||||
|
||||
|
||||
def load_sources(path: Path) -> dict[str, dict[str, Any]]:
|
||||
sources: dict[str, dict[str, Any]] = {}
|
||||
if not path.exists():
|
||||
return sources
|
||||
for line in path.read_text(encoding="utf-8").splitlines():
|
||||
if not line.strip():
|
||||
continue
|
||||
try:
|
||||
obj = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
sid = obj.get("id") or obj.get("source_id")
|
||||
if sid:
|
||||
sources[str(sid)] = obj
|
||||
return sources
|
||||
|
||||
|
||||
def extract_ordered_source_ids(text: str) -> list[str]:
|
||||
ordered: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for match in SRC_CITE_RE.finditer(text):
|
||||
for sid in [item.strip() for item in match.group(1).split(",")]:
|
||||
if sid and sid not in seen:
|
||||
seen.add(sid)
|
||||
ordered.append(sid)
|
||||
return ordered
|
||||
|
||||
|
||||
def _canonical_source_key(sid: str, source: dict[str, Any] | None) -> str:
|
||||
"""Return a stable de-duplication key for a source record.
|
||||
|
||||
Phase 2 often creates chapter-local source IDs for the same local PDF or
|
||||
official guideline. Final references should cite the underlying source
|
||||
once, while citation_map.json keeps the full src_id traceability.
|
||||
"""
|
||||
if not source:
|
||||
return f"missing:{sid}"
|
||||
title = re.sub(r"\s+", " ", str(source.get("title") or source.get("name") or sid)).strip().lower()
|
||||
title = title.removesuffix(" ocr").removesuffix(".ocr").strip()
|
||||
doi = str(source.get("doi") or "").strip().lower()
|
||||
if doi:
|
||||
return f"doi:{doi}"
|
||||
path = str(source.get("path") or "").strip()
|
||||
url = str(source.get("url") or "").strip()
|
||||
if title and ("phase0/extracted/" in path or "phase0/extracted/" in url):
|
||||
return f"local-material:{title}"
|
||||
for field in ("url", "path"):
|
||||
value = str(source.get(field) or "").strip()
|
||||
if value:
|
||||
return f"{field}:{value.rstrip('/').lower()}"
|
||||
return f"title:{title or sid}"
|
||||
|
||||
|
||||
def build_numeric_mapping(
|
||||
ordered_ids: list[str],
|
||||
sources: dict[str, dict[str, Any]],
|
||||
) -> tuple[dict[str, int], list[dict[str, Any]]]:
|
||||
mapping: dict[str, int] = {}
|
||||
records: list[dict[str, Any]] = []
|
||||
seen_keys: dict[str, int] = {}
|
||||
record_by_number: dict[int, dict[str, Any]] = {}
|
||||
for sid in ordered_ids:
|
||||
source = sources.get(sid)
|
||||
key = _canonical_source_key(sid, source)
|
||||
if key in seen_keys:
|
||||
number = seen_keys[key]
|
||||
mapping[sid] = number
|
||||
record_by_number[number].setdefault("source_ids", []).append(sid)
|
||||
continue
|
||||
number = len(records) + 1
|
||||
seen_keys[key] = number
|
||||
mapping[sid] = number
|
||||
record = {
|
||||
"number": number,
|
||||
"source_id": sid,
|
||||
"source_ids": [sid],
|
||||
"source": source or {},
|
||||
"dedupe_key": key,
|
||||
}
|
||||
records.append(record)
|
||||
record_by_number[number] = record
|
||||
return mapping, records
|
||||
|
||||
|
||||
def format_reference(number: int, sid: str, source: dict[str, Any] | None) -> str:
|
||||
if not source:
|
||||
return f"{number}. {sid}. (sources.jsonl 未找到该来源)"
|
||||
authors = ", ".join(source.get("authors", [])) if source.get("authors") else ""
|
||||
year = source.get("year") or source.get("date") or ""
|
||||
title = source.get("title") or source.get("name") or sid
|
||||
title = re.sub(r"(?i)(?:\s+OCR|\.ocr)$", "", str(title)).strip()
|
||||
publisher = source.get("publisher") or source.get("venue") or source.get("source") or ""
|
||||
url = source.get("url") or source.get("path") or ""
|
||||
parts = [f"{number}. "]
|
||||
if authors:
|
||||
parts.append(f"{authors}. ")
|
||||
if year:
|
||||
parts.append(f"({year}). ")
|
||||
parts.append(str(title))
|
||||
if publisher:
|
||||
parts.append(f". {publisher}")
|
||||
if url:
|
||||
parts.append(f". {url}")
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
def convert_citations(text: str, mapping: dict[str, int]) -> str:
|
||||
def repl(match: re.Match[str]) -> str:
|
||||
ids = [item.strip() for item in match.group(1).split(",") if item.strip()]
|
||||
nums: list[str] = []
|
||||
seen: set[int] = set()
|
||||
for sid in ids:
|
||||
if sid not in mapping:
|
||||
continue
|
||||
number = mapping[sid]
|
||||
if number in seen:
|
||||
continue
|
||||
seen.add(number)
|
||||
nums.append(str(number))
|
||||
return "<sup>[" + ", ".join(nums) + "]</sup>" if nums else match.group(0)
|
||||
|
||||
return SRC_CITE_RE.sub(repl, text)
|
||||
|
||||
|
||||
def strip_existing_reference_section(text: str) -> str:
|
||||
pattern = re.compile(r"\n##\s*(?:参考文献|参考来源清单|References)\s*\n.*\Z", re.S)
|
||||
return pattern.sub("", text).rstrip() + "\n"
|
||||
|
||||
|
||||
def number_citations(
|
||||
*,
|
||||
text: str,
|
||||
sources: dict[str, dict[str, Any]],
|
||||
) -> tuple[str, list[dict[str, Any]]]:
|
||||
ordered_ids = extract_ordered_source_ids(text)
|
||||
mapping, records = build_numeric_mapping(ordered_ids, sources)
|
||||
body = convert_citations(strip_existing_reference_section(text), mapping).rstrip()
|
||||
ref_lines = ["", "## 参考来源清单", ""]
|
||||
for record in records:
|
||||
ref_lines.append(format_reference(record["number"], record["source_id"], record["source"]))
|
||||
return body + "\n" + "\n".join(ref_lines).rstrip() + "\n", records
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Convert [src_xxx] citations to numeric citations")
|
||||
parser.add_argument("project", help="Project directory")
|
||||
parser.add_argument("--input", default="phase4/final_zh.md")
|
||||
parser.add_argument("--output", default="phase4/final_zh_numbered.md")
|
||||
parser.add_argument("--sources", default="phase2/sources.jsonl")
|
||||
parser.add_argument("--map", default="phase4/citation_map.json")
|
||||
args = parser.parse_args()
|
||||
|
||||
project = Path(args.project)
|
||||
src_path = project / args.input
|
||||
out_path = project / args.output
|
||||
sources_path = project / args.sources
|
||||
map_path = project / args.map
|
||||
if not src_path.exists():
|
||||
raise SystemExit(f"input not found: {src_path}")
|
||||
sources = load_sources(sources_path)
|
||||
numbered, records = number_citations(text=src_path.read_text(encoding="utf-8"), sources=sources)
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
out_path.write_text(numbered, encoding="utf-8")
|
||||
map_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
map_path.write_text(json.dumps(records, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
print(f"Wrote: {out_path.relative_to(project)}")
|
||||
print(f"Wrote: {map_path.relative_to(project)}")
|
||||
print(f"Citations: {len(records)}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
+12
-1
@@ -33,6 +33,7 @@ from scripts.lib.markdown_chunker import (
|
||||
split_by_headers,
|
||||
)
|
||||
from scripts.lib.zenmux_client import ZenMuxClient, ZenMuxError, load_secrets
|
||||
from scripts.runtime.skills import SkillRegistry
|
||||
|
||||
DEFAULT_MODEL = "anthropic/claude-sonnet-4.6"
|
||||
MODEL_MAX_TOKENS = {
|
||||
@@ -43,6 +44,16 @@ MODEL_MAX_TOKENS = {
|
||||
"anthropic/claude-haiku-4.5": 16000,
|
||||
}
|
||||
PROMPT_FILE = Path(__file__).parent / "prompts" / "polish_system.txt"
|
||||
POLISH_SKILLS = ("humanizer-cn", "output-hygiene")
|
||||
|
||||
|
||||
def build_polish_system_prompt(skill_registry: SkillRegistry | None = None) -> str:
|
||||
"""Build the Phase 4 polish prompt with canonical writing skills attached."""
|
||||
registry = skill_registry or SkillRegistry()
|
||||
parts = [PROMPT_FILE.read_text(encoding="utf-8").rstrip()]
|
||||
for skill_name in POLISH_SKILLS:
|
||||
parts.append(f"# Skill: {skill_name}\n\n{registry.read(skill_name).rstrip()}")
|
||||
return "\n\n".join(parts) + "\n"
|
||||
|
||||
|
||||
def resolve_project(arg: str) -> Path:
|
||||
@@ -163,7 +174,7 @@ def main() -> int:
|
||||
log_file = logs_dir / "polish.jsonl"
|
||||
notes_file = project_root / "phase4" / "polish_notes.jsonl"
|
||||
|
||||
system_prompt = PROMPT_FILE.read_text(encoding="utf-8")
|
||||
system_prompt = build_polish_system_prompt()
|
||||
text = src_path.read_text(encoding="utf-8")
|
||||
blocks = split_by_headers(text, max_level=2)
|
||||
|
||||
|
||||
@@ -91,34 +91,82 @@ def _chapter_title_from_id(chapter_id: str) -> str:
|
||||
return chapter_id
|
||||
|
||||
|
||||
def _load_source_registry(sources_path: Path, source_ids: list[str]) -> list[dict]:
|
||||
wanted = set(source_ids)
|
||||
if not sources_path.exists() or not wanted:
|
||||
return []
|
||||
rows: list[dict] = []
|
||||
for line in sources_path.read_text(encoding="utf-8").splitlines():
|
||||
if not line.strip():
|
||||
continue
|
||||
try:
|
||||
row = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if row.get("id") in wanted:
|
||||
rows.append(row)
|
||||
return rows
|
||||
|
||||
|
||||
def _cached_source_excerpts(project_root: Path, cached_paths: list[str], *, max_sources: int = 5, max_chars: int = 1400) -> list[dict]:
|
||||
excerpts: list[dict] = []
|
||||
for rel in cached_paths[:max_sources]:
|
||||
path = project_root / rel
|
||||
if not path.exists():
|
||||
continue
|
||||
text = path.read_text(encoding="utf-8", errors="ignore").strip()
|
||||
excerpts.append({"path": rel, "excerpt": text[:max_chars]})
|
||||
return excerpts
|
||||
|
||||
|
||||
def build_chapter_briefs(project_root: Path) -> list[dict]:
|
||||
cards = load_task_cards(project_root / "phase2" / "task_cards.json")
|
||||
grouped: dict[str, list[tuple[str, dict]]] = {}
|
||||
skipped_packets: list[dict[str, str]] = []
|
||||
for card in cards:
|
||||
packet_path = project_root / card.output_packet
|
||||
if not packet_path.exists():
|
||||
skipped_packets.append({"task_id": card.task_id, "reason": "packet file missing"})
|
||||
continue
|
||||
packet = json.loads(packet_path.read_text(encoding="utf-8"))
|
||||
validate_packet(packet)
|
||||
try:
|
||||
validate_packet(packet)
|
||||
except Exception as exc:
|
||||
skipped_packets.append({"task_id": card.task_id, "reason": str(exc)})
|
||||
continue
|
||||
for chapter_id in card.chapter_ids:
|
||||
grouped.setdefault(chapter_id, []).append((card.task_id, packet))
|
||||
|
||||
briefs: list[dict] = []
|
||||
out_dir = project_root / "phase2" / "chapter_briefs"
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
if skipped_packets:
|
||||
(project_root / "phase2" / "brief_warnings.json").write_text(
|
||||
json.dumps(skipped_packets, ensure_ascii=False, indent=2) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
for chapter_id in sorted(grouped):
|
||||
packet_pairs = sorted(grouped[chapter_id], key=lambda item: item[0])
|
||||
packet_pairs = grouped[chapter_id]
|
||||
packet_ids = [item[0] for item in packet_pairs]
|
||||
packets = [item[1] for item in packet_pairs]
|
||||
source_ids = sorted({sid for packet in packets for sid in packet.get("source_ids", [])})
|
||||
source_registry = _load_source_registry(project_root / "phase2" / "sources.jsonl", source_ids)
|
||||
cached_paths = [
|
||||
source["cached_text_path"]
|
||||
for source in source_registry
|
||||
if source.get("cached_text_path")
|
||||
]
|
||||
chapter_title = next((card.chapter_title for card in cards if chapter_id in card.chapter_ids and card.chapter_title), None)
|
||||
brief = {
|
||||
"chapter_id": chapter_id,
|
||||
"chapter_title": _chapter_title_from_id(chapter_id),
|
||||
"chapter_title": chapter_title or _chapter_title_from_id(chapter_id),
|
||||
"packet_ids": packet_ids,
|
||||
"core_claims": [claim for packet in packets for claim in packet.get("claims", [])],
|
||||
"evidence_items": [item for packet in packets for item in packet.get("evidence_items", [])],
|
||||
"counter_evidence": [item for packet in packets for item in packet.get("counter_evidence", [])],
|
||||
"source_ids": source_ids,
|
||||
"cached_source_paths": cached_paths,
|
||||
"cached_source_excerpts": _cached_source_excerpts(project_root, cached_paths),
|
||||
"open_questions": [q for packet in packets for q in packet.get("open_questions", [])],
|
||||
"assembly_notes": [
|
||||
"用中文写正式章节,英文仅保留在必要的来源标题、原文摘录、DOI/URL 中。",
|
||||
@@ -190,6 +238,8 @@ def build_compressed_findings(project_root: Path) -> list[dict]:
|
||||
],
|
||||
"counter_evidence": brief["counter_evidence"],
|
||||
"source_ids": brief["source_ids"],
|
||||
"cached_source_paths": brief.get("cached_source_paths", []),
|
||||
"cached_source_excerpts": brief.get("cached_source_excerpts", []),
|
||||
"open_questions": brief["open_questions"],
|
||||
"writing_plan": [
|
||||
"先写本章判断,不按 packet 顺序堆砌。",
|
||||
@@ -211,6 +261,7 @@ def build_chapter_user_prompt(brief: dict) -> str:
|
||||
"请根据以下 compressed finding / chapter brief 写一章正式中文 Markdown 正文。\n"
|
||||
"目标是形成一个完整章节,而不是 packet 摘要。避免碎片化,按金字塔结构组织:章首先给结论,再用证据支撑。\n"
|
||||
"要求:标题必须是观点型判断;每个数字和事实保留 [src_xxx];纳入反方证据;不要出现调度元数据。\n"
|
||||
"如 brief 中包含 cached_source_paths,说明这些是已抓取到本地的核心一手/权威信源快照;优先使用 packet 已摘录的原文,并在证据不足时标记需要从本地快照补摘录,不要重新联网检索。\n"
|
||||
"禁止写空泛咨询腔。每个二级小节都必须至少落下 2 个具体审计发现、法规要求、SOP/记录/参数/现场观察或整改证据;不要只写原则。\n"
|
||||
"正文末尾必须增加“证据落点与待补证据”小节,用表格列出:关键判断、已使用证据 source_id、已落地整改动作、仍缺证据。若证据不足,直接标注需回炉 Phase 2,不要用泛泛表述补齐。\n"
|
||||
"只输出 Markdown,不要输出解释。\n\n"
|
||||
@@ -238,6 +289,7 @@ class ChapterAssemblyWorker:
|
||||
except FileNotFoundError:
|
||||
skill_texts.append(f"# Skill: {name}\n\n[missing skill: {name}]")
|
||||
return (
|
||||
f"{self.role.identity}\n\n"
|
||||
"你是 Deep Research v0.20 的中文章节组装 worker。\n"
|
||||
"你的职责是把结构化证据包收束成连贯章节,解决并发研究造成的碎片化。\n"
|
||||
"不得编造来源,不得删除关键反方证据。\n\n"
|
||||
|
||||
@@ -21,6 +21,7 @@ class ResearchMethod:
|
||||
structure_principle: str
|
||||
task_axes: list[str]
|
||||
framework_sections: list[str]
|
||||
integrated_lanes: list[str]
|
||||
|
||||
|
||||
class ResearchMethodRegistry:
|
||||
@@ -56,5 +57,5 @@ class ResearchMethodRegistry:
|
||||
structure_principle=item.get("structure_principle", ""),
|
||||
task_axes=list(item.get("task_axes") or []),
|
||||
framework_sections=list(item.get("framework_sections") or []),
|
||||
integrated_lanes=list(item.get("integrated_lanes") or item.get("task_axes") or []),
|
||||
)
|
||||
|
||||
|
||||
@@ -32,6 +32,15 @@ def create_phase2_task_cards(
|
||||
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"))
|
||||
if not research_brief.get("materials"):
|
||||
material_inventory = load_manifest(project_root).get("material_inventory") or []
|
||||
materials = []
|
||||
for item in material_inventory:
|
||||
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"})
|
||||
if materials:
|
||||
research_brief["materials"] = materials
|
||||
cards = generate_task_cards_from_research_brief(
|
||||
project_root.name,
|
||||
framework_text,
|
||||
|
||||
+455
-26
@@ -192,10 +192,10 @@ def write_material_brief(
|
||||
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": "诊断运营管理、跨部门协同、会议机制、指标体系和交付节奏的结构性问题。",
|
||||
"nmpa_fda_ema_ich_who_baseline": "把 NMPA、FDA、EMA、ICH、WHO、药典或 Annex 1 等要求转化为可核验的法规基线,并纳入 FDA warning letters 与会议材料作为执法尺度参照。",
|
||||
"quality_system_gap": "把现场发现映射到质量体系流程缺口,覆盖偏差、变更、CAPA、文件、培训和数据完整性;优先检索 FDA warning letters 中同类缺陷的执法表述。",
|
||||
"manufacturing_process_risk": "围绕生产工艺、设施、公用系统、CPP/CQA、验证和无菌保障识别系统性风险,并用 FDA warning letters / inspection enforcement examples 校准严重度。",
|
||||
"operations_management_gap": "诊断运营管理、跨部门协同、会议机制、指标体系和交付节奏的结构性问题,并参考 FDA 会议纪要/meeting materials 中对质量治理的关注点。",
|
||||
"team_capability": "识别人员能力、岗位职责、质量文化和管理梯队方面的缺口与建设路径。",
|
||||
"capa_roadmap": "把差距转化为短中长期 CAPA 组合,要求绑定 owner、期限、优先级、关闭证据和复核机制。",
|
||||
"verification_evidence": "定义整改完成后可被审计接受的验证证据,包括记录、报告、趋势和管理评审输入。",
|
||||
@@ -213,13 +213,366 @@ def _material_paths(manifest: dict[str, Any]) -> list[dict[str, str]]:
|
||||
return materials
|
||||
|
||||
|
||||
def _keywords_from_title(title: str) -> list[str]:
|
||||
english = re.findall(r"[A-Za-z][A-Za-z0-9/+-]{1,}", title)
|
||||
chinese_parts = re.split(r"[,,、;;::\s]+|和|与|及|的|在|为|从|来自|集中|决定|需要|形成|成为|不是|而是", title)
|
||||
domain_terms = [
|
||||
"审计",
|
||||
"商业化",
|
||||
"阶段门",
|
||||
"风险",
|
||||
"法规",
|
||||
"欧盟",
|
||||
"NMPA",
|
||||
"GMP",
|
||||
"ICH",
|
||||
"无菌",
|
||||
"RABS",
|
||||
"First Air",
|
||||
"APS",
|
||||
"灯检",
|
||||
"隧道",
|
||||
"原液",
|
||||
"WFI",
|
||||
"SCADA",
|
||||
"EMS",
|
||||
"CPP",
|
||||
"CQA",
|
||||
"PPQ",
|
||||
"清洁验证",
|
||||
"偏差",
|
||||
"变更",
|
||||
"CAPA",
|
||||
"数据完整性",
|
||||
"人员",
|
||||
"培训",
|
||||
"质量文化",
|
||||
"运营",
|
||||
"跨部门",
|
||||
"指标",
|
||||
"团队",
|
||||
"CDMO",
|
||||
"整改",
|
||||
"owner",
|
||||
]
|
||||
title_terms = [term for term in domain_terms if term in title]
|
||||
keywords = [item.strip() for item in [*english, *title_terms, *chinese_parts] if len(item.strip()) >= 2]
|
||||
seen: set[str] = set()
|
||||
unique: list[str] = []
|
||||
for keyword in keywords:
|
||||
if keyword not in seen:
|
||||
seen.add(keyword)
|
||||
unique.append(keyword)
|
||||
return unique[:12]
|
||||
|
||||
|
||||
def _material_lines_for_chapter(project_root: Path, manifest: dict[str, Any], title: str, *, limit: int = 4) -> list[str]:
|
||||
keywords = _keywords_from_title(title)
|
||||
candidates: list[tuple[int, int, str]] = []
|
||||
order = 0
|
||||
for item in manifest.get("material_inventory") or []:
|
||||
rel = item.get("ocr_extracted_to") or item.get("extracted_to")
|
||||
if not rel:
|
||||
continue
|
||||
path = project_root / rel
|
||||
if not path.exists():
|
||||
continue
|
||||
for raw in path.read_text(encoding="utf-8").splitlines():
|
||||
line = raw.strip()
|
||||
if len(line) < 8 or len(line) > 220:
|
||||
continue
|
||||
if line.startswith("#") or line.startswith("- source_path:") or line.startswith("- extracted_at:"):
|
||||
continue
|
||||
if "OCR Material:" in line:
|
||||
continue
|
||||
if re.match(r"^(审计对象|审计执行方|审计执行人|审计时间)[::]", line):
|
||||
continue
|
||||
score = sum(1 for keyword in keywords if keyword and keyword in line)
|
||||
if score:
|
||||
order += 1
|
||||
candidates.append((score, order, f"{rel}:{line}"))
|
||||
candidates.sort(key=lambda item: (-item[0], item[1]))
|
||||
return [line for _, _, line in candidates[:limit]]
|
||||
|
||||
|
||||
def _minimum_evidence_for_method(method: ResearchMethod) -> dict[str, Any]:
|
||||
if method.key == "gmp_quality_operations_diagnosis":
|
||||
return {
|
||||
"local_material_quotes": 2,
|
||||
"official_regulatory_or_guideline_sources": 2,
|
||||
"enforcement_or_best_practice_precedents": 1,
|
||||
"counter_evidence_or_boundary_conditions": 1,
|
||||
"actionable_remediation_items": 3,
|
||||
}
|
||||
return {
|
||||
"high_quality_sources": 4,
|
||||
"tier_1_2_sources": 2,
|
||||
"counter_evidence_or_boundary_conditions": 1,
|
||||
"decision_relevant_implications": 2,
|
||||
}
|
||||
|
||||
|
||||
def _central_thesis(manifest: dict[str, Any], method: ResearchMethod) -> str:
|
||||
topic = manifest.get("topic") or manifest.get("report_title") or "本研究主题"
|
||||
if method.key == "gmp_quality_operations_diagnosis":
|
||||
return (
|
||||
f"初始主判断:{topic} 不应只按审计风险项数量来评价,而应从商业化 readiness、"
|
||||
"质量体系运行成熟度、生产工艺证据链和运营协同能力四条线同时诊断。Phase 2 必须用"
|
||||
"现场材料原文、官方法规/指南、执法案例或标杆实践来证明、修正或推翻这一判断。"
|
||||
)
|
||||
return (
|
||||
f"初始主判断:{topic} 需要先形成可被证据推翻的观点型框架,再由 Phase 2 按方法论证据线"
|
||||
"逐项求证;不能把并发检索结果直接堆砌成报告。"
|
||||
)
|
||||
|
||||
|
||||
def _strategy_for_chapter(title: str, method: ResearchMethod) -> dict[str, Any]:
|
||||
"""Return non-tautological Phase 1 strategy text for a chapter title."""
|
||||
if method.key != "gmp_quality_operations_diagnosis":
|
||||
return {
|
||||
"core_question": f"本章需要判断:在什么证据条件下“{title}”成立,它会怎样改变最终决策?",
|
||||
"bold_hypothesis": f"初始假设不是复述标题,而是预判“{title}”背后存在一个可被验证的因果机制;Phase 2 需要找证据支持、修正或推翻这个机制。",
|
||||
"writing_claim": f"本章要把“{title}”写成一个可被证据检验的判断,而不是资料综述。",
|
||||
"counter_evidence": [
|
||||
"是否存在更简单的替代解释,能削弱本章主判断?",
|
||||
"关键证据是否只来自单一来源或利益相关来源?",
|
||||
"是否有反例显示本章判断只适用于部分场景?",
|
||||
],
|
||||
}
|
||||
|
||||
strategies = [
|
||||
(
|
||||
("审计", "阶段门"),
|
||||
{
|
||||
"core_question": "审计报告的低/中风险项计数,是否低估了白帆从临床/受托生产走向商业化标准时需要跨过的阶段门?",
|
||||
"bold_hypothesis": "初始假设:白帆的硬件和文件基础总体可用,但审计材料暴露的是商业化 readiness 缺口,而不是简单的若干孤立缺陷;Phase 2 应验证这些缺口是否集中在无菌保障、工艺验证、质量闭环和运营节奏。",
|
||||
"writing_claim": "本章要先把“风险项清单”翻译成管理层可决策的阶段门地图,说明哪些问题影响商业化放行、客户审计和技术转移节奏。",
|
||||
"counter_evidence": [
|
||||
"是否已有整改证据证明这些问题只是审计时点的临时缺口?",
|
||||
"低/中风险评级是否足以说明商业化阶段门影响有限?",
|
||||
"审计范围有限是否导致本章不能外推到整体体系成熟度?",
|
||||
],
|
||||
},
|
||||
),
|
||||
(
|
||||
("法规", "欧盟", "NMPA", "ICH"),
|
||||
{
|
||||
"core_question": "如果按 EU Annex 1、NMPA GMP、ICH Q9/Q10 以及 FDA 执法尺度校准,哪些现场发现的严重度和整改优先级会发生变化?",
|
||||
"bold_hypothesis": "初始假设:白帆按国内 GMP 逻辑已具备基础合规框架,但若以欧盟无菌标准和质量风险管理要求衡量,部分“低风险/建议项”会转化为体系成熟度缺口。",
|
||||
"writing_claim": "本章要建立后文共用的法规基线,避免整改优先级只跟随原审计评级,而忽略国际化和商业化标准。",
|
||||
"counter_evidence": [
|
||||
"相关国际标准是否并不适用于当前产品阶段或委托生产边界?",
|
||||
"NMPA 与欧盟/美国要求之间是否存在可接受差异?",
|
||||
"是否有企业内部标准已经覆盖但审计材料未呈现?",
|
||||
],
|
||||
},
|
||||
),
|
||||
(
|
||||
("无菌", "RABS", "First Air", "APS", "灯检"),
|
||||
{
|
||||
"core_question": "制剂线的主要无菌风险,是硬件布局不足,还是人员干预、首次气流保护、APS 覆盖和灯检标准执行证据不足?",
|
||||
"bold_hypothesis": "初始假设:白帆制剂车间硬件基础并非主要短板,真正风险在于关键无菌行为和模拟验证是否能持续证明受控;Phase 2 应重点查 First Air、RABS 干预、APS 场景设计和灯检阳性样品管理。",
|
||||
"writing_claim": "本章要把无菌保障从“设施看起来合规”推进到“关键操作和验证证据可被审计接受”。",
|
||||
"counter_evidence": [
|
||||
"现场是否已有完整视频复核、APS 覆盖和再培训有效性证据?",
|
||||
"观察到的无菌动作问题是否只是个别人员或单次拍摄偏差?",
|
||||
"灯检和 RABS 风险是否已有 SOP、趋势和复核记录闭环?",
|
||||
],
|
||||
},
|
||||
),
|
||||
(
|
||||
("原液", "WFI", "SCADA", "EMS"),
|
||||
{
|
||||
"core_question": "原液和公用系统的风险是否被一次性封闭工艺掩盖,真正缺口在 WFI、SCADA/EMS、离线记录和异常升级证据链?",
|
||||
"bold_hypothesis": "初始假设:一次性反应器和封闭转移降低了暴露风险,但不能自动证明系统受控;Phase 2 应验证 WFI 冷却回流、环境/压差报警、SCADA 数据和离线检测记录是否形成完整证据链。",
|
||||
"writing_claim": "本章要说明原液与公用系统不是“硬件先进即可”,而是要证明关键状态、报警、数据和异常处理持续受控。",
|
||||
"counter_evidence": [
|
||||
"WFI、SCADA/EMS 和离线记录是否已有验证报告与趋势复核?",
|
||||
"一次性系统是否已经充分降低共线和交叉污染风险?",
|
||||
"被指出的公用系统风险是否只是设计建议而非实际偏差?",
|
||||
],
|
||||
},
|
||||
),
|
||||
(
|
||||
("工艺", "CPP", "CQA", "PPQ", "清洁验证"),
|
||||
{
|
||||
"core_question": "现有 IND 阶段工艺规程和批记录,距离商业化 PPQ、控制策略和清洁验证所需证据还差在哪里?",
|
||||
"bold_hypothesis": "初始假设:白帆目前的工艺文件足以支撑临床阶段执行,但不足以支撑商业化批记录、CPP/CQA 控制、PPQ 和清洁验证闭环;Phase 2 应查明哪些字段、参数和验证证据必须前置补齐。",
|
||||
"writing_claim": "本章要把技术转移风险具体化为文件、参数、验证和批记录的硬门槛。",
|
||||
"counter_evidence": [
|
||||
"是否已有商业化模板、控制策略或 PPQ 草案未体现在审计材料中?",
|
||||
"当前项目阶段是否尚不需要完整商业化批记录要求?",
|
||||
"清洁验证和工艺验证是否已有主计划覆盖?",
|
||||
],
|
||||
},
|
||||
),
|
||||
(
|
||||
("偏差", "变更", "CAPA", "数据完整性"),
|
||||
{
|
||||
"core_question": "白帆的问题是没有质量流程,还是流程之间的事件分类、升级、CAPA 有效性和数据完整性尚未形成运行闭环?",
|
||||
"bold_hypothesis": "初始假设:白帆已有偏差、变更和 CAPA 的流程框架,但事件何时启动偏差、何时作为变更、如何证明 CAPA 有效,以及电子/纸质数据如何贯通,仍存在运行机制缺口。",
|
||||
"writing_claim": "本章要把质量体系从“有 SOP”推进到“事件能被正确分类、调查、纠正、验证并趋势复核”。",
|
||||
"counter_evidence": [
|
||||
"是否有趋势分析、管理评审和 CAPA effectiveness check 证明体系已经闭环?",
|
||||
"个别事件分类问题是否不足以代表体系性缺口?",
|
||||
"电子系统和纸质记录之间是否已有数据完整性控制?",
|
||||
],
|
||||
},
|
||||
),
|
||||
(
|
||||
("人员", "培训", "质量文化"),
|
||||
{
|
||||
"core_question": "培训记录齐全是否真的转化为一线无菌行为、偏差判断和质量风险意识?哪些证据能证明培训有效?",
|
||||
"bold_hypothesis": "初始假设:白帆不缺培训台账,缺的是把培训结果转化为现场行为的一致性证据;如果 First Air、干预动作、事件判断和灯检执行仍需反复提醒,问题就不是“再培训一次”,而是培训有效性确认和质量文化运行机制不足。",
|
||||
"writing_claim": "本章要把人员问题从“有没有培训”改写为“培训是否改变行为、降低风险、形成可复核证据”。",
|
||||
"counter_evidence": [
|
||||
"现场抽问、资格确认和再培训记录是否已证明人员理解到位?",
|
||||
"被观察到的行为问题是否只发生在少数岗位或单次演示?",
|
||||
"是否有岗位胜任力矩阵、年度复评和行为观察数据支撑人员能力?",
|
||||
],
|
||||
},
|
||||
),
|
||||
(
|
||||
("运营", "跨部门", "指标", "review"),
|
||||
{
|
||||
"core_question": "白帆当前整改和生产准备依赖个人推动,还是已经形成跨部门例会、问题升级、指标看板和管理层复核的运营系统?",
|
||||
"bold_hypothesis": "初始假设:运营短板不在于团队不努力,而在于缺少固定节奏和可视化管理系统;如果 owner、关闭证据、升级阈值和管理层 review 不稳定,整改会停留在临时协调,难以支撑商业化节奏。",
|
||||
"writing_claim": "本章要说明运营管理是 GMP 风险的放大器:没有节奏、看板和升级机制,技术和质量问题会反复跨部门漂移。",
|
||||
"counter_evidence": [
|
||||
"是否已经存在稳定 PMO/例会/看板,只是未进入审计材料?",
|
||||
"短期临时协调是否足以覆盖当前项目阶段,不需要完整运营系统?",
|
||||
"owner、期限和关闭证据是否已经在复盘文件中基本清楚?",
|
||||
],
|
||||
},
|
||||
),
|
||||
(
|
||||
("团队", "CDMO", "能力矩阵"),
|
||||
{
|
||||
"core_question": "对标成熟 CDMO,白帆最需要补齐的是人数、岗位能力,还是 QA/MSAT/工程/项目管理之间的角色分工?",
|
||||
"bold_hypothesis": "初始假设:白帆的能力缺口不是简单扩编,而是商业化 CDMO 所需的角色矩阵尚未完全成型;Phase 2 应验证 QA 独立性、MSAT 工艺支持、工程保障、生产班组和 PMO 协同能力。",
|
||||
"writing_claim": "本章要给出面向商业化的团队能力地图,说明哪些能力必须自建,哪些可外部支持,哪些要通过机制补齐。",
|
||||
"counter_evidence": [
|
||||
"现有人员是否已具备商业化经验,只是材料未体现?",
|
||||
"对标 CDMO 是否会高估当前阶段所需组织复杂度?",
|
||||
"是否可通过顾问、外包或客户支持临时补足能力?",
|
||||
],
|
||||
},
|
||||
),
|
||||
(
|
||||
("整改", "owner", "路线图"),
|
||||
{
|
||||
"core_question": "哪些整改必须立即完成,哪些属于体系补强,哪些是能力建设?每项如何绑定 owner、关闭证据和复核窗口?",
|
||||
"bold_hypothesis": "初始假设:如果整改只按问题清单逐条关闭,会漏掉体系性根因;更有效的路线应分为立即纠偏、90 天体系补强和中长期能力建设三层,并为每层定义关闭证据。",
|
||||
"writing_claim": "本章要把诊断转化为可执行 CAPA 组合,而不是泛泛的改进建议。",
|
||||
"counter_evidence": [
|
||||
"是否已有整改计划足以覆盖 owner、期限、关闭证据和 QA verification?",
|
||||
"部分整改是否应前移或后移,避免资源过载?",
|
||||
"哪些建议若缺少法规证据,不应被列为强制整改?",
|
||||
],
|
||||
},
|
||||
),
|
||||
(
|
||||
("管理层", "CAPA", "总表"),
|
||||
{
|
||||
"core_question": "管理层应通过什么样的 CAPA 总表、法规映射表和复核节奏,持续判断整改是否真正降低风险?",
|
||||
"bold_hypothesis": "初始假设:白帆需要的不只是一次性报告,而是一套管理层可追踪的整改仪表盘;否则 CAPA 关闭会变成文件动作,无法证明风险趋势下降和商业化 readiness 提升。",
|
||||
"writing_claim": "本章要把报告成果固化成管理层治理工具:CAPA 总表、法规映射、证据包和复核节奏。",
|
||||
"counter_evidence": [
|
||||
"现有管理评审或质量例会是否已经能承担这个功能?",
|
||||
"过度表格化是否会增加一线负担而不改善风险?",
|
||||
"哪些指标真正能反映风险降低,而不是制造形式化 KPI?",
|
||||
],
|
||||
},
|
||||
),
|
||||
]
|
||||
for needles, strategy in strategies:
|
||||
if any(needle in title for needle in needles):
|
||||
return strategy
|
||||
return {
|
||||
"core_question": f"本章需要判断“{title}”背后的真实风险、适用边界和整改优先级。",
|
||||
"bold_hypothesis": f"初始假设:{title} 不是孤立问题,而是质量体系、工艺证据或运营机制中的一个可验证缺口;Phase 2 必须用材料原文和外部证据判断其严重度。",
|
||||
"writing_claim": f"本章要把“{title}”转化为可执行的诊断结论和整改要求。",
|
||||
"counter_evidence": [
|
||||
"该问题是否已有充分整改或验证证据?",
|
||||
"是否只是阶段性限制,而非系统性缺口?",
|
||||
"外部标准是否适用于当前业务边界?",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def build_chapter_planning(
|
||||
project_root: Path,
|
||||
manifest: dict[str, Any],
|
||||
method: ResearchMethod,
|
||||
titles: list[str],
|
||||
*,
|
||||
quota: int,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Build hypothesis-driven chapter plans that become Phase 2 prompt context."""
|
||||
lanes = list(method.integrated_lanes or method.task_axes)
|
||||
minimum_evidence = _minimum_evidence_for_method(method)
|
||||
plans: list[dict[str, Any]] = []
|
||||
for idx, title in enumerate(titles, start=1):
|
||||
chapter_id = f"ch{idx:02d}"
|
||||
material_lines = _material_lines_for_chapter(project_root, manifest, title)
|
||||
if not material_lines:
|
||||
material_lines = ["未在材料中自动匹配到足够线索;Phase 2 必须先回读全部输入材料并补充原文摘录。"]
|
||||
strategy = _strategy_for_chapter(title, method)
|
||||
core_question = strategy["core_question"]
|
||||
bold_hypothesis = strategy["bold_hypothesis"]
|
||||
verification_plan = [
|
||||
"先从允许的本地材料提取 2-4 条原文证据,保留出处和上下文。",
|
||||
f"再按方法论 evidence lanes 求证:{';'.join(lanes)}。",
|
||||
"每个核心判断至少匹配 2 个独立高质量来源;不足时降级为待验证判断。",
|
||||
"主动搜索反方证据、低严重度解释、适用范围限制或替代原因。",
|
||||
"输出时把证据、判断、整改/建议和待补证据分开,避免直接写成散文化正文。",
|
||||
]
|
||||
counter_evidence = strategy["counter_evidence"]
|
||||
writing_claim = strategy["writing_claim"]
|
||||
phase2_prompt_context = "\n".join(
|
||||
[
|
||||
f"章节:{chapter_id} {title}",
|
||||
core_question,
|
||||
bold_hypothesis,
|
||||
"材料起点:",
|
||||
*[f"- {line}" for line in material_lines],
|
||||
"求证路线:",
|
||||
*[f"- {item}" for item in verification_plan],
|
||||
"必须寻找的反方/边界:",
|
||||
*[f"- {item}" for item in counter_evidence],
|
||||
f"写作主张:{writing_claim}",
|
||||
f"最低证据要求:{json.dumps(minimum_evidence, ensure_ascii=False)}",
|
||||
]
|
||||
)
|
||||
plans.append(
|
||||
{
|
||||
"chapter_id": chapter_id,
|
||||
"title": title,
|
||||
"suggested_words": quota,
|
||||
"core_question": core_question,
|
||||
"bold_hypothesis": bold_hypothesis,
|
||||
"why_this_matters": "本章用于把 Phase1 的判断转化为 Phase2 可验证命题,并为最终报告保留清晰主线。",
|
||||
"material_starting_points": material_lines,
|
||||
"evidence_lanes": lanes,
|
||||
"verification_plan": verification_plan,
|
||||
"counter_evidence_to_seek": counter_evidence,
|
||||
"writing_claim": writing_claim,
|
||||
"minimum_evidence": minimum_evidence,
|
||||
"phase2_prompt_context": phase2_prompt_context,
|
||||
}
|
||||
)
|
||||
return plans
|
||||
|
||||
|
||||
def build_research_brief_payload(
|
||||
project_root: Path,
|
||||
manifest: dict[str, Any],
|
||||
method: ResearchMethod,
|
||||
chapter_planning: list[dict[str, Any]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Create the file-backed Phase 1 research brief used by task-card generation."""
|
||||
axes = list(method.task_axes)
|
||||
chapter_planning = chapter_planning or []
|
||||
return {
|
||||
"version": "0.21-alpha",
|
||||
"topic": manifest.get("topic", project_root.name),
|
||||
@@ -228,6 +581,13 @@ def build_research_brief_payload(
|
||||
"work_language": "zh",
|
||||
"tone": "事实型、整改导向、面向管理层和质量/生产负责人;避免空泛咨询腔。",
|
||||
"central_question": f"如何基于已提供材料和权威法规/最佳实践,系统诊断“{manifest.get('topic', project_root.name)}”并形成可执行整改路线图?",
|
||||
"central_thesis": _central_thesis(manifest, method),
|
||||
"phase_logic": {
|
||||
"phase1": "大胆假设:结合输入材料、访谈信息和初步搜索,定下主基调、章节命题和求证路线。",
|
||||
"phase2": "小心求证:worker 只围绕 Phase1 命题收集、验证、证伪和补证,不自行重写研究方向。",
|
||||
"phase3": "一致性审校:检查 Phase1 假设与 Phase2 证据是否自洽,指出需要回炉的章节或证据缺口。",
|
||||
},
|
||||
"phase2_mode": "chapter_integrated",
|
||||
"success_criteria": [
|
||||
"每个核心判断都能回到用户材料、权威法规、最佳实践或反方证据。",
|
||||
"短中长期整改建议必须绑定优先级、责任、关闭证据和复核机制。",
|
||||
@@ -239,8 +599,10 @@ def build_research_brief_payload(
|
||||
"research_brief_path": "phase1/research_brief.json",
|
||||
},
|
||||
"materials": _material_paths(manifest),
|
||||
"chapter_planning": chapter_planning,
|
||||
"task_planning": {
|
||||
"chapter_source": "phase1/framework.md",
|
||||
"phase2_mode": "chapter_integrated",
|
||||
"axes": axes,
|
||||
"required_skills": [
|
||||
"deep-research",
|
||||
@@ -275,29 +637,62 @@ def write_research_brief(
|
||||
project_root: Path,
|
||||
manifest: dict[str, Any] | None = None,
|
||||
method: ResearchMethod | None = None,
|
||||
chapter_planning: list[dict[str, Any]] | 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)
|
||||
payload = build_research_brief_payload(project_root, manifest, method, chapter_planning=chapter_planning)
|
||||
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)
|
||||
hypothesis_path = project_root / "phase1" / "hypothesis_map.json"
|
||||
json_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
hypothesis_path.write_text(json.dumps(payload.get("chapter_planning") or [], 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']}",
|
||||
f"- phase2_mode: {payload['phase2_mode']}",
|
||||
"",
|
||||
"## 中心问题",
|
||||
"",
|
||||
payload["central_question"],
|
||||
"",
|
||||
"## 成功标准",
|
||||
"## 主基调 / 大胆假设",
|
||||
"",
|
||||
payload["central_thesis"],
|
||||
"",
|
||||
"## Phase 逻辑",
|
||||
"",
|
||||
]
|
||||
for phase_name, phase_text in payload["phase_logic"].items():
|
||||
lines.append(f"- `{phase_name}`:{phase_text}")
|
||||
lines.extend([
|
||||
"",
|
||||
"## 成功标准",
|
||||
"",
|
||||
])
|
||||
lines.extend(f"- {item}" for item in payload["success_criteria"])
|
||||
if payload.get("chapter_planning"):
|
||||
lines.extend(["", "## 章节命题与求证计划", ""])
|
||||
for item in payload["chapter_planning"]:
|
||||
lines.extend(
|
||||
[
|
||||
f"### {item['chapter_id']} {item['title']}",
|
||||
"",
|
||||
f"- 核心问题:{item['core_question']}",
|
||||
f"- 大胆假设:{item['bold_hypothesis']}",
|
||||
f"- 写作主张:{item['writing_claim']}",
|
||||
f"- 证据线:{';'.join(item['evidence_lanes'])}",
|
||||
"- 材料起点:",
|
||||
]
|
||||
)
|
||||
lines.extend(f" - {line}" for line in item["material_starting_points"])
|
||||
lines.extend(["- 求证计划:"])
|
||||
lines.extend(f" - {line}" for line in item["verification_plan"])
|
||||
lines.extend([""])
|
||||
lines.extend(["", "## 任务切分原则", ""])
|
||||
planning = payload["task_planning"]
|
||||
lines.append(planning["fragmentation_guard"])
|
||||
@@ -377,30 +772,49 @@ CHAPTER_TEMPLATES: dict[str, list[str]] = {
|
||||
"落地机制决定咨询建议能否转化为成果",
|
||||
],
|
||||
"gmp_quality_operations_diagnosis": [
|
||||
"现场审计发现需要先转化为可验证的系统性问题图谱",
|
||||
"法规基线决定质量体系差距的严重度与整改边界",
|
||||
"生产工艺体系风险来自流程、设施、公用系统和验证证据的耦合缺口",
|
||||
"偏差、变更、CAPA 和数据完整性决定质量系统能否闭环",
|
||||
"人员能力与质量文化决定制度是否真正落地",
|
||||
"运营管理问题需要区分组织、流程、会议机制和指标体系缺口",
|
||||
"跨部门协同断点会放大 GMP 风险和交付风险",
|
||||
"标杆实践应转化为短中长期整改组合而非口号",
|
||||
"整改路线图必须绑定责任、优先级、证据和复核机制",
|
||||
"管理层治理机制决定白帆能否从一次整改转向持续改进",
|
||||
"从审计清单到商业化阶段门",
|
||||
"用法规基线重新校准整改优先级",
|
||||
"制剂无菌保障:从硬件合规到行为受控",
|
||||
"原液与公用系统:封闭工艺背后的证据缺口",
|
||||
"工艺文件与验证:商业化转移的硬门槛",
|
||||
"质量系统闭环:偏差、变更、CAPA 与数据完整性",
|
||||
"人员能力:培训有效性比培训记录更关键",
|
||||
"运营节奏:从临时协调转向管理系统",
|
||||
"团队建设:按 CDMO 能力矩阵补齐角色",
|
||||
"整改路线图:立即纠偏、体系补强、能力建设",
|
||||
"管理层看板:用 CAPA 总表驱动复核",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def render_framework(project_root: Path, *, method_key: str | None = None, chapter_count: int = 10) -> Path:
|
||||
def _existing_chapter_titles(project_root: Path) -> list[str]:
|
||||
framework_path = project_root / "phase1" / "framework.md"
|
||||
if not framework_path.exists():
|
||||
return []
|
||||
from scripts.runtime.tasks import parse_framework_chapters
|
||||
|
||||
chapters = parse_framework_chapters(framework_path.read_text(encoding="utf-8"))
|
||||
return [chapter.title for chapter in chapters if chapter.title]
|
||||
|
||||
|
||||
def render_framework(
|
||||
project_root: Path,
|
||||
*,
|
||||
method_key: str | None = None,
|
||||
chapter_count: int = 10,
|
||||
preserve_existing_outline: bool = False,
|
||||
) -> Path:
|
||||
manifest = load_manifest(project_root)
|
||||
registry = ResearchMethodRegistry()
|
||||
method = registry.get(method_key or manifest.get("research_method"))
|
||||
if method_key:
|
||||
manifest["research_method"] = method.key
|
||||
titles = CHAPTER_TEMPLATES.get(method.key) or CHAPTER_TEMPLATES["mckinsey_market"]
|
||||
existing_titles = _existing_chapter_titles(project_root) if preserve_existing_outline else []
|
||||
titles = existing_titles or CHAPTER_TEMPLATES.get(method.key) or CHAPTER_TEMPLATES["mckinsey_market"]
|
||||
chapter_count = max(8, min(15, chapter_count))
|
||||
selected = titles[:chapter_count]
|
||||
selected = titles[:chapter_count] if not existing_titles else titles
|
||||
quota = max(800, int(manifest.get("target_words", 30000)) // len(selected))
|
||||
chapter_planning = build_chapter_planning(project_root, manifest, method, selected, quota=quota)
|
||||
sections = "\n".join(f"- {item}" for item in method.framework_sections)
|
||||
axes = "、".join(method.task_axes)
|
||||
material_text = render_material_inventory(manifest.get("material_inventory") or [])
|
||||
@@ -430,17 +844,32 @@ def render_framework(project_root: Path, *, method_key: str | None = None, chapt
|
||||
"",
|
||||
"## 中心假设",
|
||||
"",
|
||||
f"围绕“{manifest['topic']}”形成可被证据支持或证伪的中文主线;所有核心判断必须绑定来源 ID。",
|
||||
_central_thesis(manifest, method),
|
||||
"",
|
||||
"Phase1 的职责是大胆假设:基于材料、访谈和初步搜索定下主基调、章节命题和求证路线。Phase2 的职责是小心求证:验证、证伪、补证,而不是重新发明报告方向。Phase3 则检查 Phase1 假设与 Phase2 证据是否自洽。",
|
||||
"",
|
||||
]
|
||||
for idx, title in enumerate(selected, start=1):
|
||||
for item in chapter_planning:
|
||||
lines.extend(
|
||||
[
|
||||
f"## 第{idx}章 {title}",
|
||||
f"## 第{int(item['chapter_id'][2:])}章 {item['title']}",
|
||||
"",
|
||||
f"建议字数:约 {quota} 字。",
|
||||
f"研究思路:围绕 `{method.key}` 的方法框架,从 {axes} 等任务轴并发收集 evidence packet,再由 chapter assembly 收束为完整中文章节。",
|
||||
"证据要求:至少 2 个独立 Tier 1-2 信源;不足时在正文标注待验证;必须包含反方证据。",
|
||||
f"建议字数:约 {item['suggested_words']} 字。",
|
||||
f"本章要解决的问题:{item['core_question']}",
|
||||
f"大胆假设:{item['bold_hypothesis']}",
|
||||
f"写作主张:{item['writing_claim']}",
|
||||
f"证据线:{';'.join(item['evidence_lanes'])}",
|
||||
"",
|
||||
"材料起点:",
|
||||
*[f"- {line}" for line in item["material_starting_points"]],
|
||||
"",
|
||||
"求证计划:",
|
||||
*[f"- {line}" for line in item["verification_plan"]],
|
||||
"",
|
||||
"必须寻找的反方/边界:",
|
||||
*[f"- {line}" for line in item["counter_evidence_to_seek"]],
|
||||
"",
|
||||
f"最低证据要求:`{json.dumps(item['minimum_evidence'], ensure_ascii=False)}`",
|
||||
"",
|
||||
]
|
||||
)
|
||||
@@ -457,7 +886,7 @@ 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)
|
||||
research_brief_md, research_brief_json = write_research_brief(project_root, manifest, method, chapter_planning=chapter_planning)
|
||||
manifest["phase1"] = {
|
||||
"status": "completed",
|
||||
"approved": False,
|
||||
|
||||
+172
-1
@@ -1,4 +1,4 @@
|
||||
"""Deterministic Phase 3 review checks for the Python core."""
|
||||
"""Phase 3 review checks for the Python core."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -52,6 +52,24 @@ def _ready_packet_stems(project_root: Path) -> set[str]:
|
||||
return ready
|
||||
|
||||
|
||||
def _read_text_if_exists(path: Path, *, max_chars: int | None = None) -> str:
|
||||
if not path.exists():
|
||||
return ""
|
||||
text = path.read_text(encoding="utf-8", errors="ignore")
|
||||
return text[:max_chars] if max_chars is not None else text
|
||||
|
||||
|
||||
def _json_if_exists(path: Path, *, max_chars: int | None = None) -> str:
|
||||
if not path.exists():
|
||||
return ""
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
text = json.dumps(data, ensure_ascii=False, indent=2)
|
||||
except Exception:
|
||||
text = path.read_text(encoding="utf-8", errors="ignore")
|
||||
return text[:max_chars] if max_chars is not None else text
|
||||
|
||||
|
||||
def _draft_quality_findings(drafts: list[Path]) -> list[dict[str, Any]]:
|
||||
findings: list[dict[str, Any]] = []
|
||||
generic_markers = [
|
||||
@@ -77,6 +95,159 @@ def _draft_quality_findings(drafts: list[Path]) -> list[dict[str, Any]]:
|
||||
return findings
|
||||
|
||||
|
||||
def build_phase3_model_review_context(project_root: Path, *, max_chars: int = 650_000) -> str:
|
||||
"""Build a structured, bounded context packet for an independent model review."""
|
||||
deterministic_path = build_phase3_critique(project_root)
|
||||
deterministic_copy = project_root / "phase3" / "critique_deterministic.md"
|
||||
deterministic_copy.write_text(deterministic_path.read_text(encoding="utf-8"), encoding="utf-8")
|
||||
|
||||
manifest = load_manifest(project_root)
|
||||
parts: list[str] = [
|
||||
f"# Phase 3 Model Review Context: {manifest.get('topic', project_root.name)}",
|
||||
"",
|
||||
"## Review Contract",
|
||||
"",
|
||||
"- 这是给非 Codex 模型的独立总编审校上下文,不要求重写正文。",
|
||||
"- 请判断 Phase2 草稿能否进入 Phase4,或必须回炉补证据/重写。",
|
||||
"- 重点关注:证据是否落纸面、并发 packet 是否造成碎片化、法规/最佳实践覆盖是否足够、整改建议是否具体可执行。",
|
||||
"",
|
||||
"## Manifest",
|
||||
"",
|
||||
"```json",
|
||||
json.dumps(manifest, ensure_ascii=False, indent=2),
|
||||
"```",
|
||||
"",
|
||||
"## Deterministic Review Baseline",
|
||||
"",
|
||||
_read_text_if_exists(deterministic_copy),
|
||||
"",
|
||||
"## Phase 1 Framework",
|
||||
"",
|
||||
_read_text_if_exists(project_root / "phase1" / "framework.md", max_chars=50_000),
|
||||
"",
|
||||
"## Phase 1 Research Brief",
|
||||
"",
|
||||
_read_text_if_exists(project_root / "phase1" / "research_brief.md", max_chars=30_000),
|
||||
"",
|
||||
"## Phase 2 Brief Warnings",
|
||||
"",
|
||||
_json_if_exists(project_root / "phase2" / "brief_warnings.json", max_chars=30_000) or "无",
|
||||
"",
|
||||
"## Phase 2 Packet Errors",
|
||||
"",
|
||||
]
|
||||
errors = sorted((project_root / "phase2" / "packet_errors").glob("*.json"))
|
||||
if errors:
|
||||
for path in errors[:40]:
|
||||
parts.extend([f"### {path.name}", "", _json_if_exists(path, max_chars=2_000), ""])
|
||||
else:
|
||||
parts.append("无")
|
||||
|
||||
parts.extend(["", "## Source Registry Summary", ""])
|
||||
source_lines = []
|
||||
sources_path = project_root / "phase2" / "sources.jsonl"
|
||||
if sources_path.exists():
|
||||
for line in sources_path.read_text(encoding="utf-8").splitlines()[:260]:
|
||||
if not line.strip():
|
||||
continue
|
||||
try:
|
||||
source = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
source_lines.append(
|
||||
"- {id} | {tier} | {title} | {url} | cached={cached}".format(
|
||||
id=source.get("id", ""),
|
||||
tier=source.get("tier", ""),
|
||||
title=str(source.get("title", ""))[:120],
|
||||
url=source.get("url", ""),
|
||||
cached=source.get("cached_text_path", ""),
|
||||
)
|
||||
)
|
||||
parts.append("\n".join(source_lines) or "无")
|
||||
|
||||
parts.extend(["", "## Compressed Findings", ""])
|
||||
for path in sorted((project_root / "phase2" / "compressed_findings").glob("ch*.json")):
|
||||
parts.extend([f"### {path.name}", "", "```json", _json_if_exists(path, max_chars=35_000), "```", ""])
|
||||
|
||||
parts.extend(["", "## Chapter Drafts", ""])
|
||||
for path in sorted((project_root / "phase2" / "drafts").glob("ch*.md")):
|
||||
parts.extend([f"### {path.name}", "", _read_text_if_exists(path, max_chars=55_000), ""])
|
||||
|
||||
context = "\n".join(parts)
|
||||
if len(context) > max_chars:
|
||||
context = context[:max_chars] + "\n\n[Context truncated by max_chars; review should flag if truncation limits confidence.]\n"
|
||||
|
||||
out = project_root / "phase3" / "review_context_opus_4_7.md"
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
out.write_text(context, encoding="utf-8")
|
||||
return context
|
||||
|
||||
|
||||
def phase3_model_review_system_prompt() -> str:
|
||||
return (
|
||||
"你是 Deep Research Phase 3 的独立总编审校模型,本次由 ZenMux Claude Opus 4.7 执行,用于避免 Codex/OpenAI 模型偏见。\n"
|
||||
"你的任务是审校,不是润色或重写。必须用中文输出,英文仅可保留 source title、URL、法规缩写和原文短摘录。\n"
|
||||
"请严格检查:1) 研究目标与 Phase1 框架是否契合;2) Phase2 并发 evidence packets 是否被章节真正吸收,还是造成碎片化;"
|
||||
"3) FDA/NMPA/EMA/ICH/WHO/EU GMP 等权威来源是否足以支撑关键判断;4) 用户材料是否被正确作为起点且被权威来源交叉验证;"
|
||||
"5) 运营管理与团队能力章节是否具体,不得泛泛咨询腔;6) CAPA 建议是否包含 owner、期限、关闭证据、QA verification、复核窗口和升级阈值;"
|
||||
"7) 引用链和 source_id 是否可追踪;8) 是否仍有明显 AI 味、中英文混杂或空泛表达。\n\n"
|
||||
"输出必须使用以下 Markdown 结构:\n"
|
||||
"# Phase 3 Opus 4.7 独立审校\n"
|
||||
"## 总体判定\n"
|
||||
"给出:通过 / 有条件通过 / 回炉 Phase2,并说明最核心理由。\n"
|
||||
"## P0/P1 阻断问题\n"
|
||||
"列出必须修复的问题;每条写明章节/文件、问题、为什么阻断、建议动作。\n"
|
||||
"## 章节级审校表\n"
|
||||
"用表格覆盖 ch01-ch11:主线质量、证据密度、法规覆盖、整改可执行性、是否需要回炉。\n"
|
||||
"## 证据与信源质量\n"
|
||||
"单独评价 FDA warning letters、ICH Q9/Q10、EU GMP Annex 1、本地缓存信源、第三方低质信源的使用情况。\n"
|
||||
"## 碎片化与叙事连贯性\n"
|
||||
"判断并发研究是否造成割裂,并给出具体整合建议。\n"
|
||||
"## Phase2 回炉任务清单\n"
|
||||
"如果需要回炉,列出可执行任务卡级别的补证据/重写要求。\n"
|
||||
"## Phase4 准入条件\n"
|
||||
"明确进入 final 前必须满足的条件。\n"
|
||||
)
|
||||
|
||||
|
||||
def build_phase3_model_critique(
|
||||
project_root: Path,
|
||||
*,
|
||||
client: Any,
|
||||
model: str = "zenmux-anthropic/claude-opus-4-7",
|
||||
max_context_chars: int = 650_000,
|
||||
) -> Path:
|
||||
context = build_phase3_model_review_context(project_root, max_chars=max_context_chars)
|
||||
content = client.chat_complete(
|
||||
model=model,
|
||||
system=phase3_model_review_system_prompt(),
|
||||
user=context,
|
||||
temperature=0.2,
|
||||
max_tokens=20_000,
|
||||
tag="phase3:opus-review",
|
||||
)
|
||||
out = project_root / "phase3" / "critique.md"
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
out.write_text(content.rstrip() + "\n", encoding="utf-8")
|
||||
|
||||
manifest = load_manifest(project_root)
|
||||
phase3 = manifest.setdefault("phase3", {})
|
||||
phase3.update(
|
||||
{
|
||||
"status": "completed",
|
||||
"review_mode": "model",
|
||||
"review_model": model,
|
||||
"critique_path": "phase3/critique.md",
|
||||
"context_path": "phase3/review_context_opus_4_7.md",
|
||||
"deterministic_critique_path": "phase3/critique_deterministic.md",
|
||||
"updated_at": utc_now_iso(),
|
||||
}
|
||||
)
|
||||
manifest["updated_at"] = utc_now_iso()
|
||||
write_manifest(project_root, manifest)
|
||||
return out
|
||||
|
||||
|
||||
def build_phase3_critique(project_root: Path) -> Path:
|
||||
manifest = load_manifest(project_root)
|
||||
drafts = sorted((project_root / "phase2" / "drafts").glob("ch*.md"))
|
||||
|
||||
@@ -59,6 +59,42 @@ ROLE_DEFAULTS = {
|
||||
}
|
||||
|
||||
|
||||
ROLE_IDENTITIES = {
|
||||
"dr_plan": (
|
||||
"你是 Deep Research 的 Phase1 研究架构师。你的工作不是列目录,而是先消化材料、访谈和初步搜索,"
|
||||
"形成可被证伪的主判断、章节命题和求证路线。你要大胆假设,但必须给 Phase2 留下清晰的验证和推翻条件。"
|
||||
),
|
||||
"dr_pm": (
|
||||
"你是 Deep Research 的研究项目经理。你的职责是把研究意图转化为可并发执行、可回收校验的任务,"
|
||||
"控制碎片化、重复检索和上下文污染。"
|
||||
),
|
||||
"dr_searcher": (
|
||||
"你是 Deep Research 的信源发现员。你的职责是用短英文关键词和轴向词找到高质量入口,"
|
||||
"优先官方、法规、学术和一手材料;你不写结论,只交付可追溯来源。"
|
||||
),
|
||||
"dr_analyst": (
|
||||
"你是 Deep Research 的章节证据分析师。你的职责不是写一篇像样的空泛文章,而是围绕 Phase1 命题"
|
||||
"小心求证:提取材料原文、检索权威证据、寻找反方边界,并把证据整理成可审计的结构化 packet。"
|
||||
),
|
||||
"dr_verifier": (
|
||||
"你是 Deep Research 的独立反方审校员。你的默认姿态是质疑:找证据缺口、适用边界、反例和过度推断,"
|
||||
"并指出哪些结论必须降级或回炉。"
|
||||
),
|
||||
"dr_chief_editor": (
|
||||
"你是 Deep Research 的 Phase3 总编审校。你的职责是通读 Phase1 假设与 Phase2 证据,判断二者是否自洽,"
|
||||
"优先指出结构性失败、证据不足和需要回炉的章节。"
|
||||
),
|
||||
"dr_editor_in_chief": (
|
||||
"你是 Deep Research 的终稿主编。你的职责是把已验证证据组织成客户可读的中文报告,"
|
||||
"保持观点清晰、证据密实、表达克制,避免翻译腔和 AI 味。"
|
||||
),
|
||||
"dr_reporter": (
|
||||
"你是 Deep Research 的报告制作负责人。你的职责是把已定稿内容可靠渲染为 PDF/DOCX,"
|
||||
"确保引用、排版、中文字体、表格和输出卫生可交付。"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RoleDefinition:
|
||||
name: str
|
||||
@@ -67,6 +103,7 @@ class RoleDefinition:
|
||||
temperature: float
|
||||
max_tokens: int
|
||||
max_concurrency: int
|
||||
identity: str = ""
|
||||
|
||||
|
||||
class RuntimeProfile:
|
||||
@@ -103,6 +140,7 @@ def resolve_runtime_profile(
|
||||
temperature=float(defaults["temperature"]),
|
||||
max_tokens=int(defaults["max_tokens"]),
|
||||
max_concurrency=int(defaults["max_concurrency"]),
|
||||
identity=ROLE_IDENTITIES.get(name, ""),
|
||||
)
|
||||
return RuntimeProfile(
|
||||
profile=resolved["profile"],
|
||||
|
||||
@@ -34,9 +34,10 @@ class SkillRegistry:
|
||||
self.canonical_dir = canonical_dir or CANONICAL_SKILLS_DIR
|
||||
|
||||
def roots(self) -> list[Path]:
|
||||
roots = [self.canonical_dir]
|
||||
roots = []
|
||||
if self.canonical_dir == CANONICAL_SKILLS_DIR and PROJECT_SKILLS_DIR.exists():
|
||||
roots.append(PROJECT_SKILLS_DIR)
|
||||
roots.append(self.canonical_dir)
|
||||
return roots
|
||||
|
||||
def list(self) -> list[SkillInfo]:
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
"""Cache important external sources as local Markdown snapshots."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
from lxml import html
|
||||
|
||||
|
||||
IMPORTANT_DOMAINS = (
|
||||
"fda.gov",
|
||||
"ema.europa.eu",
|
||||
"nmpa.gov.cn",
|
||||
"cde.org.cn",
|
||||
"ich.org",
|
||||
"who.int",
|
||||
"edqm.eu",
|
||||
"pmda.go.jp",
|
||||
"ec.europa.eu",
|
||||
"health.ec.europa.eu",
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CacheResult:
|
||||
source_id: str
|
||||
url: str
|
||||
cached_text_path: str
|
||||
raw_path: str
|
||||
status: str
|
||||
chars: int
|
||||
|
||||
|
||||
def _safe_stem(source: dict) -> str:
|
||||
source_id = str(source.get("id") or "source")
|
||||
digest = hashlib.sha1(str(source.get("url") or source_id).encode("utf-8")).hexdigest()[:10]
|
||||
safe_id = re.sub(r"[^A-Za-z0-9_-]+", "_", source_id).strip("_") or "source"
|
||||
return f"{safe_id}-{digest}"
|
||||
|
||||
|
||||
def _domain(url: str) -> str:
|
||||
return urlparse(url).netloc.lower()
|
||||
|
||||
|
||||
def is_important_source(source: dict) -> bool:
|
||||
url = str(source.get("url") or "")
|
||||
if not url.startswith(("http://", "https://")):
|
||||
return False
|
||||
domain = _domain(url)
|
||||
if any(domain.endswith(item) for item in IMPORTANT_DOMAINS):
|
||||
return True
|
||||
tier = str(source.get("tier") or "").lower()
|
||||
if "tier 1" in tier or tier in {"1", "1.0"}:
|
||||
return True
|
||||
title = str(source.get("title") or "").lower()
|
||||
return any(term in title for term in ("ich q9", "ich q10", "annex 1", "fda guidance", "who guideline"))
|
||||
|
||||
|
||||
def load_sources(path: Path) -> list[dict]:
|
||||
if not path.exists():
|
||||
return []
|
||||
rows: list[dict] = []
|
||||
for line in path.read_text(encoding="utf-8").splitlines():
|
||||
if not line.strip():
|
||||
continue
|
||||
rows.append(json.loads(line))
|
||||
return rows
|
||||
|
||||
|
||||
def write_sources(path: Path, rows: list[dict]) -> None:
|
||||
path.write_text("".join(json.dumps(row, ensure_ascii=False) + "\n" for row in rows), encoding="utf-8")
|
||||
|
||||
|
||||
def _response_ext(url: str, content_type: str) -> str:
|
||||
lowered = url.lower()
|
||||
if "pdf" in content_type or lowered.endswith(".pdf"):
|
||||
return ".pdf"
|
||||
if "html" in content_type or lowered.endswith((".html", ".htm", "/")):
|
||||
return ".html"
|
||||
return ".bin"
|
||||
|
||||
|
||||
def _html_to_text(content: bytes) -> str:
|
||||
doc = html.fromstring(content)
|
||||
for bad in doc.xpath("//script|//style|//noscript"):
|
||||
bad.drop_tree()
|
||||
return "\n".join(line.strip() for line in doc.text_content().splitlines() if line.strip())
|
||||
|
||||
|
||||
def _pdf_to_text(path: Path) -> str:
|
||||
try:
|
||||
import fitz
|
||||
except Exception:
|
||||
return ""
|
||||
doc = fitz.open(path)
|
||||
parts: list[str] = []
|
||||
for index, page in enumerate(doc, start=1):
|
||||
text = page.get_text("text").strip()
|
||||
if text:
|
||||
parts.append(f"## Page {index}\n\n{text}")
|
||||
return "\n\n".join(parts)
|
||||
|
||||
|
||||
def _bytes_to_text(*, raw_path: Path, content: bytes, content_type: str, url: str) -> str:
|
||||
if raw_path.suffix == ".pdf" or "pdf" in content_type or url.lower().endswith(".pdf"):
|
||||
return _pdf_to_text(raw_path)
|
||||
if raw_path.suffix in {".html", ".htm"} or "html" in content_type:
|
||||
return _html_to_text(content)
|
||||
try:
|
||||
return content.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return content.decode("utf-8", errors="ignore")
|
||||
|
||||
|
||||
def cache_source(
|
||||
project_root: Path,
|
||||
source: dict,
|
||||
*,
|
||||
client: httpx.Client | None = None,
|
||||
force: bool = False,
|
||||
timeout: float = 45.0,
|
||||
) -> CacheResult:
|
||||
url = str(source.get("url") or "")
|
||||
if not url.startswith(("http://", "https://")):
|
||||
raise ValueError(f"source URL is not remote: {url}")
|
||||
cache_dir = project_root / "phase2" / "source_cache"
|
||||
raw_dir = cache_dir / "raw"
|
||||
text_dir = cache_dir / "md"
|
||||
raw_dir.mkdir(parents=True, exist_ok=True)
|
||||
text_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
stem = _safe_stem(source)
|
||||
md_path = text_dir / f"{stem}.md"
|
||||
if md_path.exists() and not force:
|
||||
return CacheResult(
|
||||
source_id=str(source.get("id") or ""),
|
||||
url=url,
|
||||
cached_text_path=str(md_path.relative_to(project_root)),
|
||||
raw_path=str(source.get("cached_raw_path") or ""),
|
||||
status="cached",
|
||||
chars=len(md_path.read_text(encoding="utf-8")),
|
||||
)
|
||||
|
||||
owns_client = client is None
|
||||
http = client or httpx.Client(trust_env=False, follow_redirects=True, timeout=timeout)
|
||||
try:
|
||||
response = http.get(url)
|
||||
response.raise_for_status()
|
||||
content_type = response.headers.get("content-type", "").lower()
|
||||
ext = _response_ext(str(response.url), content_type)
|
||||
raw_path = raw_dir / f"{stem}{ext}"
|
||||
raw_path.write_bytes(response.content)
|
||||
text = _bytes_to_text(raw_path=raw_path, content=response.content, content_type=content_type, url=str(response.url))
|
||||
lines = [
|
||||
f"# Source Snapshot: {source.get('title') or source.get('id') or url}",
|
||||
"",
|
||||
f"- source_id: {source.get('id', '')}",
|
||||
f"- original_url: {url}",
|
||||
f"- fetched_url: {response.url}",
|
||||
f"- content_type: {content_type}",
|
||||
f"- raw_path: {raw_path.relative_to(project_root)}",
|
||||
"",
|
||||
"## Extracted Text",
|
||||
"",
|
||||
text.strip() or "[No extractable text. Keep raw file for manual review.]",
|
||||
"",
|
||||
]
|
||||
md_path.write_text("\n".join(lines), encoding="utf-8")
|
||||
return CacheResult(
|
||||
source_id=str(source.get("id") or ""),
|
||||
url=url,
|
||||
cached_text_path=str(md_path.relative_to(project_root)),
|
||||
raw_path=str(raw_path.relative_to(project_root)),
|
||||
status="fetched",
|
||||
chars=len(text),
|
||||
)
|
||||
finally:
|
||||
if owns_client:
|
||||
http.close()
|
||||
|
||||
|
||||
def cache_sources(
|
||||
project_root: Path,
|
||||
*,
|
||||
sources_rel: str = "phase2/sources.jsonl",
|
||||
important_only: bool = True,
|
||||
limit: int | None = None,
|
||||
force: bool = False,
|
||||
) -> list[CacheResult]:
|
||||
sources_path = project_root / sources_rel
|
||||
rows = load_sources(sources_path)
|
||||
results: list[CacheResult] = []
|
||||
selected_indexes = [
|
||||
index
|
||||
for index, row in enumerate(rows)
|
||||
if row.get("url")
|
||||
and (not row.get("cached_text_path") or force)
|
||||
and (not important_only or is_important_source(row))
|
||||
]
|
||||
if limit is not None:
|
||||
selected_indexes = selected_indexes[:limit]
|
||||
|
||||
with httpx.Client(trust_env=False, follow_redirects=True, timeout=45.0) as client:
|
||||
for index in selected_indexes:
|
||||
row = rows[index]
|
||||
try:
|
||||
result = cache_source(project_root, row, client=client, force=force)
|
||||
except Exception as exc:
|
||||
row["cache_status"] = "failed"
|
||||
row["cache_error"] = str(exc)[:300]
|
||||
continue
|
||||
row["cached_text_path"] = result.cached_text_path
|
||||
row["cached_raw_path"] = result.raw_path
|
||||
row["cache_status"] = result.status
|
||||
row["cached_text_chars"] = result.chars
|
||||
results.append(result)
|
||||
write_sources(sources_path, rows)
|
||||
manifest = project_root / "phase2" / "source_cache" / "manifest.json"
|
||||
manifest.parent.mkdir(parents=True, exist_ok=True)
|
||||
manifest.write_text(
|
||||
json.dumps([result.__dict__ for result in results], ensure_ascii=False, indent=2) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
return results
|
||||
@@ -8,11 +8,11 @@ from typing import Any
|
||||
|
||||
|
||||
def _source_key(source: dict[str, Any]) -> str:
|
||||
return (source.get("url") or source.get("doi") or source.get("id") or "").strip()
|
||||
return (source.get("id") or source.get("source_id") or source.get("doi") or source.get("url") or "").strip()
|
||||
|
||||
|
||||
def append_packet_sources(sources_path: Path, packet: dict[str, Any]) -> int:
|
||||
"""Append packet sources to sources.jsonl, deduping by URL/DOI/id."""
|
||||
"""Append packet sources to sources.jsonl, preserving every citeable source_id."""
|
||||
sources_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
existing: set[str] = set()
|
||||
if sources_path.exists():
|
||||
@@ -37,10 +37,27 @@ def append_packet_sources(sources_path: Path, packet: dict[str, Any]) -> int:
|
||||
|
||||
|
||||
def rebuild_sources_from_packets(project_root: Path) -> int:
|
||||
"""Rebuild phase2/sources.jsonl from packet-level source metadata."""
|
||||
"""Rebuild phase2/sources.jsonl from packet-level source metadata.
|
||||
|
||||
The registry is keyed by source_id, not URL. Two packet sources may point to
|
||||
the same URL but have different source_ids already cited in drafts; dropping
|
||||
either row would break citation traceability.
|
||||
"""
|
||||
packets_dir = project_root / "phase2" / "packets"
|
||||
sources_path = project_root / "phase2" / "sources.jsonl"
|
||||
sources_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
existing_by_key: dict[str, dict[str, Any]] = {}
|
||||
if sources_path.exists():
|
||||
for line in sources_path.read_text(encoding="utf-8").splitlines():
|
||||
if not line.strip():
|
||||
continue
|
||||
try:
|
||||
row = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
key = _source_key(row)
|
||||
if key:
|
||||
existing_by_key[key] = row
|
||||
seen: set[str] = set()
|
||||
rows: list[dict[str, Any]] = []
|
||||
|
||||
@@ -56,7 +73,8 @@ def rebuild_sources_from_packets(project_root: Path) -> int:
|
||||
if not key or key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
rows.append(source)
|
||||
previous = existing_by_key.get(key, {})
|
||||
rows.append({**source, **{k: v for k, v in previous.items() if k.startswith("cache") or k.startswith("cached_")}})
|
||||
|
||||
sources_path.write_text(
|
||||
"".join(json.dumps(row, ensure_ascii=False) + "\n" for row in rows),
|
||||
|
||||
+159
-38
@@ -11,36 +11,45 @@ from typing import Any
|
||||
from scripts.runtime.methods import ResearchMethod
|
||||
|
||||
|
||||
VALID_ROUTES = {"general", "scholar", "patents", "news"}
|
||||
VALID_ROUTES = {"general", "evidence", "scholar", "patents", "news", "fda"}
|
||||
DEFAULT_AXES = ["literature", "regulatory", "patents", "market", "counter"]
|
||||
AXIS_ROUTES = {
|
||||
"literature": ["scholar", "general"],
|
||||
"clinical": ["scholar", "general"],
|
||||
"regulatory": ["general", "news"],
|
||||
"patents": ["patents", "general"],
|
||||
"literature": ["scholar", "evidence", "general"],
|
||||
"clinical": ["scholar", "evidence", "general"],
|
||||
"regulatory": ["fda", "evidence", "general", "news"],
|
||||
"patents": ["patents", "evidence", "general"],
|
||||
"market": ["news", "general"],
|
||||
"china": ["news", "general"],
|
||||
"counter": ["scholar", "general"],
|
||||
"regulatory_gap": ["general", "news"],
|
||||
"risk_classification": ["general", "scholar"],
|
||||
"capa_design": ["general", "news"],
|
||||
"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": ["general", "scholar"],
|
||||
"process_flow": ["scholar", "general"],
|
||||
"cqa_cpp": ["scholar", "general"],
|
||||
"scale_up_risk": ["scholar", "general"],
|
||||
"control_strategy": ["scholar", "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", "general"],
|
||||
"poc_evidence": ["scholar", "general"],
|
||||
"ip_fto": ["patents", "general"],
|
||||
"development_path": ["scholar", "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": ["general"],
|
||||
"capability_gap": ["general"],
|
||||
"operating_model": ["general"],
|
||||
"governance": ["general"],
|
||||
"implementation_roadmap": ["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"],
|
||||
}
|
||||
|
||||
|
||||
@@ -60,6 +69,7 @@ class TaskCard:
|
||||
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)
|
||||
@@ -105,12 +115,34 @@ def parse_framework_chapters(framework_text: str) -> list[Chapter]:
|
||||
return chapters
|
||||
|
||||
|
||||
def _questions_for_axis(chapter: Chapter, axis: str) -> list[str]:
|
||||
return [
|
||||
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]:
|
||||
@@ -121,18 +153,37 @@ def _default_required_skills(axis: str) -> list[str]:
|
||||
|
||||
|
||||
def _default_expected_evidence(axis: str) -> dict[str, Any]:
|
||||
return {
|
||||
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]:
|
||||
@@ -143,6 +194,15 @@ def _default_stop_conditions() -> list[str]:
|
||||
]
|
||||
|
||||
|
||||
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,
|
||||
@@ -152,22 +212,27 @@ def _task_card_for_chapter_axis(
|
||||
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_for_axis(chapter, 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=f"为《{chapter.title}》收集并验证 {axis} 轴证据,形成可写入章节的具体判断与证据落点。",
|
||||
research_goal=research_goal or f"为《{chapter.title}》收集并验证 {axis} 轴证据,形成可写入章节的具体判断与证据落点。",
|
||||
research_method=method_key,
|
||||
prompt_brief=prompt_brief or f"围绕《{chapter.title}》的 {axis} 轴,优先形成可证伪、可引用、可落地的证据包。",
|
||||
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=_default_expected_evidence(axis),
|
||||
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",
|
||||
)
|
||||
@@ -193,6 +258,7 @@ def generate_task_cards(
|
||||
axis=axis,
|
||||
routes=routes,
|
||||
method_key=method.key if method else "",
|
||||
method=method,
|
||||
)
|
||||
)
|
||||
validate_task_cards(cards)
|
||||
@@ -211,7 +277,17 @@ def generate_task_cards_from_research_brief(
|
||||
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
|
||||
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 [])
|
||||
@@ -221,6 +297,15 @@ def generate_task_cards_from_research_brief(
|
||||
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:
|
||||
@@ -228,6 +313,35 @@ def generate_task_cards_from_research_brief(
|
||||
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,
|
||||
@@ -236,8 +350,12 @@ def generate_task_cards_from_research_brief(
|
||||
method_key=method_key,
|
||||
required_skills=skills,
|
||||
allowed_materials=allowed_materials,
|
||||
prompt_brief=prompt_by_axis.get(axis),
|
||||
stop_conditions=stop_conditions or None,
|
||||
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)
|
||||
@@ -284,6 +402,8 @@ def validate_task_cards(cards: list[TaskCard]) -> None:
|
||||
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"):
|
||||
@@ -344,8 +464,9 @@ def validate_packet(packet: dict[str, Any]) -> None:
|
||||
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}")
|
||||
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}")
|
||||
|
||||
+245
-9
@@ -39,6 +39,10 @@ class ProjectSearchProvider:
|
||||
hits = self.client.patents(query, num_results=num_results)
|
||||
elif route == "news":
|
||||
hits = self.client.news(query, num_results=num_results, time_range="y")
|
||||
elif route == "fda":
|
||||
hits = self.client.fda(query, num_results=num_results)
|
||||
elif route == "evidence":
|
||||
hits = self.client.evidence(query, num_results=num_results)
|
||||
else:
|
||||
hits = self.client.search(query, num_results=num_results)
|
||||
return [
|
||||
@@ -72,6 +76,213 @@ def _safe_source_stem(task_id: str) -> str:
|
||||
return re.sub(r"[^a-zA-Z0-9]+", "_", task_id).strip("_").lower()
|
||||
|
||||
|
||||
def contains_cjk(text: str) -> bool:
|
||||
return any("\u4e00" <= char <= "\u9fff" for char in text)
|
||||
|
||||
|
||||
def strip_cjk(text: str) -> str:
|
||||
return re.sub(r"[\u3400-\u9fff]+", " ", text)
|
||||
|
||||
|
||||
def validate_packet_against_allowed_context(
|
||||
packet: dict,
|
||||
search_context: dict[str, Any] | None,
|
||||
material_context: dict[str, Any] | None,
|
||||
) -> None:
|
||||
"""Ensure the model did not invent source IDs or URLs beyond candidates."""
|
||||
if not search_context and not material_context:
|
||||
return
|
||||
candidates = (search_context or {}).get("candidate_sources") or []
|
||||
materials = (material_context or {}).get("materials") or []
|
||||
if not candidates and not materials:
|
||||
return
|
||||
candidate_ids = {source.get("id") for source in candidates}
|
||||
candidate_ids.update(item.get("source_id") for item in materials)
|
||||
candidate_urls = {source.get("url") for source in candidates if source.get("url")}
|
||||
candidate_urls.update(item.get("path") for item in materials if item.get("path"))
|
||||
packet_sources = packet.get("sources") or []
|
||||
unknown_ids = sorted(
|
||||
source.get("id")
|
||||
for source in packet_sources
|
||||
if source.get("id") and source.get("id") not in candidate_ids
|
||||
)
|
||||
unknown_urls = sorted(
|
||||
source.get("url")
|
||||
for source in packet_sources
|
||||
if source.get("url") and source.get("url") not in candidate_urls
|
||||
)
|
||||
if (candidates or materials) and not packet_sources:
|
||||
raise ValueError("packet must include source metadata from candidate_sources or local materials")
|
||||
if unknown_ids:
|
||||
raise ValueError(f"packet sources include non-candidate source IDs: {unknown_ids}")
|
||||
if unknown_urls:
|
||||
raise ValueError(f"packet sources include non-candidate URLs: {unknown_urls}")
|
||||
|
||||
|
||||
def normalize_packet_against_context(
|
||||
packet: dict[str, Any],
|
||||
search_context: dict[str, Any] | None,
|
||||
material_context: dict[str, Any] | None,
|
||||
) -> dict[str, Any]:
|
||||
"""Deterministically fill schema metadata the model often omits."""
|
||||
packet = dict(packet)
|
||||
referenced: set[str] = set(packet.get("source_ids") or [])
|
||||
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"])
|
||||
if "source_ids" not in packet or not packet.get("source_ids"):
|
||||
packet["source_ids"] = sorted(referenced)
|
||||
|
||||
available_sources: dict[str, dict[str, Any]] = {}
|
||||
for source in (search_context or {}).get("candidate_sources") or []:
|
||||
if source.get("id"):
|
||||
available_sources[source["id"]] = source
|
||||
for material in (material_context or {}).get("materials") or []:
|
||||
source_id = material.get("source_id")
|
||||
if source_id:
|
||||
available_sources[source_id] = {
|
||||
"id": source_id,
|
||||
"title": material.get("title") or Path(material.get("path", "")).name,
|
||||
"url": material.get("path") or "",
|
||||
"tier": "local_material",
|
||||
"score": 8,
|
||||
}
|
||||
|
||||
existing_sources = {
|
||||
source.get("id"): source
|
||||
for source in packet.get("sources") or []
|
||||
if source.get("id")
|
||||
}
|
||||
for source_id in packet.get("source_ids") or []:
|
||||
if source_id not in existing_sources and source_id in available_sources:
|
||||
existing_sources[source_id] = available_sources[source_id]
|
||||
if existing_sources:
|
||||
packet["sources"] = [existing_sources[source_id] for source_id in packet.get("source_ids", []) if source_id in existing_sources]
|
||||
return packet
|
||||
|
||||
|
||||
FDA_AXIS_TERMS = {
|
||||
"nmpa_fda_ema_ich_who_baseline": "CGMP pharmaceutical quality system process validation aseptic processing data integrity",
|
||||
"quality_system_gap": "CGMP CAPA deviation change control data integrity quality unit pharmaceutical",
|
||||
"manufacturing_process_risk": "aseptic processing sterile drug manufacturing process validation PPQ cleaning validation water system",
|
||||
"operations_management_gap": "pharmaceutical quality system quality metrics management review senior management FDA",
|
||||
"capa_roadmap": "CGMP CAPA effectiveness remediation warning letter close-out pharmaceutical",
|
||||
"verification_evidence": "FDA 483 response CAPA effectiveness verification EIR pharmaceutical quality",
|
||||
"counter": "FDA warning letter CGMP pharmaceutical quality data integrity remediation limitations",
|
||||
"fda_enforcement_precedents": "FDA warning letter CGMP pharmaceutical aseptic processing data integrity CAPA process validation",
|
||||
}
|
||||
|
||||
|
||||
FDA_CHAPTER_TERMS = {
|
||||
"ch01": "commercial readiness phase gate remediation governance",
|
||||
"ch02": "regulatory baseline CGMP EU GMP Annex 1 ICH Q9 ICH Q10",
|
||||
"ch03": "aseptic processing RABS first air media fill visual inspection depyrogenation tunnel",
|
||||
"ch04": "biologics drug substance WFI clean utilities SCADA EMS single-use system",
|
||||
"ch05": "process validation master batch record CPP CQA PPQ cleaning validation technology transfer",
|
||||
"ch06": "deviation change control CAPA document control training data integrity quality unit",
|
||||
"ch07": "training effectiveness quality culture operator qualification human factors",
|
||||
"ch08": "quality metrics management review escalation cross-functional governance operations",
|
||||
"ch09": "CDMO quality organization technology transfer project governance capability matrix",
|
||||
"ch10": "CAPA remediation plan effectiveness check owner due date verification evidence",
|
||||
"ch11": "regulatory mapping CAPA tracker closure evidence quality assurance verification",
|
||||
}
|
||||
|
||||
|
||||
ROUTE_CHAPTER_TERMS = {
|
||||
**FDA_CHAPTER_TERMS,
|
||||
}
|
||||
|
||||
ROUTE_SUFFIX_TERMS = {
|
||||
"scholar": "pharmaceutical GMP review validation risk management quality system",
|
||||
"patents": "biologics manufacturing patent process formulation device",
|
||||
"news": "pharmaceutical quality operations CDMO quality governance",
|
||||
"evidence": "pharmaceutical GMP evidence guidance enforcement best practice quality operations",
|
||||
"general": "pharmaceutical GMP best practice guidance quality operations remediation",
|
||||
}
|
||||
|
||||
INTERNAL_QUERY_TOKENS = {
|
||||
"chapter_integrated",
|
||||
"input_material_findings",
|
||||
}
|
||||
|
||||
|
||||
def _compact_english_query(*parts: str, max_terms: int = 16) -> str:
|
||||
text = strip_cjk(" ".join(part for part in parts if part))
|
||||
text = re.sub(r"[^A-Za-z0-9./+-]+", " ", text)
|
||||
terms: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for raw in text.split():
|
||||
term = raw.strip(" ./+-").lower()
|
||||
if not term or term in INTERNAL_QUERY_TOKENS:
|
||||
continue
|
||||
key = term.casefold()
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
terms.append(term)
|
||||
if len(terms) >= max_terms:
|
||||
break
|
||||
return " ".join(terms)
|
||||
|
||||
|
||||
def _chapter_terms(card: TaskCard) -> str:
|
||||
mapped = " ".join(ROUTE_CHAPTER_TERMS.get(chapter_id, "") for chapter_id in card.chapter_ids)
|
||||
if mapped.strip():
|
||||
return mapped
|
||||
return strip_cjk(card.chapter_title)
|
||||
|
||||
|
||||
def build_route_query(card: TaskCard, route: str) -> str:
|
||||
"""Build short, route-aware queries instead of sending whole task cards."""
|
||||
if route == "fda":
|
||||
terms = FDA_AXIS_TERMS.get(card.topic_axis, "FDA warning letter CGMP pharmaceutical quality")
|
||||
chapter_terms = " ".join(FDA_CHAPTER_TERMS.get(chapter_id, "") for chapter_id in card.chapter_ids)
|
||||
query = f"{terms} {chapter_terms}".strip()
|
||||
if contains_cjk(query):
|
||||
raise ValueError(f"FDA route query must not contain Chinese text: {query}")
|
||||
return query
|
||||
if route == "scholar":
|
||||
return _compact_english_query(_chapter_terms(card), ROUTE_SUFFIX_TERMS["scholar"])
|
||||
if route == "patents":
|
||||
return _compact_english_query(_chapter_terms(card), ROUTE_SUFFIX_TERMS["patents"])
|
||||
if route == "news":
|
||||
return _compact_english_query(_chapter_terms(card), ROUTE_SUFFIX_TERMS["news"])
|
||||
if route == "evidence":
|
||||
return _compact_english_query(_chapter_terms(card), ROUTE_SUFFIX_TERMS["evidence"])
|
||||
return _compact_english_query(_chapter_terms(card), ROUTE_SUFFIX_TERMS["general"])
|
||||
|
||||
|
||||
def _material_excerpt(project_root: Path | None, rel_path: str, *, max_chars: int = 6000) -> dict[str, str] | None:
|
||||
if project_root is None:
|
||||
return None
|
||||
path = project_root / rel_path
|
||||
if not path.exists() or not path.is_file():
|
||||
return None
|
||||
text = path.read_text(encoding="utf-8", errors="ignore")
|
||||
return {
|
||||
"path": rel_path,
|
||||
"source_id": f"src_local_{_safe_source_stem(Path(rel_path).stem)}",
|
||||
"title": Path(rel_path).name,
|
||||
"excerpt": text[:max_chars],
|
||||
}
|
||||
|
||||
|
||||
def build_material_context(card: TaskCard, project_root: Path | None, *, max_chars_per_material: int = 6000) -> dict[str, Any]:
|
||||
materials = []
|
||||
seen: set[str] = set()
|
||||
for rel in card.allowed_materials:
|
||||
if rel in seen:
|
||||
continue
|
||||
seen.add(rel)
|
||||
item = _material_excerpt(project_root, rel, max_chars=max_chars_per_material)
|
||||
if item:
|
||||
materials.append(item)
|
||||
return {"materials": materials}
|
||||
|
||||
|
||||
def build_search_context(
|
||||
card: TaskCard,
|
||||
search_provider: SearchProvider,
|
||||
@@ -82,9 +293,9 @@ def build_search_context(
|
||||
routes_used: list[str] = []
|
||||
source_stem = _safe_source_stem(card.task_id)
|
||||
idx = 1
|
||||
query = " ".join(card.questions)
|
||||
for route in card.search_routes:
|
||||
routes_used.append(route)
|
||||
query = build_route_query(card, route)
|
||||
hits = search_provider.search(query=query, route=route, num_results=num_results_per_route)
|
||||
for hit in hits:
|
||||
candidate_sources.append(
|
||||
@@ -102,15 +313,22 @@ def build_search_context(
|
||||
return {"routes_used": routes_used, "candidate_sources": candidate_sources}
|
||||
|
||||
|
||||
def build_packet_user_prompt(card: TaskCard, search_context: dict[str, Any] | None = None) -> str:
|
||||
def build_packet_user_prompt(
|
||||
card: TaskCard,
|
||||
search_context: dict[str, Any] | None = None,
|
||||
material_context: dict[str, Any] | None = None,
|
||||
) -> str:
|
||||
context = search_context or {"routes_used": [], "candidate_sources": []}
|
||||
materials = material_context or {"materials": []}
|
||||
return (
|
||||
"请根据以下 task card 产出一个证据包 JSON。\n"
|
||||
"正式结论、summary、open_questions 用中文;英文原文摘录、source title、DOI/URL 可以保留英文。\n"
|
||||
"必须主动包含 counter_evidence,且所有引用的 source_id 必须出现在 source_ids 中。\n\n"
|
||||
"只能使用 candidate_sources 中的来源,不得编造 URL、DOI、trial ID 或 source_id。\n"
|
||||
"输出 JSON 必须包含 sources 字段,且 sources 只能来自 candidate_sources。\n\n"
|
||||
"只能使用 candidate_sources 或 Local material context 中的来源,不得编造 URL、DOI、trial ID 或 source_id。\n"
|
||||
"输出 JSON 必须包含 sources 字段;sources 只能来自 candidate_sources 或 Local material context。\n"
|
||||
"如 Local material context 非空,必须至少提取 1 条本地材料原文证据;如果与本章无关,必须在 open_questions 说明为什么无关。\n\n"
|
||||
f"{json.dumps(card.to_dict(), ensure_ascii=False, indent=2)}\n\n"
|
||||
f"Local material context:\n{json.dumps(materials, ensure_ascii=False, indent=2)}\n\n"
|
||||
f"Search context:\n{json.dumps(context, ensure_ascii=False, indent=2)}\n\n"
|
||||
"只输出 JSON,不要输出 Markdown 解释。"
|
||||
)
|
||||
@@ -122,16 +340,19 @@ def build_packet_repair_prompt(
|
||||
raw_response: str,
|
||||
error: Exception,
|
||||
search_context: dict[str, Any] | None = None,
|
||||
material_context: dict[str, Any] | None = None,
|
||||
) -> str:
|
||||
context = search_context or {"routes_used": [], "candidate_sources": []}
|
||||
materials = material_context or {"materials": []}
|
||||
return (
|
||||
"请修复上一次 evidence packet 输出,使其成为合法且通过 schema 校验的 JSON。\n"
|
||||
"只输出 JSON 对象,不要输出 Markdown、解释或代码块。\n"
|
||||
"保留中文主写作;英文只允许出现在来源标题、URL、DOI、原文摘录或检索笔记中。\n"
|
||||
"不得编造 candidate_sources 以外的来源、URL、DOI、trial ID 或 source_id。\n\n"
|
||||
"不得编造 candidate_sources 或 Local material context 以外的来源、URL、DOI、trial ID 或 source_id。\n\n"
|
||||
f"Schema error:\n{error}\n\n"
|
||||
f"Task card:\n{json.dumps(card.to_dict(), ensure_ascii=False, indent=2)}\n\n"
|
||||
f"Search context:\n{json.dumps(context, ensure_ascii=False, indent=2)}\n\n"
|
||||
f"Local material context:\n{json.dumps(materials, ensure_ascii=False, indent=2)}\n\n"
|
||||
f"Previous raw response:\n{raw_response[:12000]}"
|
||||
)
|
||||
|
||||
@@ -142,12 +363,14 @@ class PacketWorker:
|
||||
*,
|
||||
role: RoleDefinition,
|
||||
client: ChatClient,
|
||||
project_root: Path | None = None,
|
||||
search_provider: SearchProvider | None = None,
|
||||
skill_registry: SkillRegistry | None = None,
|
||||
num_results_per_route: int = 5,
|
||||
) -> None:
|
||||
self.role = role
|
||||
self.client = client
|
||||
self.project_root = project_root
|
||||
self.search_provider = search_provider
|
||||
self.skill_registry = skill_registry or SkillRegistry()
|
||||
self.num_results_per_route = num_results_per_route
|
||||
@@ -160,6 +383,7 @@ class PacketWorker:
|
||||
except FileNotFoundError:
|
||||
skill_texts.append(f"# Skill: {name}\n\n[missing skill: {name}]")
|
||||
return (
|
||||
f"{self.role.identity}\n\n"
|
||||
"你是 Deep Research v0.20 Python runtime 的证据包 worker。\n"
|
||||
"你的唯一任务是把一个 task card 转换为结构化 evidence packet。\n"
|
||||
"遵循中文主写作原则;不要写章节正文;不要编造 URL、DOI、trial ID 或 source_id。\n\n"
|
||||
@@ -175,17 +399,23 @@ class PacketWorker:
|
||||
self.search_provider,
|
||||
num_results_per_route=self.num_results_per_route,
|
||||
)
|
||||
material_context = build_material_context(card, self.project_root)
|
||||
raw = self.client.chat_complete(
|
||||
model=self.role.model,
|
||||
system=self._system_prompt(),
|
||||
user=build_packet_user_prompt(card, search_context),
|
||||
user=build_packet_user_prompt(card, search_context, material_context),
|
||||
temperature=self.role.temperature,
|
||||
max_tokens=self.role.max_tokens,
|
||||
tag=f"packet:{card.task_id}",
|
||||
)
|
||||
try:
|
||||
packet = _extract_json_object(raw)
|
||||
packet = normalize_packet_against_context(
|
||||
_extract_json_object(raw),
|
||||
search_context,
|
||||
material_context,
|
||||
)
|
||||
validate_packet(packet)
|
||||
validate_packet_against_allowed_context(packet, search_context, material_context)
|
||||
return packet
|
||||
except Exception as error:
|
||||
repaired = self.client.chat_complete(
|
||||
@@ -196,13 +426,19 @@ class PacketWorker:
|
||||
raw_response=raw,
|
||||
error=error,
|
||||
search_context=search_context,
|
||||
material_context=material_context,
|
||||
),
|
||||
temperature=0,
|
||||
max_tokens=self.role.max_tokens,
|
||||
tag=f"packet-repair:{card.task_id}",
|
||||
)
|
||||
packet = _extract_json_object(repaired)
|
||||
packet = normalize_packet_against_context(
|
||||
_extract_json_object(repaired),
|
||||
search_context,
|
||||
material_context,
|
||||
)
|
||||
validate_packet(packet)
|
||||
validate_packet_against_allowed_context(packet, search_context, material_context)
|
||||
return packet
|
||||
|
||||
|
||||
@@ -233,7 +469,7 @@ def run_packet_workers(
|
||||
def run_one(card: TaskCard) -> tuple[TaskCard, dict | None, Exception | None]:
|
||||
search_provider = search_provider_factory() if search_provider_factory else None
|
||||
try:
|
||||
worker = PacketWorker(role=role, client=client_factory(role), search_provider=search_provider)
|
||||
worker = PacketWorker(role=role, client=client_factory(role), project_root=project_root, search_provider=search_provider)
|
||||
return card, worker.run(card), None
|
||||
except Exception as error:
|
||||
return card, None, error
|
||||
|
||||
+16
-5
@@ -25,17 +25,19 @@ from scripts.lib.zenmux_client import load_secrets
|
||||
|
||||
|
||||
ROUTE_HELP = {
|
||||
"general": "Exa -> Tavily generic web discovery",
|
||||
"general": "Tavily -> Exa -> Brave generic web discovery",
|
||||
"evidence": "Exa highlights -> Tavily -> Brave controlled evidence discovery",
|
||||
"scholar": "Serper Scholar -> generic fallback",
|
||||
"patents": "Serper Google Patents -> site:patents.google.com fallback",
|
||||
"news": "Serper News -> generic fallback",
|
||||
"fda": "FDA-focused discovery for warning letters, enforcement pages, and meeting materials",
|
||||
}
|
||||
|
||||
PROFILE_ROUTES = {
|
||||
"biomed_literature": ["scholar", "general"],
|
||||
"patent_heavy": ["patents", "general"],
|
||||
"china_market": ["news", "general"],
|
||||
"investment": ["news", "general"],
|
||||
"biomed_literature": ["scholar", "evidence", "general"],
|
||||
"patent_heavy": ["patents", "evidence", "general"],
|
||||
"china_market": ["news", "evidence", "general"],
|
||||
"investment": ["news", "evidence", "general"],
|
||||
}
|
||||
|
||||
PROFILE_QUERY_PREFIX = {
|
||||
@@ -46,12 +48,16 @@ PROFILE_QUERY_PREFIX = {
|
||||
def search_route(client: SearchClient, route: str, query: str, args: argparse.Namespace) -> list[SearchHit]:
|
||||
if route == "general":
|
||||
return client.search(query, num_results=args.num_results)
|
||||
if route == "evidence":
|
||||
return client.evidence(query, num_results=args.num_results, category=args.exa_category)
|
||||
if route == "scholar":
|
||||
return client.scholar(query, num_results=args.num_results, year_low=args.year_low)
|
||||
if route == "patents":
|
||||
return client.patents(query, num_results=args.num_results)
|
||||
if route == "news":
|
||||
return client.news(query, num_results=args.num_results, time_range=args.time_range)
|
||||
if route == "fda":
|
||||
return client.fda(query, num_results=args.num_results)
|
||||
raise SystemExit(f"unknown route: {route}")
|
||||
|
||||
|
||||
@@ -107,6 +113,11 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
help="Run a strategy profile instead of a single route",
|
||||
)
|
||||
parser.add_argument("--num-results", type=int, default=10)
|
||||
parser.add_argument(
|
||||
"--exa-category",
|
||||
choices=["research paper", "news", "company", "financial report", "github", "tweet", "personal site", "pdf"],
|
||||
help="Optional Exa category for the evidence route",
|
||||
)
|
||||
parser.add_argument("--year-low", type=int, help="Lower year bound for scholar searches")
|
||||
parser.add_argument("--time-range", choices=["d", "w", "m", "y"], help="Serper news time range")
|
||||
parser.add_argument("--json", action="store_true", help="Emit JSON instead of Markdown")
|
||||
|
||||
Reference in New Issue
Block a user