1739 lines
72 KiB
Python
Executable File
1739 lines
72 KiB
Python
Executable File
"""Bridge saved literature methodology plans into bbav5 job state.
|
|
|
|
This module deliberately does not execute stages. It binds a reviewed plan to a
|
|
job, checks whether user data/config are present, and prepares params for the
|
|
existing MAS planner.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import csv
|
|
import copy
|
|
import json
|
|
import re
|
|
import shutil
|
|
from dataclasses import asdict, dataclass
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
|
|
WHOLE_GENOME_EXTENSIONS = ('.fa', '.fasta', '.faa', '.fna', '.fas', '.pep', '.cds')
|
|
GFF_EXTENSIONS = ('.gff', '.gff3', '.gtf')
|
|
FIXED_METHODOLOGY_STAGE_ORDER = [
|
|
'genome_prepare',
|
|
'identify',
|
|
'phylogeny',
|
|
'motif',
|
|
'domain',
|
|
'structure',
|
|
'promoter',
|
|
'combo',
|
|
'reference_classification',
|
|
'subfamily_confirm',
|
|
'synteny',
|
|
'interspecies_synteny',
|
|
'karyotype',
|
|
'ppi',
|
|
'expression',
|
|
'pep',
|
|
'rnaseq_qc',
|
|
'rnaseq_align',
|
|
'rnaseq_quant',
|
|
'rnaseq_sample_qc',
|
|
'rnaseq_de',
|
|
'rnaseq_go_enrich',
|
|
'rnaseq_kegg_enrich',
|
|
'rnaseq_fgsea',
|
|
'rnaseq_wgcna',
|
|
]
|
|
METHODOLOGY_RAW_RNASEQ_STAGES = {
|
|
'rnaseq_qc', 'rnaseq_align', 'rnaseq_quant', 'rnaseq_sample_qc', 'rnaseq_de',
|
|
'rnaseq_go_enrich', 'rnaseq_kegg_enrich', 'rnaseq_fgsea', 'rnaseq_wgcna',
|
|
}
|
|
METHODOLOGY_COMPLETION_PARADIGM_VERSION = 'methodology_evidence_completion.v2'
|
|
COMMON_PLANT_SPECIES_ALIASES = {
|
|
'A. thaliana': 'Arabidopsis thaliana',
|
|
'B. vulgaris': 'Beta vulgaris',
|
|
'G. max': 'Glycine max',
|
|
'O. sativa': 'Oryza sativa',
|
|
'S. lycopersicum': 'Solanum lycopersicum',
|
|
'V. vinifera': 'Vitis vinifera',
|
|
'Z. mays': 'Zea mays',
|
|
}
|
|
COMMON_SPECIES_NAME_ALIASES = {
|
|
'Fagopyrum tataricum': ['tartary buckwheat'],
|
|
'Arabidopsis thaliana': ['arabidopsis'],
|
|
'Oryza sativa': ['rice'],
|
|
}
|
|
METHODOLOGY_STAGE_ALIASES = {
|
|
'gene_family_identification': 'identify',
|
|
'identification': 'identify',
|
|
'identify': 'identify',
|
|
'phylogenetic_analysis': 'phylogeny',
|
|
'phylogeny': 'phylogeny',
|
|
'motif_analysis': 'motif',
|
|
'motif': 'motif',
|
|
'domain_analysis': 'domain',
|
|
'conserved_domain': 'domain',
|
|
'domain': 'domain',
|
|
'gene_structure': 'structure',
|
|
'intron_exon_structure': 'structure',
|
|
'structure': 'structure',
|
|
'promoter_analysis': 'promoter',
|
|
'cis_element': 'promoter',
|
|
'synteny': 'synteny',
|
|
'collinearity': 'synteny',
|
|
'chromosome_location': 'karyotype',
|
|
'chromosomal_location': 'karyotype',
|
|
'karyotype': 'karyotype',
|
|
'expression_analysis': 'expression',
|
|
'expression': 'expression',
|
|
'protein_properties': 'pep',
|
|
'pep': 'pep',
|
|
'ppi': 'ppi',
|
|
}
|
|
ACCESSION_PATTERN = re.compile(r'\b(?:GSE|GSM|SRP|SRR|SRX|SRS|PRJNA|PRJEB|ERP|ERR|ERX|ERS|DRP|DRR|DRX|DRS)\d+\b', re.I)
|
|
REFERENCE_DB_PATTERN = re.compile(r'\b(?:UniProt|TAIR|Pfam|InterPro|PlantTFDB|Ensembl|Phytozome)\b', re.I)
|
|
FIGURE_TABLE_PATTERN = re.compile(r'\b((?:Supplementary\s+)?(?:Fig(?:ure)?|Table)\.?\s+[A-Za-z]?\d+[A-Za-z0-9.-]*)\s*[:. ]\s*([^.\n]{0,220})', re.I)
|
|
ACCESSION_CONTEXT_PATTERN = re.compile(r'(GEO|SRA|BioProject|ENA|NCBI|RNA[- ]?seq|transcriptome|expression|raw\s+data|downloaded|deposited|accession)', re.I)
|
|
RAW_RNASEQ_CONTEXT_PATTERN = re.compile(r'(RNA[- ]?seq|raw\s+data|SRA|BioProject|transcriptome|sequencing)', re.I)
|
|
EXPRESSION_MATRIX_CONTEXT_PATTERN = re.compile(r'(expression\s+matrix|FPKM|TPM|RPKM|counts?\s+matrix|normalized\s+expression)', re.I)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class EvidenceCandidate:
|
|
object_type: str
|
|
value: str
|
|
role: str = ''
|
|
target_field: str = ''
|
|
source_file: str = ''
|
|
source_field: str = ''
|
|
evidence_text: str = ''
|
|
confidence_rule: str = ''
|
|
guardrail_status: str = 'accepted'
|
|
|
|
def to_record(self) -> dict[str, Any]:
|
|
return asdict(self)
|
|
|
|
|
|
def load_methodology_plan(path: str | Path) -> dict[str, Any]:
|
|
plan_path = Path(path)
|
|
if not plan_path.exists():
|
|
raise FileNotFoundError(f'Methodology plan not found: {plan_path}')
|
|
data = json.loads(plan_path.read_text(encoding='utf-8'))
|
|
if not isinstance(data, dict):
|
|
raise ValueError(f'Methodology plan must be a JSON object: {plan_path}')
|
|
return data
|
|
|
|
|
|
def bound_methodology_plan_path(input_root: Path, job_id: str) -> Path:
|
|
return Path(input_root) / job_id / 'methodology_plan.json'
|
|
|
|
|
|
def bind_methodology_plan(
|
|
plan: dict[str, Any],
|
|
*,
|
|
job_id: str,
|
|
input_root: Path,
|
|
workspace_root: Path,
|
|
source: str = '',
|
|
) -> dict[str, Any]:
|
|
if not job_id:
|
|
raise ValueError('job_id is required')
|
|
input_dir = Path(input_root) / job_id
|
|
final_dir = Path(workspace_root) / job_id / 'final'
|
|
input_dir.mkdir(parents=True, exist_ok=True)
|
|
final_dir.mkdir(parents=True, exist_ok=True)
|
|
plan = complete_methodology_plan_for_execution(plan, source=source)
|
|
plan_path = input_dir / 'methodology_plan.json'
|
|
plan_path.write_text(json.dumps(plan, ensure_ascii=False, indent=2), encoding='utf-8')
|
|
binding = {
|
|
'job_id': job_id,
|
|
'plan_id': plan.get('plan_id', ''),
|
|
'paper_id': plan.get('paper_id', ''),
|
|
'gene_family': plan.get('gene_family', ''),
|
|
'source': source,
|
|
'plan_path': str(plan_path),
|
|
'bound_at': datetime.now().isoformat(timespec='seconds'),
|
|
'schema_version': plan.get('schema_version', ''),
|
|
}
|
|
binding_path = final_dir / 'methodology_plan.binding.json'
|
|
binding_path.write_text(json.dumps(binding, ensure_ascii=False, indent=2), encoding='utf-8')
|
|
return {'success': True, 'job_id': job_id, 'plan_path': str(plan_path), 'binding_path': str(binding_path), 'binding': binding}
|
|
|
|
|
|
def bind_methodology_plan_from_path(
|
|
source_path: str | Path,
|
|
*,
|
|
job_id: str,
|
|
input_root: Path,
|
|
workspace_root: Path,
|
|
) -> dict[str, Any]:
|
|
plan = load_methodology_plan(source_path)
|
|
result = bind_methodology_plan(plan, job_id=job_id, input_root=input_root, workspace_root=workspace_root, source=str(source_path))
|
|
return result
|
|
|
|
|
|
def methodology_agent_params(plan: dict[str, Any]) -> dict[str, Any]:
|
|
plan = complete_methodology_plan_for_execution(plan)
|
|
prefill = plan.get('bbav5_prefill') if isinstance(plan.get('bbav5_prefill'), dict) else {}
|
|
route_stages = []
|
|
for route in plan.get('execution_routes') or []:
|
|
if isinstance(route, dict) and isinstance(route.get('stage_order'), list):
|
|
route_stages = [str(item) for item in route.get('stage_order') if str(item)]
|
|
break
|
|
stages = _fixed_methodology_stage_order(route_stages)
|
|
gene_family = prefill.get('geneFamily') or plan.get('gene_family') or _infer_gene_family_from_plan(plan)
|
|
params = {
|
|
'source': 'methodology',
|
|
'methodology': True,
|
|
'methodologyPlanId': plan.get('plan_id', ''),
|
|
'sourcePaperId': plan.get('paper_id', ''),
|
|
'geneFamily': gene_family,
|
|
'targetSpecies': _list(prefill.get('targetSpecies')),
|
|
'referenceSpecies': _list(prefill.get('referenceSpecies')),
|
|
'comparativeSpecies': _list(prefill.get('comparativeSpecies')),
|
|
'identifyBlastReferenceSpecies': _list(prefill.get('identifyBlastReferenceSpecies')),
|
|
'primaryReferenceSpecies': _list(prefill.get('primaryReferenceSpecies')),
|
|
'requiredPfams': prefill.get('requiredPfams') or _infer_required_pfams_from_plan(plan),
|
|
'scopeMode': prefill.get('scopeMode') or _scope_mode(prefill),
|
|
'requestedMethodologyStages': stages,
|
|
}
|
|
params['refSpeciesList'] = _unique([*params['referenceSpecies'], *params['comparativeSpecies']])
|
|
params['methodology_workflow_contract_version'] = 'methodology_fixed_workflow.v1'
|
|
params['requiresDataPreparation'] = True
|
|
params['requiresSubfamilyClassification'] = True
|
|
if params['targetSpecies']:
|
|
first = params['targetSpecies'][0]
|
|
family = params['geneFamily']
|
|
if first and family:
|
|
params['prefix'] = f'{first}.{family}' if len(params['targetSpecies']) == 1 else f'Target.{family}'
|
|
return params
|
|
|
|
|
|
def _fixed_methodology_stage_order(route_stages: list[str]) -> list[str]:
|
|
route_set = {stage for stage in route_stages if stage and stage != 'external_or_manual'}
|
|
ordered = []
|
|
for stage in FIXED_METHODOLOGY_STAGE_ORDER:
|
|
if stage in route_set:
|
|
ordered.append(stage)
|
|
return _unique(ordered)
|
|
|
|
|
|
def complete_methodology_plan_for_execution(plan: dict[str, Any], *, source: str = '') -> dict[str, Any]:
|
|
"""Complete a methodology plan through one evidence-to-execution paradigm."""
|
|
if not isinstance(plan, dict):
|
|
return plan
|
|
original = copy.deepcopy(plan)
|
|
candidates = _collect_methodology_evidence_candidates(plan, source=source)
|
|
completed = complete_methodology_plan_species(plan, source=source)
|
|
completed = _complete_methodology_required_pfams(completed)
|
|
completed = _complete_methodology_stage_order(completed)
|
|
completed = _complete_methodology_dataset_gates(completed, candidates)
|
|
completed = _complete_methodology_expression_requirements(completed, candidates)
|
|
completed = _complete_methodology_reproduction_targets(completed, candidates)
|
|
_attach_methodology_completion_audit(completed, original, candidates, source=source)
|
|
return completed
|
|
|
|
|
|
def _collect_methodology_evidence_candidates(plan: dict[str, Any], *, source: str = '') -> list[EvidenceCandidate]:
|
|
candidates: list[EvidenceCandidate] = []
|
|
candidates.extend(_candidate_records_from_plan(plan))
|
|
for batch_dir in _methodology_batch_dirs(plan, source=source):
|
|
candidates.extend(_candidate_records_from_batch_dir(batch_dir))
|
|
return _unique_candidates(candidates)
|
|
|
|
|
|
def _candidate_records_from_plan(plan: dict[str, Any]) -> list[EvidenceCandidate]:
|
|
candidates: list[EvidenceCandidate] = []
|
|
for record in _species_records_from_prefill(plan):
|
|
candidates.append(EvidenceCandidate(
|
|
object_type='species',
|
|
value=_canonical_latin_species(record.get('species_name')),
|
|
role=_normalize_species_role(record.get('species_role')),
|
|
target_field='bbav5_prefill',
|
|
source_field='bbav5_prefill',
|
|
confidence_rule='prefill_species',
|
|
))
|
|
for record in _species_records_from_requirements(plan.get('data_requirements')):
|
|
candidates.append(EvidenceCandidate(
|
|
object_type='species',
|
|
value=_canonical_latin_species(record.get('species_name')),
|
|
role=_normalize_species_role(record.get('species_role')),
|
|
target_field='data_requirements',
|
|
source_field='data_requirements',
|
|
confidence_rule='existing_requirement_species',
|
|
))
|
|
for record in _species_records_from_plan_field(plan.get('species_summary')):
|
|
candidates.append(EvidenceCandidate(
|
|
object_type='species',
|
|
value=_canonical_latin_species(record.get('species_name')),
|
|
role=_normalize_species_role(record.get('species_role')),
|
|
target_field='bbav5_prefill',
|
|
source_field='species_summary',
|
|
confidence_rule='structured_species_summary',
|
|
))
|
|
|
|
text = _plan_species_text(plan)
|
|
candidates.extend(_text_evidence_candidates(text, source_field='plan_text'))
|
|
candidates.extend(_candidate_stage_records_from_plan_value(plan))
|
|
return [candidate for candidate in candidates if candidate.value]
|
|
|
|
|
|
def _candidate_stage_records_from_plan_value(value: Any, *, source_field: str = 'plan') -> list[EvidenceCandidate]:
|
|
candidates: list[EvidenceCandidate] = []
|
|
|
|
def walk(item: Any, field: str = source_field, context: str = '') -> None:
|
|
if isinstance(item, dict):
|
|
text = _row_text(item)
|
|
for key, child in item.items():
|
|
if key in {'bbav5_stage', 'mapped_bbav5_stage', 'bbav5_stage_id', 'normalized_stage', 'stage'}:
|
|
stage = _normalize_methodology_stage(child)
|
|
if stage:
|
|
candidates.append(EvidenceCandidate(
|
|
object_type='stage',
|
|
value=stage,
|
|
target_field='execution_routes.stage_order',
|
|
source_field=key,
|
|
evidence_text=(text or context)[:500],
|
|
confidence_rule='structured_stage_field',
|
|
))
|
|
walk(child, str(key), text or context)
|
|
elif isinstance(item, list):
|
|
for child in item:
|
|
walk(child, field, context)
|
|
|
|
walk(value)
|
|
return candidates
|
|
|
|
|
|
def _candidate_records_from_batch_dir(batch_dir: Path) -> list[EvidenceCandidate]:
|
|
candidates: list[EvidenceCandidate] = []
|
|
for csv_name in ('species_summary.csv', 'review_template.csv', 'summary.csv', 'methodology_parameter_audit.csv'):
|
|
path = batch_dir / csv_name
|
|
if not path.exists():
|
|
continue
|
|
try:
|
|
with path.open('r', encoding='utf-8', newline='') as handle:
|
|
reader = csv.DictReader(handle)
|
|
for row in reader:
|
|
candidates.extend(_candidate_records_from_row(row, source_file=str(path), source_field=csv_name))
|
|
except Exception:
|
|
continue
|
|
for json_name in ('observations.json', 'alignment.json'):
|
|
path = batch_dir / json_name
|
|
if not path.exists():
|
|
continue
|
|
try:
|
|
data = json.loads(path.read_text(encoding='utf-8'))
|
|
except Exception:
|
|
continue
|
|
candidates.extend(_candidate_records_from_json_value(data, source_file=str(path)))
|
|
return candidates
|
|
|
|
|
|
def _candidate_records_from_row(row: dict[str, Any], *, source_file: str, source_field: str) -> list[EvidenceCandidate]:
|
|
text = _row_text(row)
|
|
candidates = _text_evidence_candidates(text, source_file=source_file, source_field=source_field)
|
|
species = row.get('species_name') or row.get('species')
|
|
if species:
|
|
candidates.append(EvidenceCandidate(
|
|
object_type='species',
|
|
value=_canonical_latin_species(species),
|
|
role=_normalize_species_role(row.get('species_role') or row.get('role')),
|
|
target_field='bbav5_prefill',
|
|
source_file=source_file,
|
|
source_field='species_name',
|
|
evidence_text=str(row.get('evidence_sentence') or text)[:500],
|
|
confidence_rule='structured_species_summary',
|
|
))
|
|
for key in ('bbav5_stage', 'mapped_bbav5_stage', 'bbav5_stage_id', 'normalized_stage', 'stage'):
|
|
stage = _normalize_methodology_stage(row.get(key))
|
|
if stage:
|
|
candidates.append(EvidenceCandidate(
|
|
object_type='stage',
|
|
value=stage,
|
|
target_field='execution_routes.stage_order',
|
|
source_file=source_file,
|
|
source_field=key,
|
|
evidence_text=text[:500],
|
|
confidence_rule='structured_stage_field',
|
|
))
|
|
return candidates
|
|
|
|
|
|
def _candidate_records_from_json_value(value: Any, *, source_file: str) -> list[EvidenceCandidate]:
|
|
candidates: list[EvidenceCandidate] = []
|
|
|
|
def walk(item: Any, source_field: str = '') -> None:
|
|
if isinstance(item, dict):
|
|
text = _row_text(item)
|
|
candidates.extend(_text_evidence_candidates(text, source_file=source_file, source_field=source_field or 'json_object'))
|
|
species = item.get('species_name') or item.get('species')
|
|
if species:
|
|
candidates.append(EvidenceCandidate(
|
|
object_type='species',
|
|
value=_canonical_latin_species(species),
|
|
role=_normalize_species_role(item.get('species_role') or item.get('role')),
|
|
target_field='bbav5_prefill',
|
|
source_file=source_file,
|
|
source_field='species_name',
|
|
evidence_text=str(item.get('evidence_sentence') or text)[:500],
|
|
confidence_rule='structured_json_species',
|
|
))
|
|
for key, child in item.items():
|
|
if key in {'bbav5_stage', 'mapped_bbav5_stage', 'bbav5_stage_id', 'normalized_stage', 'stage'}:
|
|
stage = _normalize_methodology_stage(child)
|
|
if stage:
|
|
candidates.append(EvidenceCandidate(
|
|
object_type='stage',
|
|
value=stage,
|
|
target_field='execution_routes.stage_order',
|
|
source_file=source_file,
|
|
source_field=key,
|
|
evidence_text=text[:500],
|
|
confidence_rule='structured_stage_field',
|
|
))
|
|
walk(child, str(key))
|
|
elif isinstance(item, list):
|
|
for child in item:
|
|
walk(child, source_field)
|
|
|
|
walk(value)
|
|
return candidates
|
|
|
|
|
|
def _text_evidence_candidates(text: str, *, source_file: str = '', source_field: str = '') -> list[EvidenceCandidate]:
|
|
value = str(text or '')
|
|
if not value:
|
|
return []
|
|
candidates: list[EvidenceCandidate] = []
|
|
for record in _species_records_from_text(value, default_role='reference_species'):
|
|
candidates.append(EvidenceCandidate(
|
|
object_type='species',
|
|
value=_canonical_latin_species(record.get('species_name')),
|
|
role='reference',
|
|
target_field='bbav5_prefill.referenceSpecies',
|
|
source_file=source_file,
|
|
source_field=source_field,
|
|
evidence_text=_evidence_excerpt(value, record.get('species_name')),
|
|
confidence_rule='alias_or_full_name_in_method_evidence',
|
|
))
|
|
for pfam in re.findall(r'PF\d{5}', value):
|
|
candidates.append(EvidenceCandidate(
|
|
object_type='pfam',
|
|
value=pfam,
|
|
target_field='bbav5_prefill.requiredPfams',
|
|
source_file=source_file,
|
|
source_field=source_field,
|
|
evidence_text=_evidence_excerpt(value, pfam),
|
|
confidence_rule='pfam_evidence_completion',
|
|
))
|
|
for accession in ACCESSION_PATTERN.findall(value):
|
|
excerpt = _sentence_excerpt(value, accession)
|
|
accession_role = _classify_accession_context(excerpt)
|
|
guardrail = 'accepted' if accession_role in {'raw_rnaseq_accession', 'expression_matrix_accession'} else accession_role
|
|
target_field = 'manual_gates' if accession_role == 'raw_rnaseq_accession' else 'data_requirements' if accession_role == 'expression_matrix_accession' else 'methodology_completion_audit'
|
|
candidates.append(EvidenceCandidate(
|
|
object_type='dataset_accession',
|
|
value=accession.upper(),
|
|
role=accession_role,
|
|
target_field=target_field,
|
|
source_file=source_file,
|
|
source_field=source_field,
|
|
evidence_text=excerpt,
|
|
confidence_rule='accession_with_method_context',
|
|
guardrail_status=guardrail,
|
|
))
|
|
for match in REFERENCE_DB_PATTERN.finditer(value):
|
|
db_name = match.group(0)
|
|
candidates.append(EvidenceCandidate(
|
|
object_type='reference_database',
|
|
value=db_name,
|
|
role='reference_database_accession',
|
|
target_field='methodology_completion_audit',
|
|
source_file=source_file,
|
|
source_field=source_field,
|
|
evidence_text=_sentence_excerpt(value, db_name),
|
|
confidence_rule='reference_database_source',
|
|
guardrail_status='audit_only_reference_database',
|
|
))
|
|
for label, title in FIGURE_TABLE_PATTERN.findall(value):
|
|
target_type = 'table' if re.search(r'Table', label, re.I) else 'figure'
|
|
candidates.append(EvidenceCandidate(
|
|
object_type='figure_table_target',
|
|
value=_normalize_figure_table_label(label),
|
|
role=target_type,
|
|
target_field='reproduction_targets',
|
|
source_file=source_file,
|
|
source_field=source_field,
|
|
evidence_text=(title or '').strip()[:500],
|
|
confidence_rule='caption_or_figure_table_label',
|
|
))
|
|
return candidates
|
|
|
|
|
|
def _classify_accession_context(excerpt: str) -> str:
|
|
value = str(excerpt or '')
|
|
if EXPRESSION_MATRIX_CONTEXT_PATTERN.search(value):
|
|
return 'expression_matrix_accession'
|
|
if RAW_RNASEQ_CONTEXT_PATTERN.search(value):
|
|
return 'raw_rnaseq_accession'
|
|
if ACCESSION_CONTEXT_PATTERN.search(value):
|
|
return 'rejected_ambiguous_accession_context'
|
|
return 'rejected_no_method_data_context'
|
|
|
|
|
|
def _row_text(row: dict[str, Any]) -> str:
|
|
preferred = []
|
|
for key in ('evidence_sentence', 'raw_method_text', 'caption', 'title', 'input_data', 'output_data', 'parameters'):
|
|
value = row.get(key)
|
|
if value:
|
|
preferred.append(str(value))
|
|
if preferred:
|
|
return '\n'.join(preferred)
|
|
return '\n'.join(str(value) for value in row.values() if value)
|
|
|
|
|
|
def complete_methodology_plan_species(plan: dict[str, Any], *, source: str = '') -> dict[str, Any]:
|
|
"""Fill executable species lists from reviewed methodology evidence.
|
|
|
|
This is intentionally a bridge-side safety net. It only promotes species
|
|
that appear in structured species_summary rows or in method/figure evidence,
|
|
and it ignores species that occur only in abbreviation-definition clauses.
|
|
"""
|
|
if not isinstance(plan, dict):
|
|
return plan
|
|
completed = copy.deepcopy(plan)
|
|
records = _methodology_species_records(completed, source=source)
|
|
if not records:
|
|
return completed
|
|
|
|
role_groups = _resolve_methodology_species_role_groups(completed, records, source=source)
|
|
prefill = completed.get('bbav5_prefill')
|
|
if not isinstance(prefill, dict):
|
|
prefill = {}
|
|
completed['bbav5_prefill'] = prefill
|
|
|
|
targets = role_groups['targets']
|
|
references = role_groups['references']
|
|
comparatives = role_groups['comparatives']
|
|
target_keys = {_species_key(item) for item in targets}
|
|
reference_keys = {_species_key(item) for item in references}
|
|
comparative_keys = {_species_key(item) for item in comparatives}
|
|
|
|
prefill['targetSpecies'] = targets
|
|
prefill['referenceSpecies'] = references
|
|
prefill['comparativeSpecies'] = comparatives
|
|
if references and not _list(prefill.get('primaryReferenceSpecies')):
|
|
prefill['primaryReferenceSpecies'] = [references[0]]
|
|
if references and not _list(prefill.get('identifyBlastReferenceSpecies')):
|
|
prefill['identifyBlastReferenceSpecies'] = [references[0]]
|
|
prefill['scopeMode'] = prefill.get('scopeMode') or _scope_mode(prefill)
|
|
|
|
completed['data_requirements'] = _complete_species_requirements(
|
|
completed.get('data_requirements'),
|
|
targets=targets,
|
|
references=references,
|
|
comparatives=comparatives,
|
|
)
|
|
return completed
|
|
|
|
|
|
def _complete_methodology_required_pfams(plan: dict[str, Any]) -> dict[str, Any]:
|
|
completed = copy.deepcopy(plan)
|
|
prefill = completed.get('bbav5_prefill')
|
|
if not isinstance(prefill, dict):
|
|
prefill = {}
|
|
completed['bbav5_prefill'] = prefill
|
|
if not str(prefill.get('requiredPfams') or '').strip():
|
|
inferred = _infer_required_pfams_from_plan(completed) or _infer_required_pfams_from_batch_dirs(completed)
|
|
if inferred:
|
|
prefill['requiredPfams'] = inferred
|
|
return completed
|
|
|
|
|
|
def _complete_methodology_stage_order(plan: dict[str, Any]) -> dict[str, Any]:
|
|
completed = copy.deepcopy(plan)
|
|
inferred = _infer_stage_order_from_plan(completed)
|
|
if not inferred:
|
|
return completed
|
|
routes = completed.get('execution_routes')
|
|
if not isinstance(routes, list) or not routes:
|
|
completed['execution_routes'] = [{'route_id': 'methodology_default', 'stage_order': inferred}]
|
|
return completed
|
|
for route in routes:
|
|
if not isinstance(route, dict):
|
|
continue
|
|
current = route.get('stage_order') if isinstance(route.get('stage_order'), list) else []
|
|
route['stage_order'] = _fixed_methodology_stage_order([*current, *inferred])
|
|
return completed
|
|
completed['execution_routes'].append({'route_id': 'methodology_default', 'stage_order': inferred})
|
|
return completed
|
|
|
|
|
|
def _complete_methodology_dataset_gates(plan: dict[str, Any], candidates: list[EvidenceCandidate]) -> dict[str, Any]:
|
|
completed = copy.deepcopy(plan)
|
|
gates = [copy.deepcopy(gate) for gate in completed.get('manual_gates') or [] if isinstance(gate, dict)]
|
|
seen = {str(gate.get('accession') or gate.get('value') or '').upper() for gate in gates}
|
|
for candidate in candidates:
|
|
if candidate.object_type != 'dataset_accession' or candidate.guardrail_status != 'accepted' or candidate.role != 'raw_rnaseq_accession':
|
|
continue
|
|
if candidate.value in seen:
|
|
continue
|
|
gates.append({
|
|
'gate_type': 'dataset_accession',
|
|
'accession': candidate.value,
|
|
'dataset_kind': candidate.role,
|
|
'status': 'requires_user_data_or_download_confirmation',
|
|
'source_file': candidate.source_file,
|
|
'source_field': candidate.source_field,
|
|
'evidence_text': candidate.evidence_text,
|
|
'reason': 'methodology_mentions_external_dataset_accession',
|
|
})
|
|
seen.add(candidate.value)
|
|
if gates:
|
|
completed['manual_gates'] = gates
|
|
return completed
|
|
|
|
|
|
def _complete_methodology_expression_requirements(plan: dict[str, Any], candidates: list[EvidenceCandidate]) -> dict[str, Any]:
|
|
completed = copy.deepcopy(plan)
|
|
requirements = [copy.deepcopy(req) for req in completed.get('data_requirements') or [] if isinstance(req, dict)]
|
|
seen = {
|
|
(str(req.get('requirement_type') or ''), str(req.get('accession') or req.get('value') or '').upper())
|
|
for req in requirements
|
|
}
|
|
for candidate in candidates:
|
|
if candidate.object_type != 'dataset_accession' or candidate.guardrail_status != 'accepted' or candidate.role != 'expression_matrix_accession':
|
|
continue
|
|
key = ('expression_matrix', candidate.value)
|
|
if key in seen:
|
|
continue
|
|
requirements.append({
|
|
'requirement_type': 'expression_matrix',
|
|
'accession': candidate.value,
|
|
'bbav5_role': 'expression_data',
|
|
'required_files': ['expression_matrix'],
|
|
'source': 'methodology_expression_accession_completion',
|
|
'source_file': candidate.source_file,
|
|
'source_field': candidate.source_field,
|
|
'evidence_text': candidate.evidence_text,
|
|
})
|
|
seen.add(key)
|
|
if requirements:
|
|
completed['data_requirements'] = requirements
|
|
return completed
|
|
|
|
|
|
def _complete_methodology_reproduction_targets(plan: dict[str, Any], candidates: list[EvidenceCandidate]) -> dict[str, Any]:
|
|
completed = copy.deepcopy(plan)
|
|
targets = [copy.deepcopy(target) for target in completed.get('reproduction_targets') or [] if isinstance(target, dict)]
|
|
seen = {_normalize_figure_table_label(target.get('label') or target.get('value')).lower() for target in targets}
|
|
for candidate in candidates:
|
|
if candidate.object_type != 'figure_table_target' or candidate.guardrail_status != 'accepted':
|
|
continue
|
|
key = _normalize_figure_table_label(candidate.value).lower()
|
|
if not key or key in seen:
|
|
continue
|
|
targets.append({
|
|
'target_type': candidate.role or 'figure_table',
|
|
'label': candidate.value,
|
|
'title': candidate.evidence_text,
|
|
**_figure_table_coverage(candidate, plan),
|
|
'source_file': candidate.source_file,
|
|
'source_field': candidate.source_field,
|
|
'status': 'planning_target_only',
|
|
'reason': 'figure_or_table_caption_detected',
|
|
})
|
|
seen.add(key)
|
|
if targets:
|
|
completed['reproduction_targets'] = targets
|
|
return completed
|
|
|
|
|
|
def _figure_table_coverage(candidate: EvidenceCandidate, plan: dict[str, Any]) -> dict[str, str]:
|
|
suggested = _infer_stage_from_figure_table_text(f'{candidate.value} {candidate.evidence_text}')
|
|
stages = set(_first_route_stages(plan) or _infer_stage_order_from_plan(plan))
|
|
if suggested and suggested in stages:
|
|
return {
|
|
'suggested_stage': suggested,
|
|
'coverage_status': 'covered_by_stage',
|
|
'coverage_reason': 'caption_keywords_match_existing_methodology_stage',
|
|
}
|
|
if suggested:
|
|
return {
|
|
'suggested_stage': suggested,
|
|
'coverage_status': 'needs_method_inference_or_default_tool',
|
|
'coverage_reason': 'caption_suggests_stage_without_methodology_stage_evidence',
|
|
}
|
|
return {
|
|
'suggested_stage': '',
|
|
'coverage_status': 'unclassified_target',
|
|
'coverage_reason': 'caption_has_no_supported_stage_keyword',
|
|
}
|
|
|
|
|
|
def _infer_stage_from_figure_table_text(text: str) -> str:
|
|
value = str(text or '').lower()
|
|
keyword_map = [
|
|
('expression', 'expression'),
|
|
('heatmap', 'expression'),
|
|
('phylogen', 'phylogeny'),
|
|
('tree', 'phylogeny'),
|
|
('motif', 'motif'),
|
|
('domain', 'domain'),
|
|
('structure', 'structure'),
|
|
('intron', 'structure'),
|
|
('exon', 'structure'),
|
|
('promoter', 'promoter'),
|
|
('cis', 'promoter'),
|
|
('synten', 'synteny'),
|
|
('collinear', 'synteny'),
|
|
('chromosome', 'karyotype'),
|
|
('location', 'karyotype'),
|
|
('ppi', 'ppi'),
|
|
('protein interaction', 'ppi'),
|
|
]
|
|
for keyword, stage in keyword_map:
|
|
if keyword in value:
|
|
return stage
|
|
return ''
|
|
|
|
|
|
def _infer_stage_order_from_plan(plan: dict[str, Any]) -> list[str]:
|
|
stages: list[str] = []
|
|
|
|
def add(value: Any) -> None:
|
|
stage = _normalize_methodology_stage(value)
|
|
if stage:
|
|
stages.append(stage)
|
|
|
|
def walk(value: Any, key: str = '') -> None:
|
|
if isinstance(value, dict):
|
|
for child_key, child_value in value.items():
|
|
if child_key in {'bbav5_stage_id', 'bbav5_stage', 'mapped_bbav5_stage', 'stage', 'normalized_stage'}:
|
|
add(child_value)
|
|
walk(child_value, str(child_key))
|
|
elif isinstance(value, list):
|
|
for item in value:
|
|
walk(item, key)
|
|
|
|
for section in ('stages', 'method_components', 'evidence', 'observations', 'species_summary'):
|
|
walk(plan.get(section), section)
|
|
stages.extend(_infer_stage_order_from_batch_dirs(plan))
|
|
return _fixed_methodology_stage_order(_unique(stages))
|
|
|
|
|
|
def _infer_stage_order_from_batch_dirs(plan: dict[str, Any]) -> list[str]:
|
|
stages: list[str] = []
|
|
for batch_dir in _methodology_batch_dirs(plan):
|
|
for csv_name in ('review_template.csv', 'species_summary.csv', 'summary.csv'):
|
|
path = batch_dir / csv_name
|
|
if not path.exists():
|
|
continue
|
|
try:
|
|
with path.open('r', encoding='utf-8', newline='') as handle:
|
|
reader = csv.DictReader(handle)
|
|
for row in reader:
|
|
for key in ('bbav5_stage', 'mapped_bbav5_stage', 'bbav5_stage_id', 'normalized_stage', 'stage'):
|
|
stage = _normalize_methodology_stage(row.get(key))
|
|
if stage:
|
|
stages.append(stage)
|
|
except Exception:
|
|
continue
|
|
for json_name in ('observations.json', 'alignment.json'):
|
|
path = batch_dir / json_name
|
|
if not path.exists():
|
|
continue
|
|
try:
|
|
data = json.loads(path.read_text(encoding='utf-8'))
|
|
except Exception:
|
|
continue
|
|
stages.extend(_infer_stage_order_from_json_value(data))
|
|
return _fixed_methodology_stage_order(_unique(stages))
|
|
|
|
|
|
def _infer_stage_order_from_json_value(value: Any) -> list[str]:
|
|
stages: list[str] = []
|
|
|
|
def walk(item: Any) -> None:
|
|
if isinstance(item, dict):
|
|
for key, child in item.items():
|
|
if key in {'bbav5_stage', 'mapped_bbav5_stage', 'bbav5_stage_id', 'normalized_stage', 'stage'}:
|
|
stage = _normalize_methodology_stage(child)
|
|
if stage:
|
|
stages.append(stage)
|
|
walk(child)
|
|
elif isinstance(item, list):
|
|
for child in item:
|
|
walk(child)
|
|
|
|
walk(value)
|
|
return _fixed_methodology_stage_order(_unique(stages))
|
|
|
|
|
|
def _infer_required_pfams_from_batch_dirs(plan: dict[str, Any]) -> str:
|
|
pfams: list[str] = []
|
|
for batch_dir in _methodology_batch_dirs(plan):
|
|
for name in ('review_template.csv', 'observations.json', 'alignment.json', 'methodology_parameter_audit.csv'):
|
|
path = batch_dir / name
|
|
if not path.exists() or not path.is_file():
|
|
continue
|
|
try:
|
|
text = path.read_text(encoding='utf-8', errors='ignore')
|
|
except Exception:
|
|
continue
|
|
for hit in re.findall(r'PF\d{5}', text):
|
|
if hit not in pfams:
|
|
pfams.append(hit)
|
|
return ','.join(pfams)
|
|
|
|
|
|
def _normalize_methodology_stage(value: Any) -> str:
|
|
token = re.sub(r'[^a-z0-9]+', '_', str(value or '').strip().lower()).strip('_')
|
|
if not token:
|
|
return ''
|
|
stage = METHODOLOGY_STAGE_ALIASES.get(token) or token
|
|
return stage if stage in FIXED_METHODOLOGY_STAGE_ORDER else ''
|
|
|
|
|
|
def _attach_methodology_completion_audit(
|
|
completed: dict[str, Any],
|
|
original: dict[str, Any],
|
|
candidates: list[EvidenceCandidate],
|
|
*,
|
|
source: str = '',
|
|
) -> None:
|
|
if not isinstance(completed, dict):
|
|
return
|
|
before_prefill = original.get('bbav5_prefill') if isinstance(original.get('bbav5_prefill'), dict) else {}
|
|
after_prefill = completed.get('bbav5_prefill') if isinstance(completed.get('bbav5_prefill'), dict) else {}
|
|
previous_audit = original.get('methodology_completion_audit') if isinstance(original.get('methodology_completion_audit'), dict) else {}
|
|
previous_records = [copy.deepcopy(record) for record in previous_audit.get('records') or [] if isinstance(record, dict)]
|
|
new_records: list[dict[str, Any]] = []
|
|
|
|
def added_list(field: str, rule: str) -> None:
|
|
before = {_species_key(item) for item in _list(before_prefill.get(field))}
|
|
for item in _list(after_prefill.get(field)):
|
|
if _species_key(item) not in before:
|
|
new_records.append(_audit_record('species', item, field, rule, candidates))
|
|
|
|
added_list('targetSpecies', 'role_aware_species_completion')
|
|
added_list('referenceSpecies', 'role_aware_species_completion')
|
|
added_list('comparativeSpecies', 'role_aware_species_completion')
|
|
if str(after_prefill.get('requiredPfams') or '').strip() and not str(before_prefill.get('requiredPfams') or '').strip():
|
|
new_records.append(_audit_record('domain_requirement', after_prefill.get('requiredPfams'), 'requiredPfams', 'pfam_evidence_completion', candidates))
|
|
|
|
before_stages = _first_route_stages(original)
|
|
for stage in _first_route_stages(completed):
|
|
if stage not in before_stages:
|
|
new_records.append(_audit_record('stage', stage, 'execution_routes.stage_order', 'stage_evidence_completion', candidates))
|
|
|
|
before_gates = {str(gate.get('accession') or '').upper() for gate in original.get('manual_gates') or [] if isinstance(gate, dict)}
|
|
for gate in completed.get('manual_gates') or []:
|
|
if not isinstance(gate, dict):
|
|
continue
|
|
accession = str(gate.get('accession') or '').upper()
|
|
if accession and accession not in before_gates:
|
|
new_records.append(_audit_record('dataset_accession', accession, 'manual_gates', 'accession_manual_gate_completion', candidates))
|
|
|
|
before_expression = {
|
|
str(req.get('accession') or '').upper()
|
|
for req in original.get('data_requirements') or []
|
|
if isinstance(req, dict) and str(req.get('requirement_type') or '') == 'expression_matrix'
|
|
}
|
|
for req in completed.get('data_requirements') or []:
|
|
if not isinstance(req, dict) or str(req.get('requirement_type') or '') != 'expression_matrix':
|
|
continue
|
|
accession = str(req.get('accession') or '').upper()
|
|
if accession and accession not in before_expression:
|
|
new_records.append(_audit_record('dataset_accession', accession, 'data_requirements', 'expression_matrix_requirement_completion', candidates))
|
|
|
|
before_targets = {_normalize_figure_table_label(target.get('label') or '').lower() for target in original.get('reproduction_targets') or [] if isinstance(target, dict)}
|
|
for target in completed.get('reproduction_targets') or []:
|
|
if not isinstance(target, dict):
|
|
continue
|
|
label = str(target.get('label') or '')
|
|
if label and _normalize_figure_table_label(label).lower() not in before_targets:
|
|
new_records.append(_audit_record('figure_table_target', label, 'reproduction_targets', 'figure_table_target_completion', candidates))
|
|
|
|
records = _merge_audit_records(previous_records, new_records)
|
|
accepted_candidates = [candidate.to_record() for candidate in candidates if candidate.guardrail_status == 'accepted']
|
|
rejected_candidates = [candidate.to_record() for candidate in candidates if candidate.guardrail_status != 'accepted']
|
|
completion_run_count = int(previous_audit.get('completion_run_count') or 0) + 1
|
|
|
|
completed['methodology_completion_audit'] = {
|
|
'paradigm_version': METHODOLOGY_COMPLETION_PARADIGM_VERSION,
|
|
'source': source,
|
|
'principle': 'evidence_candidate_to_role_to_requirement_with_false_positive_guards',
|
|
'applied_rules': _unique([str(record['rule']) for record in records]),
|
|
'records': records,
|
|
'accepted_candidates': accepted_candidates,
|
|
'rejected_candidates': rejected_candidates,
|
|
'candidate_count': len(candidates),
|
|
'accepted_candidate_count': len(accepted_candidates),
|
|
'rejected_candidate_count': len(rejected_candidates),
|
|
'candidate_types': _unique([candidate.object_type for candidate in candidates]),
|
|
'records_preserved_from_previous_completion': len(previous_records),
|
|
'completion_run_count': completion_run_count,
|
|
'guardrails': [
|
|
'do_not_promote_abbreviation_definition_only_species',
|
|
'do_not_guess_latin_binomials_without_structured_or_alias_evidence',
|
|
'do_not_promote_accession_without_method_data_context',
|
|
'figure_table_targets_do_not_force_execution_stage_without_method_evidence',
|
|
'preserve_existing_user_or_rag_prefill_values',
|
|
],
|
|
}
|
|
|
|
|
|
def _merge_audit_records(previous: list[dict[str, Any]], new: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
result: list[dict[str, Any]] = []
|
|
seen: set[tuple[str, str, str, str]] = set()
|
|
for record in [*previous, *new]:
|
|
key = (
|
|
str(record.get('object_type') or ''),
|
|
str(record.get('target_field') or record.get('field') or ''),
|
|
_normalize_figure_table_label(record.get('value')) if str(record.get('object_type') or '') == 'figure_table_target' else str(record.get('value') or ''),
|
|
str(record.get('rule') or ''),
|
|
)
|
|
if key in seen:
|
|
continue
|
|
result.append(record)
|
|
seen.add(key)
|
|
return result
|
|
|
|
|
|
def _methodology_completion_summary(plan: dict[str, Any]) -> dict[str, Any]:
|
|
audit = plan.get('methodology_completion_audit') if isinstance(plan.get('methodology_completion_audit'), dict) else {}
|
|
targets = [target for target in plan.get('reproduction_targets') or [] if isinstance(target, dict)]
|
|
return {
|
|
'paradigm_version': audit.get('paradigm_version') or '',
|
|
'completed_record_count': len(audit.get('records') or []),
|
|
'accepted_candidate_count': int(audit.get('accepted_candidate_count') or 0),
|
|
'rejected_candidate_count': int(audit.get('rejected_candidate_count') or 0),
|
|
'manual_gate_count': len([gate for gate in plan.get('manual_gates') or [] if isinstance(gate, dict)]),
|
|
'data_requirement_count': len([req for req in plan.get('data_requirements') or [] if isinstance(req, dict)]),
|
|
'reproduction_target_count': len(targets),
|
|
'covered_reproduction_target_count': len([target for target in targets if target.get('coverage_status') == 'covered_by_stage']),
|
|
'completion_run_count': int(audit.get('completion_run_count') or 0),
|
|
}
|
|
|
|
|
|
def _audit_record(
|
|
object_type: str,
|
|
value: Any,
|
|
target_field: str,
|
|
rule: str,
|
|
candidates: list[EvidenceCandidate],
|
|
) -> dict[str, Any]:
|
|
value_text = str(value or '')
|
|
candidate = _best_candidate(object_type, value_text, candidates)
|
|
record = {
|
|
'object_type': object_type,
|
|
'field': target_field,
|
|
'target_field': target_field,
|
|
'value': value,
|
|
'rule': rule,
|
|
'source_file': candidate.source_file if candidate else '',
|
|
'source_field': candidate.source_field if candidate else '',
|
|
'evidence_text': candidate.evidence_text if candidate else '',
|
|
'confidence_rule': candidate.confidence_rule if candidate else rule,
|
|
'guardrail_status': candidate.guardrail_status if candidate else 'accepted',
|
|
}
|
|
return record
|
|
|
|
|
|
def _best_candidate(object_type: str, value: str, candidates: list[EvidenceCandidate]) -> EvidenceCandidate | None:
|
|
aliases = {
|
|
'domain_requirement': {'pfam', 'domain_requirement'},
|
|
'species': {'species'},
|
|
'stage': {'stage'},
|
|
'dataset_accession': {'dataset_accession'},
|
|
'figure_table_target': {'figure_table_target'},
|
|
}.get(object_type, {object_type})
|
|
normalized_value = _normalize_candidate_value(value)
|
|
for candidate in candidates:
|
|
if candidate.object_type not in aliases or candidate.guardrail_status != 'accepted':
|
|
continue
|
|
if candidate.object_type == 'pfam' and candidate.value and candidate.value in value:
|
|
return candidate
|
|
if _normalize_candidate_value(candidate.value) == normalized_value:
|
|
return candidate
|
|
for candidate in candidates:
|
|
if candidate.object_type in aliases and candidate.guardrail_status == 'accepted':
|
|
return candidate
|
|
return None
|
|
|
|
|
|
def _normalize_candidate_value(value: Any) -> str:
|
|
return re.sub(r'[^a-z0-9]+', '', str(value or '').lower())
|
|
|
|
|
|
def _normalize_figure_table_label(value: Any) -> str:
|
|
label = re.sub(r'\s+', ' ', str(value or '').strip()).rstrip('.')
|
|
label = re.sub(r'^(?:Fig)\.?\s+', 'Figure ', label, flags=re.I)
|
|
label = re.sub(r'^(?:Tab)\.?\s+', 'Table ', label, flags=re.I)
|
|
return label
|
|
|
|
|
|
def _first_route_stages(plan: dict[str, Any]) -> list[str]:
|
|
for route in plan.get('execution_routes') or []:
|
|
if isinstance(route, dict) and isinstance(route.get('stage_order'), list):
|
|
return [str(item) for item in route.get('stage_order') if str(item)]
|
|
return []
|
|
|
|
|
|
def _methodology_species_records(plan: dict[str, Any], *, source: str = '') -> list[dict[str, str]]:
|
|
records: list[dict[str, str]] = []
|
|
records.extend(_species_records_from_prefill(plan))
|
|
records.extend(_species_records_from_requirements(plan.get('data_requirements')))
|
|
records.extend(_species_records_from_plan_field(plan.get('species_summary')))
|
|
batch_dirs = _methodology_batch_dirs(plan, source=source)
|
|
for batch_dir in batch_dirs:
|
|
records.extend(_species_records_from_species_summary_csv(batch_dir / 'species_summary.csv'))
|
|
records.extend(_species_records_from_text(_batch_species_text(batch_dir), default_role='reference_species'))
|
|
if not batch_dirs:
|
|
records.extend(_species_records_from_text(_plan_species_text(plan), default_role='reference_species'))
|
|
return _unique_species_records(records)
|
|
|
|
|
|
def _resolve_methodology_species_role_groups(
|
|
plan: dict[str, Any],
|
|
records: list[dict[str, str]],
|
|
*,
|
|
source: str = '',
|
|
) -> dict[str, list[str]]:
|
|
prefill = plan.get('bbav5_prefill') if isinstance(plan.get('bbav5_prefill'), dict) else {}
|
|
prefill_targets = [_canonical_latin_species(item) for item in _list(prefill.get('targetSpecies'))]
|
|
prefill_targets = [item for item in prefill_targets if item]
|
|
strong_targets = _strong_target_species_from_methodology_evidence(plan, source=source)
|
|
target_keys = {_species_key(item) for item in strong_targets}
|
|
|
|
if strong_targets:
|
|
targets = strong_targets
|
|
elif len(prefill_targets) == 1:
|
|
targets = prefill_targets
|
|
target_keys = {_species_key(item) for item in targets}
|
|
else:
|
|
targets = []
|
|
for record in records:
|
|
species = _canonical_latin_species(record.get('species_name'))
|
|
if species and _normalize_species_role(record.get('species_role')) == 'target' and _species_key(species) not in target_keys:
|
|
targets.append(species)
|
|
target_keys.add(_species_key(species))
|
|
|
|
references: list[str] = []
|
|
reference_keys: set[str] = set()
|
|
comparatives: list[str] = []
|
|
comparative_keys: set[str] = set()
|
|
|
|
for record in records:
|
|
species = _canonical_latin_species(record.get('species_name'))
|
|
if not species:
|
|
continue
|
|
key = _species_key(species)
|
|
if key in target_keys:
|
|
continue
|
|
role = _normalize_species_role(record.get('species_role'))
|
|
if role == 'comparative':
|
|
if key not in comparative_keys and key not in reference_keys:
|
|
comparatives.append(species)
|
|
comparative_keys.add(key)
|
|
elif role == 'reference':
|
|
if key not in reference_keys and key not in comparative_keys:
|
|
references.append(species)
|
|
reference_keys.add(key)
|
|
|
|
return {'targets': targets, 'references': references, 'comparatives': comparatives}
|
|
|
|
|
|
def _strong_target_species_from_methodology_evidence(plan: dict[str, Any], *, source: str = '') -> list[str]:
|
|
targets: list[str] = []
|
|
|
|
def add(species: Any) -> None:
|
|
canonical = _canonical_latin_species(species)
|
|
if canonical and _species_key(canonical) not in {_species_key(item) for item in targets}:
|
|
targets.append(canonical)
|
|
|
|
for row in plan.get('species_summary') or []:
|
|
if not isinstance(row, dict):
|
|
continue
|
|
if _normalize_species_role(row.get('species_role') or row.get('role')) != 'target':
|
|
continue
|
|
species = row.get('species_name') or row.get('species')
|
|
evidence = str(row.get('evidence_sentence') or '')
|
|
if _is_supported_target_species_evidence(species, evidence):
|
|
add(species)
|
|
|
|
for batch_dir in _methodology_batch_dirs(plan, source=source):
|
|
path = batch_dir / 'species_summary.csv'
|
|
if not path.exists():
|
|
continue
|
|
try:
|
|
with path.open('r', encoding='utf-8', newline='') as handle:
|
|
reader = csv.DictReader(handle)
|
|
for row in reader:
|
|
if _normalize_species_role(row.get('species_role')) != 'target':
|
|
continue
|
|
if _is_supported_target_species_evidence(row.get('species_name'), row.get('evidence_sentence')):
|
|
add(row.get('species_name'))
|
|
except Exception:
|
|
continue
|
|
|
|
return targets
|
|
|
|
|
|
def _is_supported_target_species_evidence(species: Any, evidence: Any) -> bool:
|
|
canonical = _canonical_latin_species(species)
|
|
text = str(evidence or '')
|
|
if not canonical or not text:
|
|
return False
|
|
if _species_mention_is_abbreviation_definition_only(canonical, text):
|
|
return False
|
|
if not _species_evidence_mentions_species(canonical, text):
|
|
return False
|
|
return _species_has_target_context(canonical, text)
|
|
|
|
|
|
def _species_has_target_context(species: str, text: str) -> bool:
|
|
stripped = _strip_species_abbreviation_definitions(str(text or ''))
|
|
lower = stripped.lower()
|
|
aliases = _species_context_aliases(species)
|
|
for alias in aliases:
|
|
alias_lower = alias.lower()
|
|
if alias_lower not in lower:
|
|
continue
|
|
for match in re.finditer(re.escape(alias_lower), lower):
|
|
start = max(0, match.start() - 120)
|
|
end = min(len(lower), match.end() + 120)
|
|
window = lower[start:end]
|
|
if 'genome-wide' in window:
|
|
return True
|
|
if re.search(r'(identification|identified|screened).{0,80}(genes?|proteins?)', window) and alias_lower in window:
|
|
return True
|
|
if re.search(r'(genes?|proteins?).{0,80}(identification|identified|screened)', window) and alias_lower in window:
|
|
return True
|
|
if f'{alias_lower} genome' in window or f'{alias_lower} genes' in window or f'{alias_lower} proteins' in window:
|
|
return True
|
|
if re.search(rf'(from|in)\s+(the\s+)?{re.escape(alias_lower)}\s+genome', window):
|
|
return True
|
|
return False
|
|
|
|
|
|
def _text_species_record_has_method_context(species: str, role: str, text: str) -> bool:
|
|
if _normalize_species_role(role) != 'reference':
|
|
return True
|
|
return _species_has_reference_context(species, text)
|
|
|
|
|
|
def _species_has_reference_context(species: str, text: str) -> bool:
|
|
stripped = _strip_species_abbreviation_definitions(str(text or ''))
|
|
lower = stripped.lower()
|
|
aliases = _species_context_aliases(species)
|
|
for alias in aliases:
|
|
alias_lower = alias.lower()
|
|
if alias_lower not in lower:
|
|
continue
|
|
for match in re.finditer(re.escape(alias_lower), lower):
|
|
start = max(0, match.start() - 180)
|
|
end = min(len(lower), match.end() + 180)
|
|
window = lower[start:end]
|
|
if re.search(r'(phylogen|tree|n-j|neighbor|aligned|alignment|clustal|mega|uniprot|tair|protein sequences?|amino acid sequences?|reference proteins?|included|comparing|multiple species|blast)', window):
|
|
return True
|
|
return False
|
|
|
|
|
|
def _species_context_aliases(species: str) -> list[str]:
|
|
canonical = _canonical_latin_species(species)
|
|
aliases = [canonical] if canonical else []
|
|
aliases.extend(COMMON_SPECIES_NAME_ALIASES.get(canonical, []))
|
|
for alias, alias_species in COMMON_PLANT_SPECIES_ALIASES.items():
|
|
if _species_key(alias_species) == _species_key(canonical):
|
|
aliases.append(alias)
|
|
return _unique([alias for alias in aliases if alias])
|
|
|
|
|
|
def _species_records_from_prefill(plan: dict[str, Any]) -> list[dict[str, str]]:
|
|
prefill = plan.get('bbav5_prefill') if isinstance(plan.get('bbav5_prefill'), dict) else {}
|
|
records: list[dict[str, str]] = []
|
|
for species in _list(prefill.get('targetSpecies')):
|
|
records.append({'species_name': species, 'species_role': 'target_species'})
|
|
for species in _list(prefill.get('referenceSpecies')):
|
|
records.append({'species_name': species, 'species_role': 'reference_species'})
|
|
for species in _list(prefill.get('comparativeSpecies')):
|
|
records.append({'species_name': species, 'species_role': 'comparative_species'})
|
|
for species in _list(prefill.get('identifyBlastReferenceSpecies')):
|
|
records.append({'species_name': species, 'species_role': 'reference_species'})
|
|
return records
|
|
|
|
|
|
def _species_records_from_requirements(requirements: Any) -> list[dict[str, str]]:
|
|
records: list[dict[str, str]] = []
|
|
if not isinstance(requirements, list):
|
|
return records
|
|
for req in requirements:
|
|
if not isinstance(req, dict):
|
|
continue
|
|
species = req.get('species_name')
|
|
if species:
|
|
records.append({'species_name': str(species), 'species_role': str(req.get('bbav5_role') or 'reference_species')})
|
|
return records
|
|
|
|
|
|
def _species_records_from_plan_field(value: Any) -> list[dict[str, str]]:
|
|
records: list[dict[str, str]] = []
|
|
if not isinstance(value, list):
|
|
return records
|
|
for row in value:
|
|
if not isinstance(row, dict):
|
|
continue
|
|
species = row.get('species_name') or row.get('species')
|
|
if species:
|
|
records.append({'species_name': str(species), 'species_role': str(row.get('species_role') or row.get('role') or 'reference_species')})
|
|
return records
|
|
|
|
|
|
def _methodology_batch_dirs(plan: dict[str, Any], *, source: str = '') -> list[Path]:
|
|
candidates: list[Path] = []
|
|
for value in (plan.get('source_batch_dir'), plan.get('batch_dir'), plan.get('methodology_batch_dir'), source):
|
|
if not value:
|
|
continue
|
|
path = Path(str(value)).expanduser()
|
|
if path.is_file():
|
|
path = path.parent
|
|
if path.exists() and path.is_dir():
|
|
candidates.append(path)
|
|
return _unique_paths(candidates)
|
|
|
|
|
|
def _species_records_from_species_summary_csv(path: Path) -> list[dict[str, str]]:
|
|
records: list[dict[str, str]] = []
|
|
if not path.exists():
|
|
return records
|
|
try:
|
|
with path.open('r', encoding='utf-8', newline='') as handle:
|
|
reader = csv.DictReader(handle)
|
|
for row in reader:
|
|
species = row.get('species_name')
|
|
canonical = _canonical_latin_species(species)
|
|
if not canonical:
|
|
continue
|
|
evidence = row.get('evidence_sentence') or ''
|
|
if evidence and not _species_evidence_mentions_species(canonical, evidence):
|
|
continue
|
|
if _species_mention_is_abbreviation_definition_only(canonical, evidence):
|
|
continue
|
|
records.append({'species_name': canonical, 'species_role': row.get('species_role') or 'reference_species'})
|
|
except Exception:
|
|
return records
|
|
return records
|
|
|
|
|
|
def _batch_species_text(batch_dir: Path) -> str:
|
|
chunks: list[str] = []
|
|
for name in ('review_template.csv', 'observations.json', 'alignment.json'):
|
|
path = batch_dir / name
|
|
if path.exists() and path.is_file():
|
|
try:
|
|
chunks.append(path.read_text(encoding='utf-8', errors='ignore'))
|
|
except Exception:
|
|
continue
|
|
return '\n'.join(chunks)
|
|
|
|
|
|
def _plan_species_text(plan: dict[str, Any]) -> str:
|
|
chunks: list[str] = []
|
|
|
|
def walk(value: Any, key: str = '') -> None:
|
|
if key in {'bbav5_prefill', 'data_requirements', 'methodology_completion_audit', 'reproduction_targets'}:
|
|
return
|
|
if isinstance(value, str):
|
|
if key not in {'plan_id', 'paper_id', 'source_batch_dir', 'batch_dir', 'methodology_batch_dir'}:
|
|
chunks.append(value)
|
|
elif isinstance(value, list):
|
|
for item in value:
|
|
walk(item, key)
|
|
elif isinstance(value, dict):
|
|
for child_key, child_value in value.items():
|
|
walk(child_value, str(child_key))
|
|
|
|
walk(plan)
|
|
return '\n'.join(chunks)
|
|
|
|
|
|
def _species_records_from_text(text: str, *, default_role: str) -> list[dict[str, str]]:
|
|
value = str(text or '')
|
|
if not value:
|
|
return []
|
|
aliases = {**COMMON_PLANT_SPECIES_ALIASES, **_species_alias_definitions(value)}
|
|
evidence_text = _strip_species_abbreviation_definitions(value)
|
|
records: list[dict[str, str]] = []
|
|
for alias, species in aliases.items():
|
|
if _contains_species_alias(evidence_text, alias) and _text_species_record_has_method_context(species, default_role, evidence_text):
|
|
records.append({'species_name': species, 'species_role': default_role})
|
|
for species in _unique(list(aliases.values())):
|
|
if _contains_species_alias(evidence_text, species) and _text_species_record_has_method_context(species, default_role, evidence_text):
|
|
records.append({'species_name': species, 'species_role': default_role})
|
|
return records
|
|
|
|
|
|
def _species_alias_definitions(text: str) -> dict[str, str]:
|
|
aliases: dict[str, str] = {}
|
|
pattern = re.compile(r'\b([A-Z]\.\s*[a-z][A-Za-z-]*)\s*:\s*([A-Z][a-z]+(?:\s+[a-z][a-z.-]+)+)')
|
|
for alias, species in pattern.findall(str(text or '')):
|
|
canonical = _canonical_latin_species(species)
|
|
if canonical:
|
|
aliases[re.sub(r'\s+', ' ', alias.strip())] = canonical
|
|
return aliases
|
|
|
|
|
|
def _strip_species_abbreviation_definitions(text: str) -> str:
|
|
return re.sub(
|
|
r'\b[A-Z]\.\s*[a-z][A-Za-z-]*\s*:\s*[A-Z][a-z]+(?:\s+[a-z][a-z.-]+)+(?:\s+[A-Za-z().-]+)*[;,.]?',
|
|
' ',
|
|
str(text or ''),
|
|
)
|
|
|
|
|
|
def _species_evidence_mentions_species(species: str, text: str) -> bool:
|
|
canonical = _canonical_latin_species(species)
|
|
if not canonical:
|
|
return False
|
|
stripped = _strip_species_abbreviation_definitions(str(text or ''))
|
|
if _contains_species_alias(stripped, canonical):
|
|
return True
|
|
for alias, alias_species in COMMON_PLANT_SPECIES_ALIASES.items():
|
|
if _species_key(alias_species) == _species_key(canonical) and _contains_species_alias(stripped, alias):
|
|
return True
|
|
for alias in COMMON_SPECIES_NAME_ALIASES.get(canonical, []):
|
|
if re.search(rf'(?<![A-Za-z0-9]){re.escape(alias)}(?![A-Za-z0-9])', stripped, re.I):
|
|
return True
|
|
return False
|
|
|
|
|
|
def _species_mention_is_abbreviation_definition_only(species: str, text: str) -> bool:
|
|
value = str(text or '')
|
|
if len(re.findall(r'\b[A-Z]\.\s*[a-z][A-Za-z-]*\s*:', value)) < 2:
|
|
return False
|
|
return not _species_evidence_mentions_species(species, value)
|
|
|
|
|
|
def _contains_species_alias(text: str, alias: str) -> bool:
|
|
compact = re.escape(re.sub(r'\s+', ' ', str(alias or '').strip()))
|
|
compact = compact.replace(r'\ ', r'\s*')
|
|
return bool(re.search(rf'(?<![A-Za-z0-9]){compact}(?![A-Za-z0-9])', str(text or '')))
|
|
|
|
|
|
def _canonical_latin_species(value: Any) -> str:
|
|
species = re.sub(r'\s+', ' ', str(value or '').strip().strip(' .,:;()[]{}'))
|
|
species = re.sub(r'\s+L\.?$', '', species).strip()
|
|
if not species:
|
|
return ''
|
|
if re.match(r'^[A-Z]\.\s*[a-z][a-z.-]+$', species):
|
|
return COMMON_PLANT_SPECIES_ALIASES.get(re.sub(r'\s+', ' ', species), '')
|
|
if not re.match(r'^[A-Z][a-z]+(?:\s+[a-z][a-z.-]+)+$', species):
|
|
return ''
|
|
words = species.split()
|
|
if len(words) < 2:
|
|
return ''
|
|
return f'{words[0]} {words[1]}'
|
|
|
|
|
|
def _normalize_species_role(value: Any) -> str:
|
|
role = str(value or '').strip().lower()
|
|
if 'target' in role or role in {'target', 'query'}:
|
|
return 'target'
|
|
if 'comparative' in role or 'comparison' in role:
|
|
return 'comparative'
|
|
return 'reference'
|
|
|
|
|
|
def _complete_species_requirements(
|
|
requirements: Any,
|
|
*,
|
|
targets: list[str],
|
|
references: list[str],
|
|
comparatives: list[str],
|
|
) -> list[dict[str, Any]]:
|
|
result = [
|
|
copy.deepcopy(req)
|
|
for req in requirements
|
|
if isinstance(req, dict) and str(req.get('requirement_type') or '') != 'whole_genome_annotation'
|
|
] if isinstance(requirements, list) else []
|
|
existing = {(_species_key(req.get('species_name')), str(req.get('requirement_type') or '')) for req in result}
|
|
|
|
def add(species: str, role: str) -> None:
|
|
key = (_species_key(species), 'whole_genome_annotation')
|
|
if not species or key in existing:
|
|
return
|
|
result.append({
|
|
'species_name': species,
|
|
'bbav5_role': role,
|
|
'requirement_type': 'whole_genome_annotation',
|
|
'required_files': ['gff', 'genome_or_cds_or_pep'],
|
|
'source': 'methodology_species_completion',
|
|
})
|
|
existing.add(key)
|
|
|
|
for species in targets:
|
|
add(species, 'target')
|
|
for species in references:
|
|
add(species, 'reference')
|
|
for species in comparatives:
|
|
add(species, 'comparative')
|
|
return result
|
|
|
|
|
|
def _unique_species_records(records: list[dict[str, str]]) -> list[dict[str, str]]:
|
|
result: list[dict[str, str]] = []
|
|
seen: set[tuple[str, str]] = set()
|
|
for record in records:
|
|
species = _canonical_latin_species(record.get('species_name'))
|
|
role = _normalize_species_role(record.get('species_role'))
|
|
if not species:
|
|
continue
|
|
key = (_species_key(species), role)
|
|
if key in seen:
|
|
continue
|
|
result.append({'species_name': species, 'species_role': role})
|
|
seen.add(key)
|
|
return result
|
|
|
|
|
|
def _unique_candidates(candidates: list[EvidenceCandidate]) -> list[EvidenceCandidate]:
|
|
result: list[EvidenceCandidate] = []
|
|
seen: set[tuple[str, str, str, str, str]] = set()
|
|
for candidate in candidates:
|
|
if not candidate.value:
|
|
continue
|
|
key = (
|
|
candidate.object_type,
|
|
_normalize_candidate_value(candidate.value),
|
|
candidate.role,
|
|
candidate.source_file,
|
|
candidate.source_field,
|
|
)
|
|
if key in seen:
|
|
continue
|
|
result.append(candidate)
|
|
seen.add(key)
|
|
return result
|
|
|
|
|
|
def _evidence_excerpt(text: str, needle: Any, *, window: int = 240) -> str:
|
|
value = str(text or '')
|
|
query = str(needle or '')
|
|
if not value:
|
|
return ''
|
|
if not query:
|
|
return value[:window].strip()
|
|
match = re.search(re.escape(query), value, re.I)
|
|
if not match:
|
|
return value[:window].strip()
|
|
start = max(0, match.start() - window // 3)
|
|
end = min(len(value), match.end() + (window - (match.end() - start)))
|
|
return re.sub(r'\s+', ' ', value[start:end]).strip()
|
|
|
|
|
|
def _sentence_excerpt(text: str, needle: Any) -> str:
|
|
value = str(text or '')
|
|
query = str(needle or '')
|
|
match = re.search(re.escape(query), value, re.I) if query else None
|
|
if not match:
|
|
return _evidence_excerpt(value, query)
|
|
start_candidates = [value.rfind(mark, 0, match.start()) for mark in ('.', '\n', ';')]
|
|
start = max(start_candidates)
|
|
start = 0 if start < 0 else start + 1
|
|
end_candidates = [idx for idx in (value.find(mark, match.end()) for mark in ('.', '\n', ';')) if idx >= 0]
|
|
end = min(end_candidates) if end_candidates else min(len(value), match.end() + 180)
|
|
return re.sub(r'\s+', ' ', value[start:end]).strip()
|
|
|
|
|
|
def _unique_paths(paths: list[Path]) -> list[Path]:
|
|
result: list[Path] = []
|
|
seen: set[str] = set()
|
|
for path in paths:
|
|
key = str(path.resolve())
|
|
if key not in seen:
|
|
result.append(path)
|
|
seen.add(key)
|
|
return result
|
|
|
|
|
|
def _species_key(value: Any) -> str:
|
|
return _normalize_species_alias(str(value or ''))
|
|
|
|
|
|
def is_methodology_execution_intent(text: str) -> bool:
|
|
value = str(text or '').strip()
|
|
if not value:
|
|
return False
|
|
has_action = bool(re.search(r'(按|按照|使用|继续|执行|运行|复现|run|execute|continue|reproduce|use)', value, re.I))
|
|
has_methodology = bool(re.search(r'(方法学|文献方法|已保存(?:的)?(?:方法学)?方案|保存(?:的)?方案|文献方案|methodology|literature\s+method|saved\s+plan)', value, re.I))
|
|
return has_action and has_methodology
|
|
|
|
|
|
def _infer_gene_family_from_plan(plan: dict[str, Any]) -> str:
|
|
for section in ('evidence', 'stages', 'method_components'):
|
|
for row in plan.get(section) or []:
|
|
if not isinstance(row, dict):
|
|
continue
|
|
if row.get('family'):
|
|
return _normalize_gene_family_token(row.get('family'))
|
|
text = ' '.join(str(value or '') for value in row.values())
|
|
family = _infer_gene_family_from_text(text)
|
|
if family:
|
|
return family
|
|
for key in ('paper_id', 'plan_id', 'source_batch_dir'):
|
|
value = str(plan.get(key) or '')
|
|
for match in re.finditer(r'(?<![A-Za-z0-9])(?:[A-Z][a-z]?\.)?([A-Z][A-Za-z0-9_-]{2,})(?![A-Za-z0-9])', value):
|
|
family = _normalize_gene_family_token(match.group(1))
|
|
if family and family.lower() not in {'plan', 'batch', 'methodology', 'job'}:
|
|
return family
|
|
return ''
|
|
|
|
|
|
def _normalize_gene_family_token(value: Any) -> str:
|
|
token = str(value or '').strip()
|
|
if not token:
|
|
return ''
|
|
token = token.strip(' .,:;()[]{}')
|
|
species_prefixed = re.match(r'^([A-Z][A-Za-z]{0,2})[-_]([A-Za-z0-9][A-Za-z0-9_-]{2,})$', token)
|
|
if species_prefixed:
|
|
token = species_prefixed.group(2)
|
|
token = re.sub(r'[^A-Za-z0-9]+', '', token)
|
|
if token.lower() == 'hdzip':
|
|
return 'HDZIP'
|
|
return token
|
|
|
|
|
|
def _infer_gene_family_from_text(text: str) -> str:
|
|
value = str(text or '')
|
|
if re.search(r'\bHD[-\s]?Zip\b', value, re.I):
|
|
return 'HDZIP'
|
|
for pattern in (
|
|
r'\b([A-Z][A-Za-z0-9]*(?:[- ][A-Z][A-Za-z0-9]*)?)\s+(?:gene\s+family|protein\s+family|protein\s+sequences?|proteins?)\b',
|
|
r'\b([A-Z][A-Za-z0-9]*(?:[- ][A-Z][A-Za-z0-9]*)?)\s+family\b',
|
|
):
|
|
for match in re.finditer(pattern, value):
|
|
family = _normalize_gene_family_token(match.group(1))
|
|
if family and family.lower() not in {'known', 'candidate', 'target', 'reference'}:
|
|
return family
|
|
return ''
|
|
|
|
|
|
def _infer_required_pfams_from_plan(plan: dict[str, Any]) -> str:
|
|
pfams: list[str] = []
|
|
def add_from(value: Any) -> None:
|
|
if isinstance(value, str):
|
|
for hit in re.findall(r'PF\d{5}', value):
|
|
if hit not in pfams:
|
|
pfams.append(hit)
|
|
elif isinstance(value, list):
|
|
for item in value:
|
|
add_from(item)
|
|
elif isinstance(value, dict):
|
|
for item in value.values():
|
|
add_from(item)
|
|
add_from(plan.get('method_components'))
|
|
add_from(plan.get('stages'))
|
|
add_from(plan.get('evidence'))
|
|
return ','.join(pfams)
|
|
|
|
|
|
def methodology_plan_readiness(
|
|
plan: dict[str, Any],
|
|
*,
|
|
job_id: str,
|
|
input_root: Path,
|
|
workspace_root: Path,
|
|
) -> dict[str, Any]:
|
|
plan = complete_methodology_plan_for_execution(plan)
|
|
input_dir = Path(input_root) / job_id if job_id else Path(input_root)
|
|
final_dir = Path(workspace_root) / job_id / 'final' if job_id else Path(workspace_root)
|
|
requirements = plan.get('data_requirements') if isinstance(plan.get('data_requirements'), list) else []
|
|
statuses = []
|
|
missing = []
|
|
for req in requirements:
|
|
if not isinstance(req, dict):
|
|
continue
|
|
status = _requirement_status(req, input_dir, final_dir)
|
|
statuses.append(status)
|
|
if status['status'] != 'ready':
|
|
missing.append(status)
|
|
agent_params = methodology_agent_params(plan)
|
|
suggested_stages = []
|
|
if any(item.get('requirement_type') == 'whole_genome_annotation' for item in missing):
|
|
suggested_stages.append('genome_prepare')
|
|
requested = agent_params.get('requestedMethodologyStages') or []
|
|
for stage in requested:
|
|
if stage not in suggested_stages:
|
|
suggested_stages.append(stage)
|
|
config_path = input_dir / 'config.xlsx'
|
|
samples_path = input_dir / 'samples.yaml'
|
|
return {
|
|
'success': True,
|
|
'job_id': job_id,
|
|
'plan_id': plan.get('plan_id', ''),
|
|
'ready': len(missing) == 0 and config_path.exists(),
|
|
'config_exists': config_path.exists(),
|
|
'samples_exists': samples_path.exists(),
|
|
'requirements': statuses,
|
|
'missing_requirements': missing,
|
|
'agent_params': agent_params,
|
|
'suggested_stages': suggested_stages,
|
|
'manual_gates': plan.get('manual_gates') or [],
|
|
'completion_audit': plan.get('methodology_completion_audit') or {},
|
|
'completion_summary': _methodology_completion_summary(plan),
|
|
'reproduction_targets': plan.get('reproduction_targets') or [],
|
|
'message': '方法学方案数据已就绪' if len(missing) == 0 and config_path.exists() else '方法学方案仍需补齐数据或确认 config.xlsx',
|
|
}
|
|
|
|
|
|
def load_bound_methodology_plan(input_root: Path, job_id: str) -> dict[str, Any] | None:
|
|
if not job_id:
|
|
return None
|
|
path = bound_methodology_plan_path(input_root, job_id)
|
|
if not path.exists():
|
|
return None
|
|
return load_methodology_plan(path)
|
|
|
|
|
|
def _requirement_status(req: dict[str, Any], input_dir: Path, final_dir: Path) -> dict[str, Any]:
|
|
species = str(req.get('species_name') or '')
|
|
species_for_files = _canonical_species_for_files(species, input_dir)
|
|
req_type = str(req.get('requirement_type') or '')
|
|
if req_type == 'whole_genome_annotation':
|
|
has_gff = _has_matching_file(input_dir, species_for_files, GFF_EXTENSIONS) or _has_matching_file(final_dir, species_for_files, GFF_EXTENSIONS)
|
|
has_seq = _has_matching_file(input_dir, species_for_files, WHOLE_GENOME_EXTENSIONS) or _has_matching_file(final_dir, species_for_files, WHOLE_GENOME_EXTENSIONS)
|
|
missing = []
|
|
if not has_gff:
|
|
missing.append('gff')
|
|
if not has_seq:
|
|
missing.append('genome_or_cds_or_pep')
|
|
return {
|
|
**req,
|
|
'status': 'ready' if not missing else 'missing',
|
|
'missing_files': missing,
|
|
'matched': {'gff': has_gff, 'sequence': has_seq},
|
|
'resolved_species_code': species_for_files,
|
|
}
|
|
if req_type == 'known_family_protein':
|
|
has_family = _has_matching_file(input_dir, species_for_files, WHOLE_GENOME_EXTENSIONS, family_hint=True) or _has_matching_file(final_dir, species_for_files, WHOLE_GENOME_EXTENSIONS, family_hint=True)
|
|
return {
|
|
**req,
|
|
'status': 'ready' if has_family else 'missing',
|
|
'missing_files': [] if has_family else ['family_pep_fasta'],
|
|
'matched': {'family_pep_fasta': has_family},
|
|
'resolved_species_code': species_for_files,
|
|
}
|
|
return {**req, 'status': 'unknown', 'missing_files': list(req.get('required_files') or []), 'resolved_species_code': species_for_files}
|
|
|
|
|
|
def _canonical_species_for_files(species: str, input_dir: Path) -> str:
|
|
value = str(species or '').strip()
|
|
if not value:
|
|
return value
|
|
aliases = _species_aliases_from_config(input_dir)
|
|
normalized = _normalize_species_alias(value)
|
|
return aliases.get(normalized) or value
|
|
|
|
|
|
def _species_aliases_from_config(input_dir: Path) -> dict[str, str]:
|
|
config_path = Path(input_dir) / 'config.xlsx'
|
|
if not config_path.exists():
|
|
return {}
|
|
try:
|
|
import pandas as pd
|
|
df = pd.read_excel(config_path).fillna('')
|
|
except Exception:
|
|
return {}
|
|
aliases: dict[str, str] = {}
|
|
|
|
def add(alias: Any, code: str) -> None:
|
|
key = _normalize_species_alias(str(alias or ''))
|
|
if key and code:
|
|
aliases[key] = code
|
|
|
|
for _, row in df.iterrows():
|
|
code = str(row.get('Species_Code') or row.get('Species') or '').strip()
|
|
if not code:
|
|
continue
|
|
add(code, code)
|
|
for key in ('Species', 'Species_Name', 'Scientific_Name', 'Latin_Name', 'species_name', 'scientific_name', 'latin_name'):
|
|
add(row.get(key), code)
|
|
filename = str(row.get('Original_File_Name') or '').strip()
|
|
if filename:
|
|
stem = Path(filename).name
|
|
stem = re.sub(r'\.(?:gz|zip|fa|fasta|faa|fna|ffn|gff3?|gtf|cds|pep|all|chr|toplevel|dna|protein)+$', '', stem, flags=re.I)
|
|
parts = [part for part in re.split(r'[._-]+', stem) if part]
|
|
if len(parts) >= 2:
|
|
add(' '.join(parts[:2]), code)
|
|
add('_'.join(parts[:2]), code)
|
|
return aliases
|
|
|
|
|
|
def _normalize_species_alias(value: str) -> str:
|
|
return re.sub(r'[^A-Za-z0-9]+', ' ', str(value or '')).strip().lower()
|
|
|
|
|
|
def _has_matching_file(root: Path, species: str, extensions: tuple[str, ...], *, family_hint: bool = False) -> bool:
|
|
if not root.exists():
|
|
return False
|
|
tokens = _species_tokens(species)
|
|
for path in root.rglob('*'):
|
|
if not path.is_file() or path.name.startswith('.'):
|
|
continue
|
|
lower = path.name.lower()
|
|
if not lower.endswith(extensions):
|
|
continue
|
|
if tokens and not any(token in lower for token in tokens):
|
|
if family_hint and any(word in lower for word in ('family', 'known', 'ref', 'reference')):
|
|
return True
|
|
continue
|
|
return True
|
|
return False
|
|
|
|
|
|
def _species_tokens(species: str) -> list[str]:
|
|
words = [part for part in re.split(r'[^A-Za-z0-9]+', species.lower()) if part]
|
|
tokens = set(words)
|
|
if len(words) >= 2:
|
|
tokens.add(words[0][0] + words[1])
|
|
tokens.add(words[0][:2] + words[1][:2])
|
|
return [token for token in tokens if len(token) >= 2]
|
|
|
|
|
|
def _scope_mode(prefill: dict[str, Any]) -> str:
|
|
refs = _list(prefill.get('referenceSpecies'))
|
|
comps = _list(prefill.get('comparativeSpecies'))
|
|
if refs and comps:
|
|
return 'target_plus_reference_plus_comparative'
|
|
if refs:
|
|
return 'target_plus_reference'
|
|
if comps:
|
|
return 'target_plus_comparative'
|
|
return 'target_only'
|
|
|
|
|
|
def _list(value: Any) -> list[str]:
|
|
if isinstance(value, list):
|
|
values = value
|
|
elif isinstance(value, str):
|
|
values = re.split(r'[,;,;|]+', value)
|
|
else:
|
|
values = []
|
|
return _unique([str(item).strip() for item in values if str(item).strip()])
|
|
|
|
|
|
def _unique(values: list[str]) -> list[str]:
|
|
result = []
|
|
seen = set()
|
|
for value in values:
|
|
if value and value not in seen:
|
|
result.append(value)
|
|
seen.add(value)
|
|
return result
|