Files
PlantAI-rag-new/agent/legacy-rag/scripts/run_methodology_batch.py

2147 lines
86 KiB
Python
Executable File

#!/usr/bin/env python3
"""Run methodology extraction and bbav5 alignment for a paper CSV."""
from __future__ import annotations
import argparse
import csv
import json
import os
import re
import sys
from dataclasses import asdict, replace
from datetime import datetime
from pathlib import Path
from typing import Any
PROJECT_ROOT = Path(__file__).resolve().parents[1]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from src.rag.unified.methodology import MethodEvidenceRef, MethodologyAligner, MethodObservation, ToolUsage
from src.rag.unified.methodology_extractor import ParsedDocumentMethodologyExtractor
from src.rag.unified.methodology_reviewer import MethodologyReviewer
from src.rag.unified.parsed_document import ParsedDocumentLoader
from src.rag.unified.bbav5_capability import BBAV5CapabilityCatalogBuilder
from src.rag.unified.bbav5_methodology_config import build_methodology_execution_plan
PAPER_FIELDS = [
"paper_id",
"family",
"source_paper_role",
"parsed_document_path",
"pmid",
"doi",
"priority",
"notes",
]
REVIEW_FIELDS = [
"paper_id",
"family",
"observation_id",
"normalized_stage",
"method_type",
"experiment_type",
"validation_target",
"biological_material",
"treatment_or_condition",
"manual_review_hint",
"tools",
"versions",
"parameters",
"inputs",
"outputs",
"bbav5_stage_id",
"bbav5_default_tool",
"replacement_tools",
"support_status",
"evidence_sentence",
"evidence_sentences",
"evidence_support_status",
"unsupported_tools",
"missing_tools",
"missing_parameters",
"evidence_repair_status",
"evidence_quality_status",
"evidence_quality_reason",
"evidence_source_section",
"v15_review_status",
"v15_review_reason",
"v15_review_suggestions",
"section",
"raw_method_text",
"review_status",
"stage_review",
"tools_review",
"versions_review",
"parameters_review",
"inputs_outputs_review",
"bbav5_review",
"bbav5_stage_review",
"bbav5_tools_review",
"support_status_review",
"corrected_stage_value",
"corrected_tools_value",
"corrected_versions_value",
"corrected_parameters_value",
"corrected_evidence_value",
"corrected_inputs_value",
"corrected_outputs_value",
"corrected_bbav5_stage_value",
"corrected_bbav5_tools_value",
"corrected_support_status_value",
"corrected_stage",
"corrected_tools",
"corrected_parameters",
"corrected_inputs",
"corrected_outputs",
"corrected_bbav5_stage",
"corrected_support_status",
"evidence_review",
"manual_note",
]
STATUS_FIELDS = [
"paper_id",
"family",
"source_paper_role",
"parsed_document_path",
"parsed_document_exists",
"parser",
"chunk_count",
"methods_chunk_count",
"methods_text_length",
"observation_count",
"ready_for_review",
"failure_reason",
]
DISCOVERED_TOOL_FIELDS = [
"tool_name",
"paper_id",
"family",
"observation_id",
"stage_guess",
"role_guess",
"version",
"url",
"evidence_sentence",
"review_status",
"accepted_stage",
"accepted_role",
"bbav5_stage",
"support_status",
"manual_note",
]
TOOL_AUDIT_FIELDS = [
"paper_id",
"family",
"observation_id",
"normalized_stage",
"method_type",
"tool_name",
"version",
"url",
"role",
"parameters",
"bbav5_stage_id",
"support_status",
"evidence_sentence",
"evidence_support_status",
"section",
]
PARAMETER_AUDIT_FIELDS = [
"paper_id",
"family",
"observation_id",
"normalized_stage",
"method_type",
"component_type",
"component_tool",
"parameter_name",
"parameter_value",
"parameter_type",
"evidence_sentence",
"section",
]
URL_AUDIT_FIELDS = [
"paper_id",
"family",
"observation_id",
"normalized_stage",
"source_name",
"source_type",
"url",
"evidence_sentence",
"section",
]
REVIEW_ISSUE_FIELDS = [
"paper_id",
"observation_id",
"normalized_stage",
"field",
"severity",
"issue_type",
"message",
"current_value",
"suggested_value",
"evidence_sentence",
"section",
"recommendation",
]
SPECIES_SUMMARY_FIELDS = [
"paper_id",
"family",
"species_name",
"species_role",
"bbav5_role",
"file_scope",
"reference_use",
"evidence_source",
"observation_id",
"normalized_stage",
"evidence_sentence",
"confidence",
"actionable",
]
KNOWN_TOOL_NAMES = {
"BLAST",
"HMMER",
"Pfam",
"SMART",
"NCBI CDD",
"InterProScan",
"ExPASy",
"ProtParam",
"ClustalX",
"ClustalW",
"MUSCLE",
"MAFFT",
"MEGA",
"IQ-TREE",
"RAxML",
"PhyML",
"FastTree",
"MEME",
"MCScanX",
"JCVI",
"TBtools",
"GSDS",
"qRT-PCR",
"Primer3",
"TopHat",
"Cufflinks",
"Cuffcompare",
"HISAT2",
"STAR",
"Bowtie2",
"DESeq",
"DESeq2",
"edgeR",
"limma",
"FastQC",
"Trimmomatic",
"Cutadapt",
"featureCounts",
"StringTie",
"Salmon",
"Kallisto",
"HTSeq",
"PlantCARE",
"PLACE",
"PlantPAN",
"KOBAS",
"GO",
"KEGG",
"STRING",
"KaKs_Calculator",
"ParaAT",
"BLASTp",
"HMM",
"Hidden Markov Model",
"UniProt",
}
SPECIES_ALIASES = {
"broomcorn millet": "Panicum miliaceum",
"common millet": "Panicum miliaceum",
"hog millet": "Panicum miliaceum",
"panicum miliaceum": "Panicum miliaceum",
"proso millet": "Panicum miliaceum",
"pmnac": "Panicum miliaceum",
"tartary buckwheat": "Fagopyrum tataricum",
"fagopyrum tataricum": "Fagopyrum tataricum",
"ftspl": "Fagopyrum tataricum",
"a. thaliana": "Arabidopsis thaliana",
"atnac": "Arabidopsis thaliana",
"atspl": "Arabidopsis thaliana",
"arabidopsis": "Arabidopsis thaliana",
"arabidopsis thaliana": "Arabidopsis thaliana",
"b. vulgaris": "Beta vulgaris",
"beta vulgaris": "Beta vulgaris",
"g. max": "Glycine max",
"glycine max": "Glycine max",
"s. lycopersicum": "Solanum lycopersicum",
"solanum lycopersicum": "Solanum lycopersicum",
"tomato": "Solanum lycopersicum",
"v. vinifera": "Vitis vinifera",
"vitis vinifera": "Vitis vinifera",
"grape": "Vitis vinifera",
"o. sativa": "Oryza sativa",
"oryza sativa": "Oryza sativa",
"rice": "Oryza sativa",
"osnac": "Oryza sativa",
"pecan": "Carya illinoinensis",
"carya illinoinensis": "Carya illinoinensis",
"cispl": "Carya illinoinensis",
"sunflower": "Helianthus annuus",
"helianthus annuus": "Helianthus annuus",
"citrus": "Citrus",
"brassica rapa": "Brassica rapa",
"foxtail millet": "Setaria italica",
"setaria italica": "Setaria italica",
"maize": "Zea mays",
"zea mays": "Zea mays",
"sorghum": "Sorghum bicolor",
"sorghum bicolor": "Sorghum bicolor",
"litchi": "Litchi chinensis",
"litchi chinensis": "Litchi chinensis",
"longan": "Dimocarpus longan",
"dimocarpus longan": "Dimocarpus longan",
"rambutan": "Nephelium lappaceum",
"nephelium lappaceum": "Nephelium lappaceum",
}
TARGET_SPECIES_ALIAS_PATTERNS = re.compile(
r"\b(tartary buckwheat|pecan|sunflower|foxtail millet|litchi|rambutan|longan|citrus|"
r"brassica|broomcorn millet|common millet|hog millet|proso millet|panicum miliaceum)\b",
re.I,
)
NON_TOOL_TERMS = {
"Project",
"Genome",
"Tartary Buckwheat Genome",
"SPL",
"SBP",
"PF03110",
"Pinku1",
"After",
"Additional",
"Figure",
"Table",
"Methods",
"Results",
"FtSPL",
"Arabidopsis",
"Abscisic",
"Beta",
"Capsicum",
"Coding",
"Multiple",
"From",
"DNA",
"MVs",
"PIs",
"N-J",
"The PIs",
"The SPL",
"Service",
"Gene Structure Display",
"CJ-Chen",
"DEGs",
"SUTs",
"The SUTs",
}
KNOWN_NON_TOOL_PATTERNS = [
r"^Ft[A-Z0-9]",
r"^[A-Z][a-z]+[A-Z0-9]",
r"^[A-Z][a-z]+$",
r"^[A-Z]{2,4}$",
r"^PF\d+",
r"^\d",
r".*\d+\.\d+$",
]
def resolve(path: str) -> Path:
p = Path(path)
return p if p.is_absolute() else PROJECT_ROOT / p
def load_or_build_capability_catalog(path: Path) -> dict[str, Any]:
if path.exists():
return json.loads(path.read_text(encoding="utf-8"))
bbav5_root_value = os.environ.get("BBAV5_ROOT", "").strip()
if bbav5_root_value:
bbav5_root = Path(bbav5_root_value).expanduser()
try:
catalog = BBAV5CapabilityCatalogBuilder(bbav5_root).build()
catalog_payload = catalog.to_dict()
try:
catalog.write_json(path)
except OSError as write_exc:
print(f"WARNING: capability catalog built but could not be cached at {path}: {write_exc}")
return catalog_payload
except Exception as exc:
print(f"WARNING: failed to build bbav5 capability catalog from BBAV5_ROOT={bbav5_root}: {exc}")
print(f"WARNING: capability catalog missing; continuing with empty catalog: {path}")
return {"source_root": str(Path(bbav5_root_value).expanduser()) if bbav5_root_value else "", "stage_count": 0, "stages": {}}
def read_rows(path: Path) -> list[dict[str, str]]:
with path.open("r", encoding="utf-8-sig", newline="") as handle:
return [row for row in csv.DictReader(handle) if row.get("paper_id")]
def write_csv(path: Path, fields: list[str], rows: list[dict[str, Any]]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w", encoding="utf-8", newline="") as handle:
writer = csv.DictWriter(handle, fieldnames=fields)
writer.writeheader()
for row in rows:
writer.writerow({field: row.get(field, "") for field in fields})
def dump_json(path: Path, value: Any) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(value, ensure_ascii=False, indent=2), encoding="utf-8")
def observation_to_output_dict(obs: Any) -> dict[str, Any]:
data = asdict(obs)
data["parameters"] = clean_method_parameters(data.get("parameters") or {})
for tool in data.get("tools") or []:
if isinstance(tool, dict):
tool["parameters"] = clean_method_parameters(tool.get("parameters") or {})
evidence_support_status, unsupported_tools = evidence_support_for_obs(obs)
data["evidence_sentences"] = evidence_sentences_for_obs(obs)
data["evidence_support_status"] = evidence_support_status
data["unsupported_tools"] = unsupported_tools
data.setdefault("missing_tools", getattr(obs, "missing_tools", []))
data.setdefault("missing_parameters", getattr(obs, "missing_parameters", []))
data.setdefault("method_type", getattr(obs, "method_type", "computational_method"))
data.setdefault("experiment_type", getattr(obs, "experiment_type", ""))
data.setdefault("validation_target", getattr(obs, "validation_target", ""))
data.setdefault("biological_material", getattr(obs, "biological_material", ""))
data.setdefault("treatment_or_condition", getattr(obs, "treatment_or_condition", ""))
data.setdefault("manual_review_hint", getattr(obs, "manual_review_hint", ""))
data.setdefault("evidence_repair_status", getattr(obs, "evidence_repair_status", ""))
data.setdefault("evidence_quality_status", getattr(obs, "evidence_quality_status", ""))
data.setdefault("evidence_quality_reason", getattr(obs, "evidence_quality_reason", ""))
data.setdefault("evidence_source_section", getattr(obs, "evidence_source_section", ""))
data.setdefault("v15_review_status", getattr(obs, "v15_review_status", ""))
data.setdefault("v15_review_reason", getattr(obs, "v15_review_reason", ""))
data.setdefault("v15_review_suggestions", getattr(obs, "v15_review_suggestions", []))
return data
def compact_json(value: Any) -> str:
return json.dumps(value, ensure_ascii=False, sort_keys=True)
def timestamp() -> str:
return datetime.now().isoformat(timespec="seconds")
def patch_external_job_status(
status_json_path: str,
substage: str,
message: str,
*,
stage: str | None = None,
extra: dict[str, Any] | None = None,
) -> None:
"""Merge progress and optional summary fields into upload-job status.json."""
path_str = (status_json_path or "").strip()
if not path_str:
return
path = Path(path_str)
if not path.is_file():
return
try:
data = json.loads(path.read_text(encoding="utf-8"))
data["substage"] = substage
data["message"] = message
if stage is not None:
data["stage"] = stage
if extra:
data.update(extra)
data["updated_at"] = datetime.now().isoformat(timespec="seconds")
path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
except Exception:
pass
def append_log(path: Path, message: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("a", encoding="utf-8") as handle:
handle.write(f"{timestamp()} {message}\n")
handle.flush()
def append_jsonl(path: Path, payload: dict[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
row = {"timestamp": timestamp(), **payload}
with path.open("a", encoding="utf-8") as handle:
handle.write(json.dumps(row, ensure_ascii=False, sort_keys=True) + "\n")
handle.flush()
def print_log(path: Path, message: str) -> None:
print(message, flush=True)
append_log(path, message)
def write_tool_lexicon(path: Path, rows: list[dict[str, Any]]) -> None:
"""Persist reviewed and high-confidence discovered tools for the next run."""
import re
lexicon = []
seen = set()
for row in rows:
name = str(row.get("tool_name") or "").strip()
if not name:
continue
status = str(row.get("review_status") or "").strip().lower()
manual_stage = str(row.get("accepted_stage") or "").strip()
stage = manual_stage or str(row.get("stage_guess") or row.get("normalized_stage") or row.get("bbav5_stage") or row.get("bbav5_stage_id") or "").strip()
role = str(row.get("accepted_role") or row.get("role_guess") or row.get("role") or "").strip()
support = str(row.get("support_status") or "").strip()
norm = re.sub(r"[^a-z0-9]+", "", name.lower())
if (
not norm
or norm in seen
or len(name) < 3
or len(name) > 80
or re.search(r"^PF\d+|^\d|^(SPL|SBP|DNA|RNA|CDS|DEGs?|Methods?|Results?)$", name, re.I)
):
continue
accepted = status in {"accept", "accepted", "yes", "keep"}
high_confidence = bool(stage and support in {"supported_default", "supported_alternative", "partially_supported", "pending", ""})
if not accepted and not high_confidence:
continue
seen.add(norm)
lexicon.append(
{
"tool_name": name,
"stage": stage,
"role": role,
"bbav5_stage_id": str(row.get("bbav5_stage_id") or row.get("bbav5_stage") or "").strip(),
"support_status": support,
"source": "manual_review" if accepted or row.get("normalized_stage") else "auto_discovery",
}
)
dump_json(path, {"tools": lexicon, "tool_count": len(lexicon)})
def load_llm_discoveries(path: Path | None) -> list[dict[str, Any]]:
if not path:
return []
if not path.exists():
raise FileNotFoundError(f"LLM discovery JSON not found: {path}")
data = json.loads(path.read_text(encoding="utf-8"))
rows = data.get("tools", data) if isinstance(data, dict) else data
if not isinstance(rows, list):
raise ValueError("LLM discovery JSON must be a list or an object with a 'tools' list")
normalized = []
for item in rows:
if not isinstance(item, dict) or not item.get("tool_name"):
continue
normalized.append({field: item.get(field, "") for field in DISCOVERED_TOOL_FIELDS})
return normalized
def is_methods_section(section: str) -> bool:
if __import__("re").search(
r"\b(availability of data|data availability|supplementary|additional files?|abbreviations|acknowledg|author'?s? contribution|funding|ethics|consent|competing interests?|references)\b",
section or "",
__import__("re").I,
):
return False
return bool(
__import__("re").search(
r"\b(methods?|materials|experimental|methodology|sequencing|analysis)\b",
section or "",
__import__("re").I,
)
)
def normalize_observations(observations: list[Any], family: str) -> list[Any]:
"""Remove non-method tail observations and correct obvious family-name bleed-through."""
import dataclasses
import re
normalized = []
current_family = (family or "").strip()
wrong_family_pattern = re.compile(r"\b(AP2\s*/\s*ERF|AP2/ERF)\b", re.I)
for obs in observations:
evidence = obs.evidence[0] if obs.evidence else None
section = evidence.section if evidence else ""
text = " ".join(
[
section or "",
evidence.evidence_sentence if evidence else "",
obs.raw_method_text or "",
" ".join(obs.outputs or []),
]
)
is_tail = re.search(
r"\b(availability of data|data availability|datasets supporting|included in the article|authors'? contributions?|competing interests?|publisher'?s note)\b",
text,
re.I,
)
if is_tail:
continue
if current_family.upper() == "SPL" and wrong_family_pattern.search(text):
fixed_tools = obs.tools
fixed_parameters = _replace_family_terms(obs.parameters, wrong_family_pattern, "SPL")
fixed_inputs = [_replace_family_terms(value, wrong_family_pattern, "SPL") for value in obs.inputs]
fixed_outputs = [_replace_family_terms(value, wrong_family_pattern, "SPL") for value in obs.outputs]
fixed_evidence = [
dataclasses.replace(
ev,
evidence_sentence=_replace_family_terms(ev.evidence_sentence, wrong_family_pattern, "SPL"),
)
for ev in obs.evidence
]
obs = dataclasses.replace(
obs,
tools=fixed_tools,
parameters=fixed_parameters,
inputs=fixed_inputs,
outputs=fixed_outputs,
evidence=fixed_evidence,
raw_method_text=_replace_family_terms(obs.raw_method_text, wrong_family_pattern, "SPL"),
)
normalized.append(obs)
return normalized
def _replace_family_terms(value: Any, pattern: Any, replacement: str) -> Any:
if isinstance(value, str):
return pattern.sub(replacement, value)
if isinstance(value, list):
return [_replace_family_terms(item, pattern, replacement) for item in value]
if isinstance(value, dict):
return {key: _replace_family_terms(item, pattern, replacement) for key, item in value.items()}
return value
def clean_method_parameters(value: Any) -> Any:
"""Remove supplemental evidence payloads from parameter-like structures."""
if isinstance(value, dict):
cleaned: dict[str, Any] = {}
for key, item in value.items():
if str(key) == "supplemental_evidence" or _is_tool_version_parameter(str(key), item):
continue
cleaned_item = clean_method_parameters(item)
if cleaned_item not in ("", None, [], {}):
cleaned[key] = cleaned_item
return cleaned
if isinstance(value, list):
cleaned_list = []
for item in value:
cleaned_item = clean_method_parameters(item)
if cleaned_item not in ("", None, [], {}):
cleaned_list.append(cleaned_item)
return cleaned_list
return value
def _is_tool_version_parameter(key: str, value: Any) -> bool:
key_norm = _norm(key)
if key_norm in {"version", "toolversion", "softwareversion", "programversion"}:
return True
if key_norm.endswith("version") and key_norm not in {"genomeversion", "annotationversion"}:
return True
value_text = str(value or "").strip()
if re.fullmatch(r"v?\d+(?:\.\d+){0,4}[a-z]?", value_text, re.I) and "version" in key_norm:
return True
return False
def clean_observation_parameters(obs: Any) -> Any:
tools = [replace(tool, parameters=clean_method_parameters(tool.parameters or {})) for tool in obs.tools]
return replace(obs, tools=tools, parameters=clean_method_parameters(obs.parameters or {}))
def final_dedupe_observations(observations: list[Any]) -> tuple[list[Any], list[dict[str, Any]]]:
"""Final high-confidence same-paper/same-stage dedupe before batch files are written."""
selected: list[Any] = []
report: list[dict[str, Any]] = []
for obs in observations:
obs = clean_observation_parameters(obs)
duplicate_index = _final_duplicate_target_index(obs, selected)
if duplicate_index is None:
selected.append(obs)
continue
primary = selected[duplicate_index]
if _final_observation_rank(obs) > _final_observation_rank(primary):
merged = _merge_final_duplicate(obs, primary)
selected[duplicate_index] = merged
primary_id = obs.observation_id
removed_id = primary.observation_id
else:
merged = _merge_final_duplicate(primary, obs)
selected[duplicate_index] = merged
primary_id = primary.observation_id
removed_id = obs.observation_id
evidence_relation = _evidence_duplicate_relation(obs, primary) or _evidence_duplicate_relation(primary, obs) or "high_overlap"
report.append(
{
"paper_id": obs.paper_id,
"normalized_stage": obs.normalized_stage,
"primary_observation_id": primary_id,
"removed_observation_id": removed_id,
"reason": evidence_relation,
"evidence_overlap": f"{_token_overlap(_observation_text(obs), _observation_text(primary)):.3f}",
"tool_overlap": f"{_tool_overlap(_tool_set(obs), _tool_set(primary)):.3f}",
}
)
return selected, report
def _final_duplicate_target_index(obs: Any, selected: list[Any]) -> int | None:
for index, primary in enumerate(selected):
if obs.paper_id != primary.paper_id:
continue
if obs.normalized_stage != primary.normalized_stage:
continue
if _same_stage_duplicate_score(obs, primary) is None:
continue
return index
return None
def _same_stage_duplicate_score(obs: Any, primary: Any) -> tuple[float, float] | None:
left = _observation_text(obs)
right = _observation_text(primary)
relation = _evidence_duplicate_relation(obs, primary)
evidence_overlap = _token_overlap(left, right)
if relation is None and evidence_overlap < 0.72:
return None
left_tools = _tool_set(obs)
right_tools = _tool_set(primary)
tool_overlap = _tool_overlap(left_tools, right_tools)
shorter, longer = (left_tools, right_tools) if len(left) <= len(right) else (right_tools, left_tools)
tool_subset = bool(shorter) and shorter <= longer
empty_weak = (not left_tools or not right_tools) and (_is_weak_context(obs) or _is_weak_context(primary))
weak_same_text = (relation in {"contained_by_primary", "contains_primary"} or evidence_overlap >= 0.8) and (
_is_weak_context(obs) or _is_weak_context(primary) or _is_rule_fallback(obs) or _is_rule_fallback(primary)
)
if tool_overlap >= 0.5 or tool_subset or empty_weak or weak_same_text:
return (evidence_overlap, tool_overlap)
return None
def _merge_final_duplicate(primary: Any, duplicate: Any) -> Any:
tools = _merge_tools(primary.tools, duplicate.tools)
params = clean_method_parameters(dict(primary.parameters or {}))
for key, value in clean_method_parameters(duplicate.parameters or {}).items():
if key not in params and _is_stage_parameter_like(primary.normalized_stage, key, value):
params[key] = value
inputs = _merge_scalar_values(primary.inputs, duplicate.inputs)
outputs = _merge_scalar_values(primary.outputs, duplicate.outputs)
evidence = list(primary.evidence)
known = {(item.section, item.evidence_sentence, item.chunk_id) for item in evidence}
for item in duplicate.evidence:
key = (item.section, item.evidence_sentence, item.chunk_id)
if key not in known:
evidence.append(item)
known.add(key)
raw = primary.raw_method_text
if len(duplicate.raw_method_text or "") > len(raw or "") and _looks_like_primary_context(duplicate):
raw = duplicate.raw_method_text
return replace(primary, raw_method_text=raw, tools=tools, parameters=params, inputs=inputs, outputs=outputs, evidence=evidence)
def _final_observation_rank(obs: Any) -> tuple[int, int, int, int, int, float, int]:
return (
1 if _looks_like_primary_context(obs) else 0,
0 if _is_weak_context(obs) else 1,
0 if _is_rule_fallback(obs) else 1,
len(_tool_set(obs)),
len(clean_method_parameters(obs.parameters or {})),
float(getattr(obs, "confidence", 0) or 0),
len(_observation_text(obs)),
)
def _looks_like_primary_context(obs: Any) -> bool:
section = " ".join(item.section or "" for item in obs.evidence)
text = " ".join([section, obs.raw_method_text or ""])
return bool(__import__("re").search(r"\b(methods?|materials|experimental|methodology|analysis)\b", text, __import__("re").I)) and not _is_weak_context(obs)
def _is_weak_context(obs: Any) -> bool:
section = " ".join(item.section or "" for item in obs.evidence)
text = " ".join([section, " ".join(item.evidence_sentence or "" for item in obs.evidence), obs.raw_method_text or ""])
return bool(__import__("re").search(r"\b(results?|discussion|abstract|figure|fig\.?|table|supplement|additional file|abbreviations)\b", text, __import__("re").I))
def _is_rule_fallback(obs: Any) -> bool:
return bool(__import__("re").search(r"_\d{3}$", obs.observation_id or ""))
def _observation_text(obs: Any) -> str:
parts = [obs.raw_method_text or ""]
parts.extend(item.evidence_sentence or "" for item in obs.evidence)
parts.extend(obs.inputs or [])
parts.extend(obs.outputs or [])
return _compact_text(" ".join(parts))
def _evidence_duplicate_relation(left_obs: Any, right_obs: Any) -> str | None:
left = _compact_text(_observation_text(left_obs)).lower()
right = _compact_text(_observation_text(right_obs)).lower()
if not left or not right:
return None
if len(left) <= len(right) and len(left) >= 80 and left in right:
return "contained_by_primary"
if len(right) < len(left) and len(right) >= 80 and right in left:
return "contains_primary"
if _token_overlap(left, right) >= 0.72:
return "high_overlap"
return None
def _token_overlap(left: str, right: str) -> float:
tokens_left = set(__import__("re").findall(r"[a-zA-Z0-9]+", (left or "").lower()))
tokens_right = set(__import__("re").findall(r"[a-zA-Z0-9]+", (right or "").lower()))
if not tokens_left or not tokens_right:
return 0.0
return len(tokens_left & tokens_right) / len(tokens_left | tokens_right)
def _tool_overlap(left: set[str], right: set[str]) -> float:
if not left and not right:
return 0.0
if not left or not right:
return 0.0
return len(left & right) / len(left | right)
def _tool_set(obs: Any) -> set[str]:
return {_canonical_tool_name(tool.tool_name) for tool in obs.tools if _canonical_tool_name(tool.tool_name)}
def _norm(value: str) -> str:
return "".join(ch.lower() for ch in str(value or "") if ch.isalnum())
def _canonical_tool_name(name: str) -> str:
key = _norm(name)
aliases = {
"megax": "mega", "mega5": "mega", "mega6": "mega", "mega7": "mega", "mega11": "mega",
"clustalomega": "clustal", "clustalw": "clustal", "clustalx": "clustal",
"hmmer3": "hmmer", "hmmsearch": "hmmer", "pfamdatabase": "pfam",
"stringdatabase": "string", "stringdb": "string", "plantcaredatabase": "plantcare",
"gsds20": "gsds", "kakscalculator": "kaks_calculator",
}
return aliases.get(key, key)
def _tool_evidence_aliases(tool_name: str) -> list[str]:
canonical = _canonical_tool_name(tool_name)
aliases = {
"gsds": ["GSDS", "Gene Structure Display Server"],
"mega": ["MEGA", "MEGA X"],
"clustal": ["ClustalW", "ClustalX", "Clustal Omega"],
"hmmer": ["HMMER", "hmmsearch"],
"pfam": ["Pfam", "PFAM"],
"blast": ["BLAST", "BLASTP", "blastp", "blastn", "tblastn"],
"meme": ["MEME", "MEME Suite"],
"mcscanx": ["MCScanX"],
"tbtools": ["TBtools", "TBtools-II"],
"plantcare": ["PlantCARE"],
"pal2nal": ["PAL2NAL", "pal2nal.pl"],
"paml": ["PAML"],
"codeml": ["codeml", "CodeML"],
"muscle": ["MUSCLE"],
"diverge": ["DIVERGE"],
"genevestigator": ["GENEVESTIGATOR", "Genevestigator"],
}
values = [tool_name, canonical, *aliases.get(canonical, [])]
result: list[str] = []
seen: set[str] = set()
for value in values:
key = _norm(value)
if key and key not in seen:
result.append(str(value))
seen.add(key)
return result
def _text_mentions_tool(text: str, tool_name: str) -> bool:
text = str(text or "")
text_norm = _norm(text)
for alias in _tool_evidence_aliases(tool_name):
alias_norm = _norm(alias)
if not alias_norm:
continue
if len(alias_norm) <= 2:
if __import__("re").search(rf"(?<![A-Za-z0-9]){__import__('re').escape(alias)}(?![A-Za-z0-9])", text, __import__("re").I):
return True
continue
if alias_norm in text_norm:
return True
return False
def evidence_sentences_for_obs(obs: Any) -> list[str]:
for item in getattr(obs, "evidence", []) or []:
sentence = _compact_text(getattr(item, "evidence_sentence", ""))
if sentence:
return [sentence]
return []
def evidence_support_for_obs(obs: Any) -> tuple[str, list[str]]:
evidence_text = " ".join(evidence_sentences_for_obs(obs) + [getattr(obs, "raw_method_text", "") or ""])
unsupported = [
tool.tool_name
for tool in getattr(obs, "tools", []) or []
if tool.tool_name and not _text_mentions_tool(evidence_text, tool.tool_name)
]
tool_count = len(getattr(obs, "tools", []) or [])
if not tool_count:
return "supported", []
if not unsupported:
return "supported", []
if len(unsupported) < tool_count:
return "partially_supported", unsupported
return "unresolved", unsupported
def evidence_sentence_for_tool(obs: Any, tool: Any) -> str:
for sentence in evidence_sentences_for_obs(obs):
if _text_mentions_tool(sentence, tool.tool_name):
return sentence
evidence = (getattr(obs, "evidence", []) or [None])[0]
return getattr(evidence, "evidence_sentence", "") if evidence else ""
def section_for_tool_evidence(obs: Any, evidence_sentence: str) -> str:
for item in getattr(obs, "evidence", []) or []:
if getattr(item, "evidence_sentence", "") == evidence_sentence:
return getattr(item, "section", "") or ""
evidence = (getattr(obs, "evidence", []) or [None])[0]
return getattr(evidence, "section", "") if evidence else ""
def _merge_tools(left: list[Any], right: list[Any]) -> list[Any]:
result = list(left or [])
by_name = {_canonical_tool_name(tool.tool_name): index for index, tool in enumerate(result)}
for tool in right or []:
key = _canonical_tool_name(tool.tool_name)
if not key:
continue
cleaned = replace(tool, parameters=clean_method_parameters(tool.parameters or {}))
if key not in by_name:
by_name[key] = len(result)
result.append(cleaned)
continue
current = result[by_name[key]]
params = dict(current.parameters or {})
for pkey, pvalue in clean_method_parameters(tool.parameters or {}).items():
params.setdefault(pkey, pvalue)
version = current.version or tool.version
role = current.role or tool.role
result[by_name[key]] = replace(current, version=version, role=role, parameters=params)
return result
def _merge_scalar_values(left: list[Any], right: list[Any]) -> list[Any]:
result = list(left or [])
seen = {str(item) for item in result}
for item in right or []:
key = str(item)
if key not in seen:
result.append(item)
seen.add(key)
return result
def _is_stage_parameter_like(stage: str, key: str, value: Any) -> bool:
if str(key) == "supplemental_evidence":
return False
value_text = json.dumps(value, ensure_ascii=False) if not isinstance(value, str) else value
if len(value_text) > 600:
return False
if __import__("re").search(r"\b(chunk_id|evidence_sentence|observation_id)\b", f"{key} {value_text}", __import__("re").I):
return False
return True
def _compact_text(text: str) -> str:
return " ".join(str(text or "").split())
def _species_regex(alias: str) -> str:
escaped = re.escape(alias)
if re.fullmatch(r"[A-Za-z][A-Za-z0-9_-]*", alias or ""):
return rf"(?<![A-Za-z0-9]){escaped}(?![A-Za-z0-9])"
return rf"(?<![A-Za-z]){escaped}(?![A-Za-z])"
def _species_canonical_name(name: str) -> str:
clean = re.sub(r"\s+", " ", str(name or "")).strip()
clean = re.sub(r"\s+(?:L\.|L|Mill\.|Bunge|Moench|Merr\.)$", "", clean).strip()
return clean
def _species_aliases_from_text(text: str) -> dict[str, str]:
aliases = dict(SPECIES_ALIASES)
pair_pattern = re.compile(
r"\b([A-Za-z][A-Za-z-]*(?:\s+[A-Za-z][A-Za-z-]*){0,3})\s*"
r"\(([A-Z][a-z]+\s+[a-z][a-z-]+(?:\s+(?:L\.|L|Mill\.|Bunge|Moench|Merr\.))?)\)",
)
bad_common = {"open access", "check for", "additional file", "figure", "table"}
for common, latin in pair_pattern.findall(text or ""):
common_clean = re.sub(r"\s+", " ", common).strip(" .,:;()[]").lower()
canonical = _species_canonical_name(latin)
if not common_clean or common_clean in bad_common:
continue
if len(common_clean) > 60:
continue
aliases[common_clean] = canonical
aliases[canonical.lower()] = canonical
return aliases
def _species_evidence_sources(observations: list[Any], document: Any | None) -> list[dict[str, str]]:
sources: list[dict[str, str]] = []
if document is not None:
for chunk in (document.chunks or [])[:30]:
clean = _compact_text(chunk.text)
if not (20 <= len(clean) <= 260):
continue
if re.search(r"\b(abstract|background|methods?|results?|discussion|fig\.?|table)\b", clean, re.I):
continue
if re.search(r"\b(genome-wide|identification|analysis|expression analysis)\b.{0,160}\b(in|of|from)\b", clean, re.I):
sources.append({"source": "title", "text": clean, "section": chunk.section or "front_matter", "observation_id": "", "stage": ""})
break
for chunk in document.chunks or []:
clean = _compact_text(chunk.text)
if not clean:
continue
section = str(chunk.section or "")
if re.search(r"\b(abstract|background)\b", section, re.I):
sources.append({"source": "abstract", "text": clean, "section": section, "observation_id": "", "stage": ""})
elif is_methods_section(section):
sources.append({"source": "methods", "text": clean, "section": section, "observation_id": "", "stage": ""})
for obs in observations:
evidence = obs.evidence[0] if obs.evidence else None
text = _compact_text(
" ".join(
[
evidence.evidence_sentence if evidence else "",
" ".join(obs.inputs or []),
" ".join(obs.outputs or []),
compact_json(obs.parameters),
obs.raw_method_text or "",
]
)
)
if text:
sources.append(
{
"source": "method_observation",
"text": text,
"section": evidence.section if evidence else "Methods",
"observation_id": obs.observation_id,
"stage": obs.normalized_stage,
}
)
return sources
def _is_background_species_context(text: str) -> bool:
return bool(
re.search(
r"\b(?:such as|including|many species|various plant species|studied in many species|has been widely studied)\b",
text,
re.I,
)
)
def _species_role_for_context(alias: str, canonical: str, text: str, source: str) -> str:
lower = text.lower()
alias_pattern = _species_regex(alias)
alias_match = re.search(alias_pattern, text, re.I)
if not alias_match:
return ""
mention = re.escape(alias)
ref_context = (
re.search(rf"\bdatabases?\s+of\s+{mention}", lower, re.I)
or re.search(rf"\bclassification\s+(?:from|of)\s+{mention}", lower, re.I)
or re.search(rf"{mention}.{{0,90}}\b(?:used as quer(?:y|ies)|as quer(?:y|ies)|classification|TAIR|Rice Genome Annotation Project)\b", text, re.I)
or (re.search(r"\b(?:At[A-Z0-9]+|Os[A-Z0-9]+)\b", text) and canonical in {"Arabidopsis thaliana", "Oryza sativa"})
)
if ref_context:
return "reference_species"
reference_model_species = {"Arabidopsis thaliana", "Oryza sativa"}
explicit_model_target = (
source == "title"
or re.search(rf"\b(?:genome|chromosomes|genes|proteins)\s+of\s+{mention}", lower, re.I)
or re.search(rf"{mention}.{{0,60}}\b(?:genome|chromosomes|genes|proteins)\b", lower, re.I)
or re.search(rf"\bidentified\b.{{0,80}}\b(?:in|from)\s+{mention}", lower, re.I)
)
if canonical in reference_model_species and not explicit_model_target:
return ""
explicit_target_context = (
(source == "title" and re.search(rf"\b(?:in|from|of|for)\s+(?:the\s+)?{mention}\b", lower, re.I))
or re.search(rf"\b(?:in|from|for)\s+(?:the\s+)?{mention}\b", lower, re.I)
or re.search(rf"\b(?:genome|chromosomes|genes|proteins|seedlings|cultivar)\b.{{0,90}}{mention}", lower, re.I)
or re.search(rf"{mention}.{{0,90}}\b(?:genome|chromosomes|genes|proteins|seedlings|cultivar|was downloaded|were identified)\b", lower, re.I)
or (canonical not in {"Arabidopsis thaliana", "Oryza sativa"} and re.search(r"\b(?:identified|obtained|named|mapped|distributed)\b.{0,120}\b(?:PmNAC|CiSPL|FtSPL|SiHDZ)", text, re.I))
)
if explicit_target_context:
return "target_species"
if _is_background_species_context(text):
return ""
if TARGET_SPECIES_ALIAS_PATTERNS.search(alias):
return "target_species"
return ""
def infer_species_rows(paper_id: str, family: str, observations: list[Any], document: Any | None = None) -> list[dict[str, Any]]:
sources = _species_evidence_sources(observations, document)
corpus = " ".join(source["text"] for source in sources)
aliases = _species_aliases_from_text(corpus)
rows: list[dict[str, Any]] = []
seen: set[tuple[str, str, str, str]] = set()
source_rank = {"title": 0, "abstract": 1, "methods": 2, "method_observation": 3}
for source in sorted(sources, key=lambda item: source_rank.get(item["source"], 9)):
text = source["text"]
if not text:
continue
for alias, canonical_raw in aliases.items():
canonical = _species_canonical_name(canonical_raw)
if not canonical or not re.search(_species_regex(alias), text, re.I):
continue
role = _species_role_for_context(alias, canonical, text, source["source"])
if not role:
continue
key = (canonical, role, source.get("observation_id", ""), source["source"])
if key in seen:
continue
seen.add(key)
rows.append(_species_summary_row(paper_id, family, canonical, role, source, text))
rows = _resolve_species_role_conflicts(rows)
if any(row["species_role"] == "target_species" for row in rows):
return rows
# Last non-species-specific rule: if the title contains exactly one common-name/Latin-name pair, it is the target species.
for source in sources:
if source["source"] != "title":
continue
title_aliases = _species_aliases_from_text(source["text"])
pair_candidates = [name for alias, name in title_aliases.items() if alias not in SPECIES_ALIASES]
unique = sorted({_species_canonical_name(name) for name in pair_candidates if name})
if len(unique) == 1:
rows.append(_species_summary_row(paper_id, family, unique[0], "target_species", {"source": "title", "observation_id": "", "stage": ""}, source["text"]))
break
return _resolve_species_role_conflicts(rows)
def _species_summary_row(paper_id: str, family: str, species_name: str, species_role: str, source: dict[str, str], evidence_text: str) -> dict[str, Any]:
bbav5_role = _bbav5_role_for_species_context(species_role, evidence_text, source.get("source", ""), source.get("stage", ""))
return {
"paper_id": paper_id,
"family": family,
"species_name": species_name,
"species_role": species_role,
"bbav5_role": bbav5_role,
"file_scope": _bbav5_role_file_scope(bbav5_role),
"reference_use": _bbav5_role_reference_use(bbav5_role),
"evidence_source": source.get("source", ""),
"observation_id": source.get("observation_id", ""),
"normalized_stage": source.get("stage", ""),
"evidence_sentence": str(evidence_text or "")[:1000],
"confidence": f"{_bbav5_role_confidence(bbav5_role, source.get('source', '')):.2f}",
"actionable": "true" if bbav5_role not in {"background_only", "uncertain", ""} else "false",
}
def _bbav5_role_for_species_context(species_role: str, text: str, source: str, stage: str) -> str:
lower = " ".join([str(text or ""), str(stage or ""), str(source or "")]).lower()
if _is_background_species_context(text):
return "background_only"
if species_role == "target_species":
return "target"
if species_role == "reference_species":
if re.search(r"\bblastp?|quer(?:y|ies)|known .{0,40}protein|reference protein|identify", lower, re.I):
return "identify_blast_reference"
if re.search(r"\bsubfamily|classification|label|clade|phylogen", lower, re.I):
return "primary_reference"
return "primary_reference"
return "uncertain"
def _bbav5_role_file_scope(role: str) -> str:
if role in {"target", "comparative", "outgroup", "primary_reference"}:
return "whole_genome"
if role in {"identify_blast_reference", "seed_reference"}:
return "known_family"
return "none"
def _bbav5_role_reference_use(role: str) -> str:
if role == "identify_blast_reference":
return "identify_blast"
if role == "seed_reference":
return "both"
if role == "primary_reference":
return "downstream_primary"
return "none"
def _bbav5_role_confidence(role: str, source: str) -> float:
if role == "background_only":
return 0.35
if source == "title":
return 0.90
if source in {"methods", "method_observation"}:
return 0.80
return 0.60
def _resolve_species_role_conflicts(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
target_names = {row["species_name"] for row in rows if row.get("species_role") == "target_species"}
non_model_targets = target_names - {"Arabidopsis thaliana", "Oryza sativa"}
filtered: list[dict[str, Any]] = []
seen: set[tuple[str, str, str, str]] = set()
for row in rows:
name = row.get("species_name", "")
role = row.get("species_role", "")
if role == "reference_species" and name in target_names:
continue
if role == "target_species" and non_model_targets and name in {"Arabidopsis thaliana", "Oryza sativa"}:
continue
key = (name, role, row.get("observation_id", ""), row.get("evidence_source", ""))
if key in seen:
continue
seen.add(key)
filtered.append(row)
return filtered
def tool_audit_rows(paper_id: str, family: str, observations: list[Any], alignments_by_id: dict[str, Any]) -> list[dict[str, Any]]:
rows: list[dict[str, Any]] = []
for obs in observations:
alignment = alignments_by_id.get(obs.observation_id)
evidence = obs.evidence[0] if obs.evidence else None
for tool in obs.tools:
params = tool.parameters or {}
tool_evidence = evidence_sentence_for_tool(obs, tool)
rows.append(
{
"paper_id": paper_id,
"family": family,
"observation_id": obs.observation_id,
"normalized_stage": obs.normalized_stage,
"method_type": getattr(obs, "method_type", "computational_method"),
"tool_name": tool.tool_name,
"version": tool.version or "",
"url": params.get("url", ""),
"role": tool.role or "",
"parameters": compact_json(params),
"bbav5_stage_id": alignment.bbav5_stage_id if alignment else "",
"support_status": alignment.support_status if alignment else "",
"evidence_sentence": tool_evidence,
"evidence_support_status": "supported" if tool_evidence and _text_mentions_tool(tool_evidence, tool.tool_name) else "unresolved",
"section": section_for_tool_evidence(obs, tool_evidence) or (evidence.section if evidence else ""),
}
)
return rows
def parameter_audit_rows(paper_id: str, family: str, observations: list[Any]) -> list[dict[str, Any]]:
rows: list[dict[str, Any]] = []
for obs in observations:
evidence = obs.evidence[0] if obs.evidence else None
if getattr(obs, "normalized_stage", "") == "gene_family_identification":
rows.extend(_identify_parameter_audit_rows(paper_id, family, obs, evidence))
continue
for key, value in flatten_parameters(clean_method_parameters(obs.parameters or {})).items():
rows.append(
_parameter_audit_row(
paper_id,
family,
obs,
key,
value,
evidence.evidence_sentence if evidence else "",
evidence.section if evidence else "",
)
)
for tool in obs.tools:
for key, value in flatten_parameters(tool.parameters or {}).items():
rows.append(
_parameter_audit_row(
paper_id,
family,
obs,
f"{tool.tool_name}.{key}",
value,
evidence.evidence_sentence if evidence else "",
evidence.section if evidence else "",
)
)
return rows
def _identify_parameter_audit_rows(paper_id: str, family: str, obs: Any, evidence: Any) -> list[dict[str, Any]]:
rows: list[dict[str, Any]] = []
evidence_sentence = evidence.evidence_sentence if evidence else ""
section = evidence.section if evidence else ""
emitted: set[tuple[str, str]] = set()
for component in getattr(obs, "method_components", []) or []:
if not isinstance(component, dict):
continue
component_type = str(component.get("component_type") or "").strip() or _component_type_for_tool_names(_component_tools(component))
component_tool = _component_tool_display(component)
component_evidence = str(component.get("evidence_sentence") or evidence_sentence)
component_params = _component_parameters_for_audit(component)
for key, value in flatten_parameters(component_params).items():
parameter_name = _identify_component_parameter_name(component_type, component_tool, key)
row_key = (parameter_name, compact_json(value) if isinstance(value, (dict, list)) else str(value))
if row_key in emitted:
continue
emitted.add(row_key)
rows.append(
_parameter_audit_row(
paper_id,
family,
obs,
parameter_name,
value,
component_evidence,
section,
component_type=component_type,
component_tool=component_tool,
)
)
for key, value in flatten_parameters(clean_method_parameters(obs.parameters or {})).items():
parameter_name, component_type, component_tool = _identify_legacy_parameter_assignment(key)
rows.append(
_parameter_audit_row(
paper_id,
family,
obs,
parameter_name,
value,
evidence_sentence,
section,
component_type=component_type,
component_tool=component_tool,
)
)
for tool in obs.tools:
component_type = _component_type_for_tool_names([tool.tool_name])
component_tool = tool.tool_name if component_type else ""
for key, value in flatten_parameters(tool.parameters or {}).items():
parameter_name = _identify_component_parameter_name(component_type, component_tool, key) if component_type else f"{tool.tool_name}.{key}"
rows.append(
_parameter_audit_row(
paper_id,
family,
obs,
parameter_name,
value,
evidence_sentence,
section,
component_type=component_type,
component_tool=component_tool,
)
)
return rows
def _parameter_audit_row(
paper_id: str,
family: str,
obs: Any,
parameter_name: str,
value: Any,
evidence_sentence: str,
section: str,
*,
component_type: str = "",
component_tool: str = "",
) -> dict[str, Any]:
return {
"paper_id": paper_id,
"family": family,
"observation_id": obs.observation_id,
"normalized_stage": obs.normalized_stage,
"method_type": getattr(obs, "method_type", "computational_method"),
"component_type": component_type,
"component_tool": component_tool,
"parameter_name": parameter_name,
"parameter_value": compact_json(value) if isinstance(value, (dict, list)) else value,
"parameter_type": type(value).__name__,
"evidence_sentence": evidence_sentence,
"section": section,
}
def _component_parameters_for_audit(component: dict[str, Any]) -> dict[str, Any]:
params = clean_method_parameters(component.get("parameters") or {})
params = dict(params) if isinstance(params, dict) else {}
for source_key, target_key in (
("reference_species", "reference_species"),
("referenceSpecies", "reference_species"),
("reference_sets", "reference_sets"),
("referenceSets", "reference_sets"),
("target_database", "target_database"),
("targetDatabase", "target_database"),
):
value = component.get(source_key)
if value not in (None, "", [], {}):
params.setdefault(target_key, value)
return params
def _component_tools(component: dict[str, Any]) -> list[str]:
tools: list[str] = []
if component.get("tool"):
tools.append(str(component.get("tool")))
if component.get("tool_name"):
tools.append(str(component.get("tool_name")))
if isinstance(component.get("tools"), list):
tools.extend(str(item) for item in component.get("tools") if str(item).strip())
return tools
def _component_tool_display(component: dict[str, Any]) -> str:
tools = []
for tool in _component_tools(component):
if tool and tool not in tools:
tools.append(tool)
return ";".join(tools)
def _component_type_for_tool_names(tools: list[str]) -> str:
joined = " ".join(tools)
if re.search(r"\bBLASTP?|blastp|blastn|tblastn\b", joined, re.I):
return "blast_search"
if re.search(r"\bHMMER|hmmsearch|Pfam\b", joined, re.I):
return "hmm_search"
if re.search(r"\bSMART|CDD|CD-Search|InterProScan\b", joined, re.I):
return "domain_confirmation"
return ""
def _identify_component_parameter_name(component_type: str, component_tool: str, key: str) -> str:
text = str(key or "").strip()
if not text:
return text
prefix, _, suffix = text.partition(".")
prefix_norm = _norm(prefix)
if prefix_norm in {"blast", "hmmer", "cdd", "confirmation", "domainconfirmation", "unassigned"} and suffix:
return _normalize_scoped_identify_parameter(prefix, suffix)
suffix = _normalize_identify_parameter_suffix(text)
if component_type == "blast_search":
return f"blast.{suffix}"
if component_type == "hmm_search":
return f"hmmer.{suffix}"
if component_type == "domain_confirmation":
if _norm(component_tool) in {"ncbicdd", "cdd", "cdsearch"} or suffix.lower() in {"evalue", "e_value"}:
return f"cdd.{suffix}"
return f"confirmation.{suffix}"
if component_type == "unassigned":
return f"unassigned.{text}"
return text
def _identify_legacy_parameter_assignment(key: str) -> tuple[str, str, str]:
text = str(key or "").strip()
norm = _norm(text)
if "." in text:
prefix, suffix = text.split(".", 1)
scoped = _normalize_scoped_identify_parameter(prefix, suffix)
component_type = {
"blast": "blast_search",
"hmmer": "hmm_search",
"cdd": "domain_confirmation",
"domainConfirmation": "domain_confirmation",
"confirmation": "domain_confirmation",
"unassigned": "unassigned",
}.get(scoped.split(".", 1)[0], "")
return scoped, component_type, ""
if norm in {"evalue", "evaluecutoff"} or text in {"e_value", "e-value", "E-value"}:
return "unassigned.e_value", "unassigned", ""
if norm in {"blastidentitythreshold", "blastidentity", "identitythreshold"}:
return "blast.identityThreshold", "blast_search", ""
if norm in {"blastevalue", "blastevaluecutoff"}:
return "blast.evalue", "blast_search", ""
if norm in {"hmmerevalue", "hmmerevaluecutoff", "hmmerthreshold", "hmmerethreshold"}:
return "hmmer.evalue", "hmm_search", ""
if norm in {"hmmercoverage", "hmmcoverage"}:
return "hmmer.coverage", "hmm_search", ""
if norm in {"cddevalue", "cddthreshold"}:
return "cdd.evalue", "domain_confirmation", ""
if norm in {"domain", "pfamdomain", "pfamdomains", "hmmerdomains", "domains"}:
return "hmmer.domains", "hmm_search", ""
return text, "", ""
def _normalize_scoped_identify_parameter(prefix: str, suffix: str) -> str:
normalized_prefix = {
"blast": "blast",
"hmmer": "hmmer",
"cdd": "cdd",
"domainconfirmation": "domainConfirmation",
"confirmation": "confirmation",
"unassigned": "unassigned",
}.get(_norm(prefix), prefix)
return f"{normalized_prefix}.{_normalize_identify_parameter_suffix(suffix)}"
def _normalize_identify_parameter_suffix(value: str) -> str:
norm = _norm(value)
if norm in {"evalue", "evaluecutoff"}:
return "evalue"
if norm in {"identity", "identitythreshold", "percentidentity", "percentidentitythreshold"}:
return "identityThreshold"
if norm in {"referencespecies", "referencespecieslist"}:
return "reference_species"
if norm in {"referencesets", "referenceset", "referencequeries"}:
return "reference_sets"
if norm in {"targetdatabase", "targetdb"}:
return "target_database"
if norm in {"domains", "domain", "pfamdomains", "pfamdomain"}:
return "domains"
if norm in {"cddevalue", "cddevaluecutoff"}:
return "evalue"
return value
def flatten_parameters(value: Any, prefix: str = "") -> dict[str, Any]:
if not isinstance(value, dict):
return {prefix or "value": value} if value not in ("", None, [], {}) else {}
rows: dict[str, Any] = {}
for key, item in value.items():
if str(key) == "supplemental_evidence":
continue
name = f"{prefix}.{key}" if prefix else str(key)
if isinstance(item, dict):
rows.update(flatten_parameters(item, name))
else:
rows[name] = item
return rows
def url_audit_rows(paper_id: str, family: str, observations: list[Any]) -> list[dict[str, Any]]:
import re
rows: list[dict[str, Any]] = []
seen = set()
for obs in observations:
evidence = obs.evidence[0] if obs.evidence else None
text = " ".join([evidence.evidence_sentence if evidence else "", obs.raw_method_text or ""])
for tool in obs.tools:
url = (tool.parameters or {}).get("url", "")
if url:
key = (obs.observation_id, tool.tool_name, url)
if key not in seen:
seen.add(key)
rows.append(
{
"paper_id": paper_id,
"family": family,
"observation_id": obs.observation_id,
"normalized_stage": obs.normalized_stage,
"source_name": tool.tool_name,
"source_type": "tool_parameter",
"url": url,
"evidence_sentence": evidence.evidence_sentence if evidence else "",
"section": evidence.section if evidence else "",
}
)
for match in re.finditer(r"https?://[^\s,)]+|www\.[^\s,)]+", text):
url = match.group(0).rstrip(".,;")
key = (obs.observation_id, "", url)
if key in seen:
continue
seen.add(key)
rows.append(
{
"paper_id": paper_id,
"family": family,
"observation_id": obs.observation_id,
"normalized_stage": obs.normalized_stage,
"source_name": "",
"source_type": "evidence_url",
"url": url,
"evidence_sentence": evidence.evidence_sentence if evidence else "",
"section": evidence.section if evidence else "",
}
)
return rows
def build_review_rows(
paper_id: str,
family: str,
observations: list[Any],
alignments_by_id: dict[str, Any],
catalog: dict[str, Any],
) -> list[dict[str, Any]]:
rows: list[dict[str, Any]] = []
for obs in observations:
alignment = alignments_by_id.get(obs.observation_id)
evidence = obs.evidence[0] if obs.evidence else None
evidence_sentences = evidence_sentences_for_obs(obs)
evidence_support_status, unsupported_tools = evidence_support_for_obs(obs)
default_tools = []
if alignment and alignment.bbav5_stage_id:
default_tools = catalog.get("stages", {}).get(alignment.bbav5_stage_id, {}).get("default_tools", [])
rows.append(
{
"paper_id": paper_id,
"family": family,
"observation_id": obs.observation_id,
"normalized_stage": obs.normalized_stage,
"method_type": getattr(obs, "method_type", "computational_method"),
"experiment_type": getattr(obs, "experiment_type", ""),
"validation_target": getattr(obs, "validation_target", ""),
"biological_material": getattr(obs, "biological_material", ""),
"treatment_or_condition": getattr(obs, "treatment_or_condition", ""),
"manual_review_hint": getattr(obs, "manual_review_hint", ""),
"tools": ";".join(tool.tool_name for tool in obs.tools),
"versions": ";".join(f"{tool.tool_name}:{tool.version}" for tool in obs.tools if tool.version),
"parameters": compact_json(clean_method_parameters(obs.parameters or {})),
"inputs": ";".join(obs.inputs),
"outputs": ";".join(obs.outputs),
"bbav5_stage_id": alignment.bbav5_stage_id if alignment else "",
"bbav5_default_tool": ";".join(default_tools),
"replacement_tools": ";".join(alignment.replacement_tools if alignment else []),
"support_status": alignment.support_status if alignment else "",
"evidence_sentence": evidence.evidence_sentence if evidence else "",
"evidence_sentences": compact_json(evidence_sentences),
"evidence_support_status": evidence_support_status,
"unsupported_tools": ";".join(unsupported_tools),
"missing_tools": ";".join(getattr(obs, "missing_tools", []) or []),
"missing_parameters": ";".join(getattr(obs, "missing_parameters", []) or []),
"evidence_repair_status": getattr(obs, "evidence_repair_status", ""),
"evidence_quality_status": getattr(obs, "evidence_quality_status", ""),
"evidence_quality_reason": getattr(obs, "evidence_quality_reason", ""),
"evidence_source_section": getattr(obs, "evidence_source_section", ""),
"v15_review_status": getattr(obs, "v15_review_status", ""),
"v15_review_reason": getattr(obs, "v15_review_reason", ""),
"v15_review_suggestions": compact_json(getattr(obs, "v15_review_suggestions", []) or []),
"section": evidence.section if evidence else "",
"raw_method_text": obs.raw_method_text[:1200],
"review_status": "",
"stage_review": "",
"tools_review": "",
"versions_review": "",
"parameters_review": "",
"inputs_outputs_review": "",
"bbav5_review": "",
"bbav5_stage_review": "",
"bbav5_tools_review": "",
"support_status_review": "",
"corrected_stage_value": "",
"corrected_tools_value": "",
"corrected_versions_value": "",
"corrected_parameters_value": "",
"corrected_evidence_value": "",
"corrected_inputs_value": "",
"corrected_outputs_value": "",
"corrected_bbav5_stage_value": "",
"corrected_bbav5_tools_value": "",
"corrected_support_status_value": "",
"corrected_stage": "",
"corrected_tools": "",
"corrected_parameters": "",
"corrected_inputs": "",
"corrected_outputs": "",
"corrected_bbav5_stage": "",
"corrected_support_status": "",
"evidence_review": "",
"manual_note": "",
}
)
return rows
def sanitize_review_text(value: Any) -> str:
text = str(value or "")
if "supplemental_evidence" not in text:
return text
text = __import__("re").sub(r'"?supplemental_evidence"?\s*:\s*\[.*?\]', '[supplemental evidence removed]', text, flags=__import__("re").S)
return text.replace("supplemental_evidence", "supplemental evidence")
def sanitize_review_row(row: dict[str, Any]) -> dict[str, Any]:
cleaned = dict(row)
for key in ("parameters", "corrected_parameters_value", "corrected_parameters", "manual_note"):
if key in cleaned:
cleaned[key] = sanitize_review_text(cleaned.get(key, ""))
return cleaned
def write_per_paper_checkpoint(
output_dir: Path,
paper_id: str,
observations: list[Any],
package: Any,
rows: dict[str, list[dict[str, Any]]],
review_report: dict[str, Any],
duplicate_merge_rows: list[dict[str, Any]] | None = None,
) -> None:
paper_dir = output_dir / "per_paper" / paper_id
paper_dir.mkdir(parents=True, exist_ok=True)
dump_json(paper_dir / "observations.json", [observation_to_output_dict(obs) for obs in observations])
dump_json(paper_dir / "alignment.json", package.to_dict())
dump_json(paper_dir / "methodology_review_report.json", review_report)
write_csv(paper_dir / "review_template.csv", REVIEW_FIELDS, [sanitize_review_row(row) for row in rows.get("review", [])])
write_csv(paper_dir / "methodology_tool_audit.csv", TOOL_AUDIT_FIELDS, rows.get("tool_audit", []))
write_csv(paper_dir / "methodology_parameter_audit.csv", PARAMETER_AUDIT_FIELDS, rows.get("parameter_audit", []))
write_csv(paper_dir / "methodology_database_url_audit.csv", URL_AUDIT_FIELDS, rows.get("url_audit", []))
write_csv(paper_dir / "species_summary.csv", SPECIES_SUMMARY_FIELDS, rows.get("species", []))
write_csv(paper_dir / "methodology_review_issues.csv", REVIEW_ISSUE_FIELDS, review_report.get("issues", []))
dump_json(paper_dir / "duplicate_merge_report.json", {"merges": duplicate_merge_rows or [], "merge_count": len(duplicate_merge_rows or [])})
def discover_tool_candidates(paper_id: str, family: str, obs: Any, alignment: Any, evidence: Any) -> list[dict[str, Any]]:
import re
known_norm = {re.sub(r"[^a-z0-9]+", "", tool.lower()) for tool in KNOWN_TOOL_NAMES}
observed_norm = {re.sub(r"[^a-z0-9]+", "", tool.tool_name.lower()) for tool in obs.tools}
candidates: list[dict[str, Any]] = []
text = obs.raw_method_text
patterns = [
r"\b[A-Z][A-Za-z0-9_.+-]{2,}(?:\s+[A-Z][A-Za-z0-9_.+-]{2,}){0,1}\b",
r"\b[a-z]+[A-Z][A-Za-z0-9_.+-]{2,}\b",
]
seen = set()
for pattern in patterns:
for match in re.finditer(pattern, text):
name = match.group(0).strip(" ,.;:()[]")
norm = re.sub(r"[^a-z0-9]+", "", name.lower())
norm_no_version = re.sub(r"\d+(?:\d+)*$", "", norm)
if (
not norm
or norm in seen
or norm in known_norm
or norm_no_version in known_norm
or norm in observed_norm
or norm_no_version in observed_norm
or any(norm in known or known in norm for known in observed_norm if len(known) >= 5)
):
continue
if name in NON_TOOL_TERMS or name.lower() in {"the", "and", "for", "using", "additional", "figure", "table", "methods"}:
continue
if any(re.search(pattern, name) for pattern in KNOWN_NON_TOOL_PATTERNS):
continue
window_start = max(0, match.start() - 180)
window_end = min(len(text), match.end() + 220)
window = " ".join(text[window_start:window_end].split())
if not re.search(r"\b(software|program|tool|package|database|server|website|platform|using|used|performed with|analyzed with|conducted by|calculated by|constructed with)\b", window, re.I):
continue
if not re.search(re.escape(name) + r".{0,80}\b(software|program|tool|package|database|server|website|platform)\b|\b(software|program|tool|package|database|server|website|platform)\b.{0,80}" + re.escape(name), window, re.I):
continue
if re.search(r"\b(gene|genes|protein|proteins|domain|domains|sequence|sequences|species|sample|samples|value|method|analysis)\b", name, re.I):
continue
if len(name) < 3 or len(name) > 60:
continue
seen.add(norm)
version_match = re.search(r"(?:v|version)?\s*(\d+(?:\.\d+){0,3})", window, re.I)
url_match = re.search(r"https?://[^\s,)]+", window)
candidates.append(
{
"tool_name": name,
"paper_id": paper_id,
"family": family,
"observation_id": obs.observation_id,
"stage_guess": obs.normalized_stage,
"role_guess": "",
"version": version_match.group(1) if version_match else "",
"url": url_match.group(0) if url_match else "",
"evidence_sentence": evidence.evidence_sentence if evidence else window[:500],
"review_status": "",
"accepted_stage": "",
"accepted_role": "",
"bbav5_stage": alignment.bbav5_stage_id if alignment else "",
"support_status": alignment.support_status if alignment else "",
"manual_note": "",
}
)
return candidates
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--papers", default="data/evaluation/methodology_papers.csv")
parser.add_argument("--catalog", default="data/bbav5_capability_catalog.json")
parser.add_argument("--output-dir", default="data/evaluation/methodology_batch")
parser.add_argument("--paper-id", default="", help="Optional single paper_id to process.")
parser.add_argument("--max-papers", type=int, default=0, help="Optional cap for smoke tests.")
parser.add_argument(
"--reference",
default="data/evaluation/bbav5_methodology_reference/bbav5_tools_reviewed.json",
help="Reviewed bbav5 methodology reference JSON.",
)
parser.add_argument(
"--llm-discovery-json",
default="",
help="Optional external LLM tool discovery JSON to merge into discovered_tools.csv.",
)
parser.add_argument(
"--use-llm",
action="store_true",
help="Use LLM-based extraction instead of rule-based extraction. Requires LLM_API_KEY, DEEPSEEK_API_KEY, or DASHSCOPE_API_KEY.",
)
parser.add_argument(
"--llm-model",
default="deepseek-v4-pro",
help="LLM model to use for extraction (default: deepseek-v4-pro)",
)
parser.add_argument(
"--llm-provider",
default="deepseek",
choices=["deepseek", "kimi", "openai_compatible"],
help="LLM provider profile family (default: deepseek).",
)
parser.add_argument(
"--llm-profile",
default="",
choices=["", "deepseek", "kimi", "openai_compatible"],
help="Optional provider profile override for prompt/output behavior.",
)
parser.add_argument(
"--llm-base-url",
default="",
help="OpenAI-compatible LLM base URL. Defaults to LLM_BASE_URL/DEEPSEEK_BASE_URL or https://api.deepseek.com/v1.",
)
parser.add_argument(
"--llm-api-key-env",
default="",
help="Environment variable name containing the LLM API key. Explicit env selection overrides provider defaults.",
)
parser.add_argument(
"--llm-disable-repair",
action="store_true",
help="Disable JSON repair retry and use rule fallback on invalid model JSON.",
)
parser.add_argument(
"--llm-request-timeout",
type=int,
default=75,
help="Per-request LLM timeout in seconds. Logged, not used as a whole-batch limit.",
)
parser.add_argument(
"--external-job-status-json",
default="",
help="Optional path to methodology upload job status.json to patch with substage progress.",
)
args = parser.parse_args()
papers_path = resolve(args.papers)
catalog = load_or_build_capability_catalog(resolve(args.catalog))
aligner = MethodologyAligner(catalog)
output_dir = resolve(args.output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
log_path = output_dir / "methodology_batch_run.log"
progress_path = output_dir / "progress.jsonl"
llm_discovery_path = resolve(args.llm_discovery_json) if args.llm_discovery_json else None
reference_path = resolve(args.reference) if args.reference else None
reviewer = MethodologyReviewer.from_reference_path(reference_path)
print_log(log_path, f"START batch papers={papers_path} output_dir={output_dir} use_llm={args.use_llm}")
append_jsonl(progress_path, {"event": "batch_start", "papers": str(papers_path), "use_llm": args.use_llm})
# Initialize LLM extractor if requested
llm_extractor = None
if args.use_llm:
from src.rag.unified.llm_methodology_extractor import LLMMethodologyExtractor
llm_extractor = LLMMethodologyExtractor(
model=args.llm_model,
base_url=args.llm_base_url or None,
provider=args.llm_provider,
provider_profile=args.llm_profile,
api_key_env=args.llm_api_key_env,
disable_repair=args.llm_disable_repair,
request_timeout=args.llm_request_timeout,
)
print_log(
log_path,
"Using LLM extraction "
f"provider={args.llm_provider} profile={args.llm_profile or args.llm_provider} "
f"model={llm_extractor.model} base_url={llm_extractor.base_url} "
f"request_timeout={args.llm_request_timeout}s repair_enabled={not args.llm_disable_repair}",
)
all_observations = []
all_alignments = []
review_rows: list[dict[str, Any]] = []
summary_rows: list[dict[str, Any]] = []
status_rows: list[dict[str, Any]] = []
tool_audit: list[dict[str, Any]] = []
parameter_audit: list[dict[str, Any]] = []
url_audit: list[dict[str, Any]] = []
species_summary: list[dict[str, Any]] = []
review_issue_rows: list[dict[str, Any]] = []
review_reports: list[dict[str, Any]] = []
discovered_tool_rows: list[dict[str, Any]] = load_llm_discoveries(llm_discovery_path)
duplicate_merge_rows: list[dict[str, Any]] = []
llm_provider_reports: list[dict[str, Any]] = []
ext_job_status = str(args.external_job_status_json or "").strip()
paper_rows = read_rows(papers_path)
if args.paper_id:
paper_rows = [row for row in paper_rows if row["paper_id"].strip() == args.paper_id]
if args.max_papers:
paper_rows = paper_rows[: args.max_papers]
for index, row in enumerate(paper_rows, start=1):
paper_id = row["paper_id"].strip()
family = row.get("family", "").strip()
role = row.get("source_paper_role", "primary_gene_family_paper").strip() or "primary_gene_family_paper"
parsed_path = resolve(row["parsed_document_path"].strip())
paper_duplicate_merges: list[dict[str, Any]] = []
paper_llm_provider_reports: list[dict[str, Any]] = []
print_log(log_path, f"PAPER_START {index}/{len(paper_rows)} paper_id={paper_id} path={parsed_path}")
append_jsonl(progress_path, {"event": "paper_start", "paper_id": paper_id, "index": index, "total": len(paper_rows)})
try:
document = ParsedDocumentLoader.from_path(parsed_path, document_id=paper_id)
patch_external_job_status(ext_job_status, "load_document", "已载入解析文档,准备抽取方法学…")
# Use LLM extraction if requested, otherwise use rule-based
if llm_extractor:
print_log(log_path, f"EXTRACT_START paper_id={paper_id} mode=llm")
patch_external_job_status(ext_job_status, "extract", "正在进行方法学抽取(LLM)…")
observations = llm_extractor.extract(document, paper_id, role)
paper_llm_provider_reports = list(getattr(llm_extractor, "provider_report", []))
llm_provider_reports.extend(paper_llm_provider_reports)
else:
print_log(log_path, f"EXTRACT_START paper_id={paper_id} mode=rule")
patch_external_job_status(ext_job_status, "extract", "正在进行方法学抽取(规则)…")
observations = ParsedDocumentMethodologyExtractor(paper_id, role).extract(document)
patch_external_job_status(ext_job_status, "normalize", "抽取完成,正在规范化 observation…")
observations = normalize_observations(observations, family)
patch_external_job_status(ext_job_status, "align", "正在进行 bbav5 目录对齐与审计表生成…")
package = aligner.build_package(paper_id, observations)
error = ""
print_log(log_path, f"EXTRACT_DONE paper_id={paper_id} observations={len(observations)}")
except Exception as exc:
observations = []
package = aligner.build_package(paper_id, [])
error = f"{type(exc).__name__}: {exc}"
document = None
print_log(log_path, f"EXTRACT_ERROR paper_id={paper_id} error={error}")
methods_chunks = []
if document is not None:
methods_chunks = [chunk for chunk in document.chunks if is_methods_section(chunk.section)]
methods_text_length = sum(len(chunk.text or "") for chunk in methods_chunks)
if methods_text_length < 500 and observations:
observed_texts = {
(obs.raw_method_text or "").strip()
for obs in observations
if getattr(obs, "raw_method_text", "")
}
methods_text_length = max(methods_text_length, sum(len(text) for text in observed_texts))
ready_for_review = not error and methods_text_length >= 500 and len(observations) >= 3
alignments_by_id = {alignment.observation_id: alignment for alignment in package.alignments}
paper_tool_audit = tool_audit_rows(paper_id, family, observations, alignments_by_id)
paper_parameter_audit = parameter_audit_rows(paper_id, family, observations)
paper_url_audit = url_audit_rows(paper_id, family, observations)
paper_species_summary = infer_species_rows(paper_id, family, observations, document)
paper_review_rows = build_review_rows(paper_id, family, observations, alignments_by_id, catalog)
patch_external_job_status(ext_job_status, "review", "正在执行自动审核(规则 / 交叉验证)…")
review_report = reviewer.review(paper_id, family, observations, package.alignments, document)
tool_audit.extend(paper_tool_audit)
parameter_audit.extend(paper_parameter_audit)
url_audit.extend(paper_url_audit)
species_summary.extend(paper_species_summary)
review_rows.extend(paper_review_rows)
review_issue_rows.extend(review_report.get("issues", []))
review_reports.append(review_report)
all_observations.extend(observation_to_output_dict(observation) for observation in observations)
all_alignments.extend(asdict(alignment) for alignment in package.alignments)
stages = sorted({obs.normalized_stage for obs in observations})
tools = sorted({tool.tool_name for obs in observations for tool in obs.tools})
support_counts: dict[str, int] = {}
for alignment in package.alignments:
support_counts[alignment.support_status] = support_counts.get(alignment.support_status, 0) + 1
summary_rows.append(
{
"paper_id": paper_id,
"family": family,
"source_paper_role": role,
"parsed_document_path": row.get("parsed_document_path", ""),
"observation_count": len(observations),
"stage_count": len(stages),
"stages_detected": ";".join(stages),
"tools_detected": ";".join(tools),
"support_counts": compact_json(support_counts),
"error": error,
}
)
status_rows.append(
{
"paper_id": paper_id,
"family": family,
"source_paper_role": role,
"parsed_document_path": row.get("parsed_document_path", ""),
"parsed_document_exists": "yes" if parsed_path.exists() else "no",
"parser": document.parser if document else "",
"chunk_count": len(document.chunks) if document else 0,
"methods_chunk_count": len(methods_chunks),
"methods_text_length": methods_text_length,
"observation_count": len(observations),
"ready_for_review": "yes" if ready_for_review else "no",
"failure_reason": error
or (
"methods_text_too_short"
if methods_text_length < 500
else ("too_few_observations" if len(observations) < 3 else "")
),
}
)
for obs in observations:
alignment = alignments_by_id.get(obs.observation_id)
evidence = obs.evidence[0] if obs.evidence else None
if ready_for_review:
discovered_tool_rows.extend(discover_tool_candidates(paper_id, family, obs, alignment, evidence))
patch_external_job_status(ext_job_status, "persist", "审核完成,正在写入本轮检查点…")
write_per_paper_checkpoint(
output_dir,
paper_id,
observations,
package,
{
"review": paper_review_rows,
"tool_audit": paper_tool_audit,
"parameter_audit": paper_parameter_audit,
"url_audit": paper_url_audit,
"species": paper_species_summary,
},
review_report,
paper_duplicate_merges,
)
if paper_llm_provider_reports:
dump_json(
output_dir / "per_paper" / paper_id / "llm_provider_report.json",
{"windows": paper_llm_provider_reports, "window_count": len(paper_llm_provider_reports)},
)
append_jsonl(
progress_path,
{
"event": "paper_done" if not error else "paper_failed",
"paper_id": paper_id,
"observation_count": len(observations),
"tool_count": len(paper_tool_audit),
"parameter_count": len(paper_parameter_audit),
"species_count": len(paper_species_summary),
"review_status": review_report.get("review_status"),
"review_score": review_report.get("score"),
"error": error,
},
)
print_log(
log_path,
f"PAPER_DONE paper_id={paper_id} observations={len(observations)} "
f"tools={len(paper_tool_audit)} species={len(paper_species_summary)} "
f"review={review_report.get('review_status')} score={review_report.get('score')}",
)
final_stage_names = sorted({
stage
for row in summary_rows
for stage in str(row.get("stages_detected") or "").split(";")
if stage
})
final_stage_count = len(final_stage_names)
patch_external_job_status(
ext_job_status,
"done",
"方法学批量提取完成",
extra={
"stage_count": final_stage_count,
"species_count": len(species_summary),
"methodology_observation_count": len(all_observations),
"methodology_tool_count": len(tool_audit),
"methodology_stage_names": final_stage_names,
},
)
dump_json(output_dir / "observations.json", all_observations)
dump_json(output_dir / "alignment.json", {"observations": all_observations, "alignments": all_alignments})
dump_json(output_dir / "methodology_review_report.json", {"reports": review_reports})
dump_json(output_dir / "duplicate_merge_report.json", {"merges": duplicate_merge_rows, "merge_count": len(duplicate_merge_rows)})
dump_json(output_dir / "llm_provider_report.json", {"windows": llm_provider_reports, "window_count": len(llm_provider_reports)})
write_csv(output_dir / "summary.csv", list(summary_rows[0].keys()) if summary_rows else [], summary_rows)
write_csv(output_dir / "processing_status.csv", STATUS_FIELDS, status_rows)
write_csv(output_dir / "review_template.csv", REVIEW_FIELDS, [sanitize_review_row(row) for row in review_rows])
write_csv(output_dir / "discovered_tools.csv", DISCOVERED_TOOL_FIELDS, discovered_tool_rows)
write_csv(output_dir / "methodology_tool_audit.csv", TOOL_AUDIT_FIELDS, tool_audit)
write_csv(output_dir / "methodology_parameter_audit.csv", PARAMETER_AUDIT_FIELDS, parameter_audit)
write_csv(output_dir / "methodology_database_url_audit.csv", URL_AUDIT_FIELDS, url_audit)
write_csv(output_dir / "species_summary.csv", SPECIES_SUMMARY_FIELDS, species_summary)
write_csv(output_dir / "methodology_review_issues.csv", REVIEW_ISSUE_FIELDS, review_issue_rows)
try:
dump_json(output_dir / "methodology_plan.json", build_methodology_execution_plan(output_dir))
except Exception as exc:
dump_json(output_dir / "methodology_plan_error.json", {"error": str(exc)})
write_tool_lexicon(PROJECT_ROOT / "data/evaluation/methodology_tool_lexicon.json", discovered_tool_rows + tool_audit)
append_jsonl(progress_path, {"event": "batch_done", "papers": len(summary_rows), "observations": len(review_rows)})
print_log(log_path, f"Wrote {output_dir / 'observations.json'}")
print_log(log_path, f"Wrote {output_dir / 'alignment.json'}")
print_log(log_path, f"Wrote {output_dir / 'summary.csv'}")
print_log(log_path, f"Wrote {output_dir / 'processing_status.csv'}")
print_log(log_path, f"Wrote {output_dir / 'review_template.csv'}")
print_log(log_path, f"Wrote {output_dir / 'discovered_tools.csv'}")
print_log(log_path, f"Wrote {output_dir / 'methodology_tool_audit.csv'}")
print_log(log_path, f"Wrote {output_dir / 'methodology_parameter_audit.csv'}")
print_log(log_path, f"Wrote {output_dir / 'methodology_database_url_audit.csv'}")
print_log(log_path, f"Wrote {output_dir / 'species_summary.csv'}")
print_log(log_path, f"Wrote {output_dir / 'methodology_review_issues.csv'}")
print_log(log_path, f"Wrote {output_dir / 'methodology_review_report.json'}")
print_log(log_path, f"Wrote {output_dir / 'methodology_plan.json'}")
print_log(log_path, f"Wrote {PROJECT_ROOT / 'data/evaluation/methodology_tool_lexicon.json'}")
print_log(log_path, f"DONE papers={len(summary_rows)} observations={len(review_rows)}")
if __name__ == "__main__":
main()