"""Preprocessor agent (M6): the LLM decides a column mapping; code applies it (golden rule #6). Input per file = filename + header + first 5 rows. The LLM returns a `ColumnMapping` (canonical field -> source column); `core.parse.apply_mapping` transforms the full table — the LLM never rewrites data rows. With no key (or low confidence / a mapping that won't apply) we fall back to the default clusterProfiler mapping, and only flag `needs_confirmation` when even that can't resolve the required fields — so the deterministic core stays usable without an LLM. """ from __future__ import annotations import json from pathlib import Path from typing import Optional, Union import pandas as pd from agents.llm import Provider from core.parse import apply_mapping, default_mapping, read_table from core.schema import ColumnMapping, GseaResult, MappingReport FileInput = Union[str, Path] SYSTEM = ( "You map the columns of a GSEA results table to a canonical schema. Return ONLY a JSON object " "with keys: pathway, nes, padj, pval, size, leading_edge, collection, suggested_name, confidence. " "pathway/nes/padj/pval/size/leading_edge must each be an EXACT column name from the provided " "header, or null if absent. 'pathway' is the gene-set/term name; 'nes' is the signed normalized " "enrichment score; 'padj' is the adjusted p-value (FDR). For clusterProfiler output the " "leading-edge GENES are in 'core_enrichment', NOT in a column literally named 'leading_edge' " "(that one holds a stats string). 'confidence' is your certainty from 0 to 1." ) def _preview(df: pd.DataFrame, name: str, n: int = 5) -> str: return json.dumps( {"filename": name, "columns": list(df.columns), "first_rows": df.head(n).to_dict(orient="records")}, default=str, )[:4000] def _parse_json(text: str) -> dict: try: return json.loads(text) except json.JSONDecodeError: i, j = text.find("{"), text.rfind("}") if i != -1 and j > i: return json.loads(text[i : j + 1]) raise def _llm_mapping(df: pd.DataFrame, name: str, llm: Provider) -> Optional[ColumnMapping]: user = f"Header and sample rows:\n{_preview(df, name)}\nReturn the JSON mapping." resp = llm.chat( [{"role": "system", "content": SYSTEM}, {"role": "user", "content": user}], json_mode=True, ) try: return ColumnMapping.model_validate(_parse_json(resp.text)) except Exception: return None def preprocess_one( path: FileInput, llm: Optional[Provider] = None, confidence_floor: float = 0.5 ) -> tuple[GseaResult, MappingReport]: path = Path(path) name = path.stem df = read_table(path) mapped = _llm_mapping(df, name, llm) if llm is not None else None if mapped is not None and mapped.confidence >= confidence_floor: mapping, collection = mapped.to_mapping(), mapped.collection result_name, source, confidence = (mapped.suggested_name or name), "llm", mapped.confidence else: mapping, collection = default_mapping(), None result_name, source = name, "default" confidence = mapped.confidence if mapped is not None else 1.0 try: result = apply_mapping(df, mapping=mapping, name=result_name, collection=collection) return result, MappingReport( name=result.name, mapping=mapping, collection=result.collection, confidence=confidence, source=source, needs_confirmation=False, ) except ValueError as err: # An LLM mapping that won't apply: retry with the deterministic default before giving up. if source != "default": try: result = apply_mapping(df, mapping=default_mapping(), name=name) return result, MappingReport( name=result.name, mapping=default_mapping(), collection=result.collection, confidence=confidence, source="default", needs_confirmation=False, message=f"LLM mapping did not apply ({err}); used the default mapping.", ) except ValueError as err2: err = err2 # Even the default can't resolve required fields → surface for manual confirmation. return GseaResult(name=name, rows=[]), MappingReport( name=name, mapping=mapping, collection=None, confidence=confidence, source=source, needs_confirmation=True, message=f"{err}. Columns seen: {list(df.columns)}", ) def preprocess( files: list[FileInput], llm: Optional[Provider] = None, confidence_floor: float = 0.5 ) -> list[tuple[GseaResult, MappingReport]]: """Map and parse each uploaded file → (GseaResult, MappingReport).""" return [preprocess_one(f, llm=llm, confidence_floor=confidence_floor) for f in files]