#!/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"(? 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"(? 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()