728 lines
30 KiB
Python
Executable File
728 lines
30 KiB
Python
Executable File
"""Project/job context index for bbav5 orchestration.
|
|
|
|
This module is intentionally read-only. It builds a structured view of a
|
|
workspace job before planning, execution, diagnosis, or repair decide what to
|
|
do. The goal is to stop each agent from independently guessing files from
|
|
prefixes or extensions.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass, field, asdict
|
|
from pathlib import Path
|
|
from typing import Any, Dict, Iterable, List, Optional
|
|
import json
|
|
import re
|
|
|
|
from gfa_core.project_paths import PROJECT_ROOT, WORKSPACE_ROOT
|
|
from gfa_core.gene_family_scope import find_config_file, read_config_workbook, read_scope
|
|
from gfa_core.mas.stage_catalog import STAGE_INFO
|
|
from gfa_core.mas.stage_capabilities import get_stage_capability
|
|
from gfa_core.mas.tools.stage_status import get_stage_status
|
|
|
|
|
|
FASTA_EXTS = {".fa", ".faa", ".fasta", ".fas", ".pep"}
|
|
TABLE_EXTS = {".txt", ".tsv", ".csv", ".id", ".ids", ".list"}
|
|
|
|
|
|
def _safe_stat(path: Path) -> Dict[str, Any]:
|
|
try:
|
|
stat = path.stat()
|
|
return {
|
|
"exists": True,
|
|
"size": stat.st_size,
|
|
"mtime": stat.st_mtime,
|
|
}
|
|
except OSError:
|
|
return {"exists": False, "size": None, "mtime": None}
|
|
|
|
|
|
def _read_json(path: Path) -> Optional[Dict[str, Any]]:
|
|
try:
|
|
return json.loads(path.read_text(encoding="utf-8"))
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def _split_prefix(prefix: str) -> tuple[str, str]:
|
|
prefix = str(prefix or "").strip()
|
|
if not prefix:
|
|
return "", ""
|
|
parts = [part.strip() for part in prefix.split(".") if part.strip()]
|
|
if len(parts) >= 3 and parts[0] in {"Target", "Reference", "Comparative", "Ref"}:
|
|
# Legacy per-species identify outputs used Target.{Species}.{Family}.
|
|
if not any(token.startswith(("target-", "ref-", "comp-")) or token in {"target_only", "target_plus_reference", "all_selected", "subfamily_prediction"} for token in parts[2:]):
|
|
return parts[1], parts[2]
|
|
if len(parts) >= 2 and parts[0] in {"Multi", "Target", "Reference", "Comparative", "Ref"}:
|
|
return "", parts[1]
|
|
if len(parts) >= 2:
|
|
return parts[0], parts[1]
|
|
return "", parts[0]
|
|
|
|
|
|
def _single_string(value: Any) -> str:
|
|
return str(value or "").strip()
|
|
|
|
|
|
def _first_list_value(value: Any) -> str:
|
|
if isinstance(value, list):
|
|
for item in value:
|
|
text = _single_string(item)
|
|
if text:
|
|
return text
|
|
return ""
|
|
|
|
|
|
def _manifest_stage_context(manifest: Dict[str, Any]) -> Dict[str, Any]:
|
|
for key in ("stage_context", "scope", "scope_data"):
|
|
value = manifest.get(key)
|
|
if isinstance(value, dict):
|
|
return value
|
|
params = manifest.get("parameters")
|
|
if isinstance(params, dict):
|
|
value = params.get("stage_context") or params.get("scope")
|
|
if isinstance(value, dict):
|
|
return value
|
|
return {}
|
|
|
|
|
|
def _infer_file_kind(path: Path) -> str:
|
|
name = path.name.lower()
|
|
suffixes = "".join(path.suffixes).lower()
|
|
stem = path.stem.lower()
|
|
|
|
if name.endswith((".manifest.json", ".scope.json")):
|
|
return "metadata"
|
|
if path.suffix.lower() in {".yaml", ".yml"}:
|
|
return "yaml"
|
|
if (
|
|
name.endswith(".subfamily")
|
|
or "geneid.subfamily" in name
|
|
or "confirmed_subfamily" in name
|
|
or "reference_classification.subfamily" in name
|
|
):
|
|
return "table"
|
|
if name.endswith((".gff3", ".gff", ".gtf")):
|
|
return "gff"
|
|
if any(token in name for token in ["genome", "genomic", "chromosome", "chr"]) and suffixes.endswith((".fa", ".fasta", ".fa.gz", ".fasta.gz")):
|
|
return "genome_fasta"
|
|
if any(token in name for token in ["pep", "protein", "proteome", "faa"]) and suffixes.endswith((".fa", ".fasta", ".faa", ".pep")):
|
|
return "protein_fasta"
|
|
if any(token in name for token in ["cds", "transcript", "mrna"]) and suffixes.endswith((".fa", ".fasta")):
|
|
return "cds_fasta"
|
|
if path.suffix.lower() in FASTA_EXTS or suffixes.endswith((".fa.gz", ".fasta.gz", ".faa.gz")):
|
|
return "fasta"
|
|
if any(token in stem for token in ["count_matrix", "fpkm", "tpm", "expression"]):
|
|
return "expression_matrix"
|
|
if path.suffix.lower() in TABLE_EXTS:
|
|
if any(token in name for token in ["interproscan", "domain_review", "domain_check"]):
|
|
return "table"
|
|
if path.suffix.lower() in {".id", ".ids", ".list"} or name.endswith((".members.id", ".final_members.id", ".final_gfmembers.id")):
|
|
return "id_list"
|
|
return "table"
|
|
if path.suffix.lower() in {".pdf", ".png", ".svg"}:
|
|
return "figure"
|
|
if path.suffix.lower() in {".nwk", ".tree", ".newick", ".treefile"} or name.endswith((".tree.nwk", ".iqtree.treefile", ".phylogeny.treefile")):
|
|
return "tree"
|
|
return "file"
|
|
|
|
|
|
def _infer_species_family(path: Path, manifest: Optional[Dict[str, Any]] = None) -> tuple[str, str]:
|
|
if manifest:
|
|
context = _manifest_stage_context(manifest)
|
|
species = ""
|
|
family = _single_string(manifest.get("family") or manifest.get("gene_family") or context.get("family"))
|
|
for prefix_key in ("canonical_member_prefix", "output_prefix", "prefix"):
|
|
prefix = _single_string(manifest.get(prefix_key) or context.get(prefix_key))
|
|
if not prefix:
|
|
continue
|
|
prefix_species, prefix_family = _split_prefix(prefix)
|
|
if prefix_species and not species:
|
|
species = prefix_species
|
|
if prefix_family and not family:
|
|
family = prefix_family
|
|
if species and family:
|
|
break
|
|
selected = manifest.get("selected_species") or manifest.get("target_species") or context.get("selected_species") or context.get("target_species") or []
|
|
if not species and isinstance(selected, list) and len(selected) == 1:
|
|
species = str(selected[0]).strip()
|
|
return species, family
|
|
|
|
name = path.name
|
|
match = re.match(r"^([A-Za-z0-9_-]+)_final\.gff3$", name)
|
|
if match:
|
|
return match.group(1), ""
|
|
match = re.match(r"^([A-Za-z0-9_-]+)\.([A-Za-z0-9_-]+)\.(pep|protein|cds|genome)", name, re.I)
|
|
if match:
|
|
return match.group(1), match.group(2)
|
|
match = re.match(r"^([A-Za-z0-9_-]+)\.(pep|protein|cds|genome|genomic|gff3?|faa|fna|fa|fasta)(?:\.|$)", name, re.I)
|
|
if match:
|
|
return match.group(1), ""
|
|
match = re.match(r"^([A-Za-z0-9_-]+)\.([A-Za-z0-9_-]+)\.", name)
|
|
if match:
|
|
return match.group(1), match.group(2)
|
|
return "", ""
|
|
|
|
|
|
def _iter_files(root: Path, limit: int = 5000) -> Iterable[Path]:
|
|
if not root.exists():
|
|
return []
|
|
count = 0
|
|
for path in root.rglob("*"):
|
|
if path.is_file():
|
|
yield path
|
|
count += 1
|
|
if count >= limit:
|
|
break
|
|
|
|
|
|
@dataclass
|
|
class ContextFile:
|
|
path: str
|
|
rel_path: str
|
|
source: str
|
|
kind: str
|
|
species: str = ""
|
|
family: str = ""
|
|
exists: bool = True
|
|
size: Optional[int] = None
|
|
mtime: Optional[float] = None
|
|
|
|
def to_dict(self) -> Dict[str, Any]:
|
|
return asdict(self)
|
|
|
|
|
|
@dataclass
|
|
class ContextArtifact:
|
|
stage: str
|
|
key: str
|
|
path: str
|
|
kind: str
|
|
exists: bool
|
|
source_manifest: str
|
|
|
|
def to_dict(self) -> Dict[str, Any]:
|
|
return asdict(self)
|
|
|
|
|
|
@dataclass
|
|
class ProjectContextIndex:
|
|
job_id: str
|
|
workspace: str
|
|
input_dir: str
|
|
final_dir: str
|
|
logs_dir: str
|
|
config_path: str = ""
|
|
family: str = ""
|
|
target_species: List[str] = field(default_factory=list)
|
|
reference_species: List[str] = field(default_factory=list)
|
|
comparative_species: List[str] = field(default_factory=list)
|
|
scope_files: List[Dict[str, Any]] = field(default_factory=list)
|
|
files: List[ContextFile] = field(default_factory=list)
|
|
manifests: List[Dict[str, Any]] = field(default_factory=list)
|
|
artifacts: List[ContextArtifact] = field(default_factory=list)
|
|
stage_status: Dict[str, Dict[str, Any]] = field(default_factory=dict)
|
|
logs: List[Dict[str, Any]] = field(default_factory=list)
|
|
warnings: List[Dict[str, Any]] = field(default_factory=list)
|
|
|
|
def to_dict(self) -> Dict[str, Any]:
|
|
data = asdict(self)
|
|
data["files"] = [f.to_dict() for f in self.files]
|
|
data["artifacts"] = [a.to_dict() for a in self.artifacts]
|
|
return data
|
|
|
|
def files_by_kind(self, kind: str) -> List[ContextFile]:
|
|
return [f for f in self.files if f.kind == kind]
|
|
|
|
def artifacts_by_stage(self, stage: str) -> List[ContextArtifact]:
|
|
return [a for a in self.artifacts if a.stage == stage]
|
|
|
|
|
|
class ProjectContextIndexer:
|
|
"""Build a structured, read-only index for one job."""
|
|
|
|
def __init__(
|
|
self,
|
|
project_root: Path | str = PROJECT_ROOT,
|
|
*,
|
|
allow_legacy_manifest_migration: bool = True,
|
|
):
|
|
self.project_root = Path(project_root)
|
|
self.workspace_root = self.project_root / "workspace"
|
|
self.allow_legacy_manifest_migration = allow_legacy_manifest_migration
|
|
|
|
def build(self, job_id: str, prefix: str = "") -> ProjectContextIndex:
|
|
workspace = self.workspace_root / job_id
|
|
canonical_input_dir = self.project_root / "input" / job_id
|
|
input_dir = canonical_input_dir if canonical_input_dir.exists() else workspace / "input"
|
|
final_dir = workspace / "final"
|
|
logs_dir = workspace / "logs"
|
|
prefix_species, prefix_family = _split_prefix(prefix)
|
|
|
|
index = ProjectContextIndex(
|
|
job_id=job_id,
|
|
workspace=str(workspace),
|
|
input_dir=str(input_dir),
|
|
final_dir=str(final_dir),
|
|
logs_dir=str(logs_dir),
|
|
family=prefix_family,
|
|
target_species=[prefix_species] if prefix_species and prefix_species.lower() not in {"multi", "target", "ref", "reference", "comparative"} else [],
|
|
)
|
|
|
|
self._load_config(index, input_dir, final_dir)
|
|
self._load_scope_files(index, final_dir)
|
|
self._scan_files(index, input_dir, "input")
|
|
self._scan_files(index, final_dir, "final")
|
|
self._scan_stage_dirs(index, workspace)
|
|
self._load_manifests(index, final_dir)
|
|
self._load_stage_status(index, job_id, prefix or self._best_prefix(index))
|
|
self._scan_logs(index, logs_dir)
|
|
self._derive_warnings(index)
|
|
return index
|
|
|
|
def _load_config(self, index: ProjectContextIndex, input_dir: Path, final_dir: Path) -> None:
|
|
config = find_config_file(input_dir)
|
|
if not config:
|
|
for candidate in [
|
|
final_dir / "config.xlsx",
|
|
final_dir / "config.autofill.draft.xlsx",
|
|
final_dir / "config_autofill.xlsx",
|
|
]:
|
|
if candidate.exists():
|
|
config = candidate
|
|
break
|
|
if not config:
|
|
return
|
|
index.config_path = str(config)
|
|
try:
|
|
rows, analysis = read_config_workbook(config)
|
|
except Exception as exc:
|
|
index.warnings.append({"code": "config_read_failed", "message": str(exc), "path": str(config)})
|
|
return
|
|
|
|
index.family = analysis.get("Family", "") or analysis.get("family", "") or index.family
|
|
index.target_species = self._merge(index.target_species, self._csv(analysis.get("Target_Species", "")))
|
|
index.reference_species = self._merge(index.reference_species, self._csv(analysis.get("Reference_Species", "")))
|
|
index.comparative_species = self._merge(index.comparative_species, self._csv(analysis.get("Comparative_Species", "")))
|
|
|
|
for row in rows:
|
|
species = str(row.get("Species_Code") or row.get("species") or row.get("Species") or "").strip()
|
|
family = str(row.get("Gene_Family") or row.get("family") or row.get("Family") or index.family or "").strip()
|
|
role = str(row.get("Species_Role") or row.get("role") or row.get("Role") or "").strip().lower()
|
|
if species and role in {"target", "targets"}:
|
|
index.target_species = self._merge(index.target_species, [species])
|
|
elif species and role in {"reference", "ref"}:
|
|
index.reference_species = self._merge(index.reference_species, [species])
|
|
elif species and role in {"comparative", "comp"}:
|
|
index.comparative_species = self._merge(index.comparative_species, [species])
|
|
typed_files: list[tuple[Any, str]] = []
|
|
file_type = str(row.get("File_Type") or row.get("file_type") or "").strip().lower()
|
|
original_name = row.get("Original_File_Name") or row.get("File_Name") or row.get("filename")
|
|
kind_by_file_type = {
|
|
"pep": "protein_fasta",
|
|
"protein": "protein_fasta",
|
|
"all_pep": "protein_fasta",
|
|
"family_pep": "protein_fasta",
|
|
"cds": "cds_fasta",
|
|
"family_cds": "cds_fasta",
|
|
"gff": "gff",
|
|
"gff3": "gff",
|
|
"gtf": "gff",
|
|
"genome": "genome_fasta",
|
|
}
|
|
if original_name and file_type in kind_by_file_type:
|
|
typed_files.append((original_name, kind_by_file_type[file_type]))
|
|
for key, kind in [
|
|
("protein", "protein_fasta"),
|
|
("pep", "protein_fasta"),
|
|
("cds", "cds_fasta"),
|
|
("gff", "gff"),
|
|
("gff3", "gff"),
|
|
("genome", "genome_fasta"),
|
|
]:
|
|
raw = row.get(key) or row.get(key.upper()) or row.get(key.capitalize())
|
|
if raw:
|
|
typed_files.append((raw, kind))
|
|
seen_config_paths: set[str] = set()
|
|
for raw, kind in typed_files:
|
|
path = Path(str(raw)).expanduser()
|
|
if not path.is_absolute():
|
|
path = input_dir / path
|
|
path_key = f"{kind}:{path}"
|
|
if path_key in seen_config_paths:
|
|
continue
|
|
seen_config_paths.add(path_key)
|
|
stat = _safe_stat(path)
|
|
index.files.append(ContextFile(
|
|
path=str(path),
|
|
rel_path=str(path.relative_to(index.workspace)) if stat["exists"] and self._is_relative_to(path, Path(index.workspace)) else str(path),
|
|
source="config",
|
|
kind=kind,
|
|
species=species,
|
|
family=family,
|
|
exists=stat["exists"],
|
|
size=stat["size"],
|
|
mtime=stat["mtime"],
|
|
))
|
|
|
|
def _load_scope_files(self, index: ProjectContextIndex, final_dir: Path) -> None:
|
|
for path in sorted(final_dir.glob("*.scope.json")):
|
|
data = _read_json(path)
|
|
if not data:
|
|
continue
|
|
try:
|
|
scope_data = read_scope(path)
|
|
except Exception:
|
|
scope_data = data
|
|
index.scope_files.append({
|
|
"path": str(path),
|
|
"scope_id": scope_data.get("scope_id", ""),
|
|
"scope_mode": scope_data.get("scope_mode", ""),
|
|
"family": scope_data.get("family", ""),
|
|
"target_species": scope_data.get("target_species", []),
|
|
"reference_species": scope_data.get("reference_species", []),
|
|
"comparative_species": scope_data.get("comparative_species", []),
|
|
"selected_species": scope_data.get("selected_species", []),
|
|
})
|
|
if scope_data.get("family") and not index.family:
|
|
index.family = str(scope_data["family"])
|
|
index.target_species = self._merge(index.target_species, scope_data.get("target_species") or [])
|
|
index.reference_species = self._merge(index.reference_species, scope_data.get("reference_species") or [])
|
|
index.comparative_species = self._merge(index.comparative_species, scope_data.get("comparative_species") or [])
|
|
|
|
def _scan_files(self, index: ProjectContextIndex, root: Path, source: str) -> None:
|
|
workspace = Path(index.workspace)
|
|
for path in _iter_files(root):
|
|
if path.name.endswith(".manifest.json"):
|
|
continue
|
|
kind = _infer_file_kind(path)
|
|
species, family = _infer_species_family(path)
|
|
stat = _safe_stat(path)
|
|
index.files.append(ContextFile(
|
|
path=str(path),
|
|
rel_path=str(path.relative_to(workspace)) if self._is_relative_to(path, workspace) else str(path),
|
|
source=source,
|
|
kind=kind,
|
|
species=species,
|
|
family=family,
|
|
exists=stat["exists"],
|
|
size=stat["size"],
|
|
mtime=stat["mtime"],
|
|
))
|
|
|
|
def _scan_stage_dirs(self, index: ProjectContextIndex, workspace: Path) -> None:
|
|
skip = {"input", "final", "logs"}
|
|
for child in sorted(workspace.iterdir()) if workspace.exists() else []:
|
|
if not child.is_dir() or child.name in skip:
|
|
continue
|
|
for path in _iter_files(child, limit=1000):
|
|
kind = _infer_file_kind(path)
|
|
species, family = _infer_species_family(path)
|
|
stat = _safe_stat(path)
|
|
index.files.append(ContextFile(
|
|
path=str(path),
|
|
rel_path=str(path.relative_to(workspace)) if self._is_relative_to(path, workspace) else str(path),
|
|
source=f"stage:{child.name}",
|
|
kind=kind,
|
|
species=species,
|
|
family=family,
|
|
exists=stat["exists"],
|
|
size=stat["size"],
|
|
mtime=stat["mtime"],
|
|
))
|
|
|
|
def _load_manifests(self, index: ProjectContextIndex, final_dir: Path) -> None:
|
|
for path in sorted(final_dir.glob("*.manifest.json")):
|
|
data = _read_json(path)
|
|
if not data:
|
|
index.warnings.append({"code": "manifest_read_failed", "path": str(path)})
|
|
continue
|
|
stage = str(data.get("stage") or "")
|
|
prefix = str(data.get("prefix") or "")
|
|
context = _manifest_stage_context(data)
|
|
species, family = _infer_species_family(path, data)
|
|
if family and not index.family:
|
|
index.family = family
|
|
for key in ("target_species", "reference_species", "comparative_species", "selected_species"):
|
|
values = data.get(key) or context.get(key)
|
|
if not isinstance(values, list):
|
|
continue
|
|
if key == "target_species":
|
|
index.target_species = self._merge(index.target_species, values)
|
|
elif key == "reference_species":
|
|
index.reference_species = self._merge(index.reference_species, values)
|
|
elif key == "comparative_species":
|
|
index.comparative_species = self._merge(index.comparative_species, values)
|
|
|
|
params = data.get("parameters") if isinstance(data.get("parameters"), dict) else {}
|
|
index.manifests.append({
|
|
"path": str(path),
|
|
"stage": stage,
|
|
"status": data.get("status", ""),
|
|
"prefix": prefix,
|
|
"output_prefix": data.get("output_prefix", ""),
|
|
"canonical_member_prefix": data.get("canonical_member_prefix", ""),
|
|
"legacy_output_prefix": data.get("legacy_output_prefix", ""),
|
|
"member_set_id": data.get("member_set_id", ""),
|
|
"result_group": data.get("result_group", ""),
|
|
"analysis_route": data.get("analysis_route", ""),
|
|
"scope_id": data.get("scope_id", ""),
|
|
"scope_mode": data.get("scope_mode", ""),
|
|
"family": family,
|
|
"species": species,
|
|
"target_species": data.get("target_species") or context.get("target_species") or [],
|
|
"reference_species": data.get("reference_species") or context.get("reference_species") or [],
|
|
"comparative_species": data.get("comparative_species") or context.get("comparative_species") or [],
|
|
"selected_species": data.get("selected_species") or context.get("selected_species") or [],
|
|
"stage_context": context,
|
|
"parameters": params,
|
|
})
|
|
self._index_manifest_artifacts(index, path, data, stage, species, family)
|
|
|
|
def _index_manifest_artifacts(
|
|
self,
|
|
index: ProjectContextIndex,
|
|
manifest_path: Path,
|
|
manifest: Dict[str, Any],
|
|
stage: str,
|
|
species: str,
|
|
family: str,
|
|
) -> None:
|
|
artifacts = manifest.get("artifacts") or {}
|
|
if isinstance(artifacts, dict):
|
|
iterable = artifacts.items()
|
|
elif isinstance(artifacts, list):
|
|
iterable = [(str(item.get("artifact") or item.get("name") or idx), item) for idx, item in enumerate(artifacts) if isinstance(item, dict)]
|
|
else:
|
|
return
|
|
|
|
workspace = Path(index.workspace)
|
|
for key, artifact in iterable:
|
|
if not isinstance(artifact, dict):
|
|
continue
|
|
path = self._artifact_path_from_manifest(artifact, workspace, Path(index.input_dir))
|
|
if path is None:
|
|
continue
|
|
kind = next(
|
|
(
|
|
value.strip()
|
|
for value in (artifact.get("type"), artifact.get("kind"))
|
|
if isinstance(value, str) and value.strip()
|
|
),
|
|
"",
|
|
)
|
|
if not kind:
|
|
role = _single_string(
|
|
artifact.get("role") or artifact.get("artifact") or key
|
|
)
|
|
capability = get_stage_capability(stage)
|
|
product = next(
|
|
(item for item in capability.provides if item.role == role),
|
|
None,
|
|
) if capability else None
|
|
kind = product.kind if product else ""
|
|
if not kind:
|
|
kind = _infer_file_kind(path)
|
|
exists = path.exists()
|
|
artifact_species = _single_string(artifact.get("species") or artifact.get("target_species") or species)
|
|
artifact_family = _single_string(artifact.get("family") or family)
|
|
artifact_prefix = _single_string(artifact.get("canonical_prefix") or artifact.get("prefix") or artifact.get("output_prefix"))
|
|
if artifact_prefix:
|
|
prefix_species, prefix_family = _split_prefix(artifact_prefix)
|
|
artifact_species = artifact_species or prefix_species
|
|
artifact_family = artifact_family or prefix_family
|
|
index.artifacts.append(ContextArtifact(
|
|
stage=stage,
|
|
key=str(key),
|
|
path=str(path),
|
|
kind=kind,
|
|
exists=exists,
|
|
source_manifest=str(manifest_path),
|
|
))
|
|
stat = _safe_stat(path)
|
|
index.files.append(ContextFile(
|
|
path=str(path),
|
|
rel_path=self._rel_path_for_display(path, workspace),
|
|
source=f"manifest:{stage}",
|
|
kind=kind,
|
|
species=artifact_species,
|
|
family=artifact_family,
|
|
exists=stat["exists"],
|
|
size=stat["size"],
|
|
mtime=stat["mtime"],
|
|
))
|
|
|
|
def _load_stage_status(self, index: ProjectContextIndex, job_id: str, prefix: str) -> None:
|
|
if not job_id or not prefix:
|
|
return
|
|
for stage in sorted(STAGE_INFO.keys()):
|
|
try:
|
|
report = get_stage_status(
|
|
job_id=job_id,
|
|
prefix=prefix,
|
|
stage=stage,
|
|
workspace_root=self.workspace_root,
|
|
allow_legacy_migration=self.allow_legacy_manifest_migration,
|
|
)
|
|
if report.status != "missing" or report.manifest_path:
|
|
index.stage_status[stage] = report.to_dict()
|
|
except Exception as exc:
|
|
index.stage_status[stage] = {"stage": stage, "status": "error", "errors": [str(exc)]}
|
|
|
|
def _scan_logs(self, index: ProjectContextIndex, logs_dir: Path) -> None:
|
|
for path in sorted(logs_dir.glob("*.log"))[-50:] if logs_dir.exists() else []:
|
|
stat = _safe_stat(path)
|
|
index.logs.append({
|
|
"path": str(path),
|
|
"name": path.name,
|
|
"size": stat["size"],
|
|
"mtime": stat["mtime"],
|
|
})
|
|
|
|
def _derive_warnings(self, index: ProjectContextIndex) -> None:
|
|
if not index.config_path:
|
|
index.warnings.append({"code": "config_missing", "message": "No config.xlsx/config workbook detected in input directory."})
|
|
|
|
for artifact in index.artifacts:
|
|
if not artifact.exists:
|
|
index.warnings.append({
|
|
"code": "artifact_missing",
|
|
"stage": artifact.stage,
|
|
"key": artifact.key,
|
|
"path": artifact.path,
|
|
})
|
|
|
|
for file in index.files:
|
|
if file.exists and file.size == 0:
|
|
index.warnings.append({
|
|
"code": "empty_file",
|
|
"kind": file.kind,
|
|
"path": file.path,
|
|
})
|
|
|
|
def _best_prefix(self, index: ProjectContextIndex) -> str:
|
|
prefixes = [m.get("prefix", "") for m in index.manifests if m.get("prefix")]
|
|
if prefixes:
|
|
return prefixes[-1]
|
|
if index.family and len(index.target_species) == 1:
|
|
return f"{index.target_species[0]}.{index.family}"
|
|
if index.family:
|
|
return f"Multi.{index.family}"
|
|
return ""
|
|
|
|
@staticmethod
|
|
def _csv(value: Any) -> List[str]:
|
|
if isinstance(value, list):
|
|
return [str(v).strip() for v in value if str(v).strip()]
|
|
return [x.strip() for x in re.split(r"[,;,;\s]+", str(value or "")) if x.strip()]
|
|
|
|
@staticmethod
|
|
def _merge(current: List[str], new_values: Iterable[Any]) -> List[str]:
|
|
seen = set()
|
|
merged: List[str] = []
|
|
for value in list(current) + [str(v).strip() for v in new_values if str(v).strip()]:
|
|
if value not in seen:
|
|
seen.add(value)
|
|
merged.append(value)
|
|
return merged
|
|
|
|
@staticmethod
|
|
def _artifact_path_from_manifest(artifact: Dict[str, Any], workspace: Path, input_dir: Path) -> Optional[Path]:
|
|
"""Map stale manifest paths back onto the current job roots when possible."""
|
|
raw_values = [
|
|
artifact.get("relpath_or_hint"),
|
|
artifact.get("path"),
|
|
artifact.get("full_path"),
|
|
artifact.get("file"),
|
|
]
|
|
candidates: List[Path] = []
|
|
for raw in raw_values:
|
|
text = str(raw or "").strip()
|
|
if not text:
|
|
continue
|
|
raw_path = Path(text).expanduser()
|
|
if raw_path.is_absolute():
|
|
candidates.append(raw_path)
|
|
else:
|
|
candidates.extend([
|
|
workspace / raw_path,
|
|
input_dir / raw_path,
|
|
workspace / "final" / raw_path,
|
|
])
|
|
|
|
for candidate in candidates:
|
|
mapped = ProjectContextIndexer._map_to_current_root(candidate, workspace, input_dir)
|
|
if mapped.exists():
|
|
return mapped
|
|
for candidate in candidates:
|
|
mapped = ProjectContextIndexer._map_to_current_root(candidate, workspace, input_dir)
|
|
if ProjectContextIndexer._is_relative_to(mapped, workspace) or ProjectContextIndexer._is_relative_to(mapped, input_dir):
|
|
return mapped
|
|
return candidates[0] if candidates else None
|
|
|
|
@staticmethod
|
|
def _map_to_current_root(path: Path, workspace: Path, input_dir: Path) -> Path:
|
|
for root in (workspace, input_dir):
|
|
if ProjectContextIndexer._is_relative_to(path, root):
|
|
try:
|
|
return root / path.resolve().relative_to(root.resolve())
|
|
except Exception:
|
|
return path
|
|
return path
|
|
|
|
@staticmethod
|
|
def _rel_path_for_display(path: Path, workspace: Path) -> str:
|
|
try:
|
|
return str(path.relative_to(workspace))
|
|
except Exception:
|
|
pass
|
|
try:
|
|
return str(path.resolve().relative_to(workspace.resolve()))
|
|
except Exception:
|
|
return str(path)
|
|
|
|
@staticmethod
|
|
def _is_relative_to(path: Path, parent: Path) -> bool:
|
|
try:
|
|
path.resolve().relative_to(parent.resolve())
|
|
return True
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def build_project_context(
|
|
job_id: str,
|
|
prefix: str = "",
|
|
project_root: Path | str = PROJECT_ROOT,
|
|
*,
|
|
allow_legacy_manifest_migration: bool = True,
|
|
) -> ProjectContextIndex:
|
|
return ProjectContextIndexer(
|
|
project_root,
|
|
allow_legacy_manifest_migration=allow_legacy_manifest_migration,
|
|
).build(job_id=job_id, prefix=prefix)
|
|
|
|
|
|
def main() -> int:
|
|
import argparse
|
|
|
|
parser = argparse.ArgumentParser(description="Build read-only bbav5 project context index")
|
|
parser.add_argument("job_id")
|
|
parser.add_argument("--prefix", default="")
|
|
parser.add_argument("--project-root", default=str(PROJECT_ROOT))
|
|
parser.add_argument("--output", default="")
|
|
args = parser.parse_args()
|
|
|
|
index = build_project_context(args.job_id, prefix=args.prefix, project_root=args.project_root)
|
|
data = index.to_dict()
|
|
text = json.dumps(data, indent=2, ensure_ascii=False)
|
|
if args.output:
|
|
Path(args.output).write_text(text + "\n", encoding="utf-8")
|
|
else:
|
|
print(text)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|