Source code for pycsamt.agents.pipeline_agent

"""
pycsamt.agents.pipeline_agent
==============================

:class:`PipelineAgent` — LLM-assisted MT processing pipeline selection and
interpretation.

Bridges :mod:`pycsamt.pipeline` with the agent framework:

* Accepts a natural-language ``"request"`` key → LLM recommends preset/steps
* Builds and runs the :class:`~pycsamt.pipeline.Pipeline`
* LLM interprets the :class:`~pycsamt.pipeline.PipelineResult` as a narrative

Available presets: ``"basic_qc"``, ``"noise_reduction"``, ``"full_processing"``,
``"tensor_analysis"``, ``"dimensionality_filter"``, ``"publication_ready"``.
"""

from __future__ import annotations

import time
from typing import Any

from ._base import AgentResult, BaseAgent

# ── LLM system messages ───────────────────────────────────────────────────────

_SYSTEM_RECOMMEND = """\
You are an expert in magnetotelluric (MT/AMT/CSAMT) data processing pipelines.
Given a user description of their dataset and processing goals, recommend the most
appropriate pipeline configuration as a JSON object with exactly these fields:
  "preset"          : one of "basic_qc", "noise_reduction", "full_processing",
                      "tensor_analysis", "dimensionality_filter",
                      "publication_ready", or null
  "steps"           : list of step codes if preset is null
                      (e.g. ["NR001","FREQ002","SS001"]) — empty list otherwise
  "param_overrides" : dict of {step_code: {param: value}} for parameter tweaks,
                      e.g. {"NR001": {"mains_hz": 60}} for a 60 Hz grid
  "rationale"       : one sentence explaining the recommendation

Reply with ONLY valid JSON — no markdown fences, no text outside the JSON.
"""

_SYSTEM_INTERPRET = """\
You are an expert MT data processing specialist reviewing pipeline execution results.
Given a processing summary, write 3–4 sentences that:
1. Describe the overall data quality change (stations retained, step errors if any).
2. Highlight which steps were most impactful or took the most time.
3. Comment on whether the stated processing objectives appear to have been met.
4. Recommend a concrete follow-up action (further correction, QC plot review,
   or readiness for inversion).
Reply in plain English. No bullet points or markdown.
"""


[docs] class PipelineAgent(BaseAgent): """LLM-assisted MT processing pipeline selection and interpretation. Two modes of operation: **Guided mode** — pass ``"request"`` in *input_data*. The LLM recommends a preset and any parameter overrides, then the pipeline is built and run automatically. **Direct mode** — pass ``"preset"`` (name string) *or* ``"steps"`` (list of step codes) in *input_data*. No pre-run LLM call is made; the pipeline is built directly from those instructions. In both modes the post-run LLM call interprets the :class:`~pycsamt.pipeline.PipelineResult` as a narrative. Parameters ---------- api_key, model, llm_provider : str Standard LLM configuration inherited from :class:`~._base.BaseAgent`. preset : str or None Default preset name used when *input_data* contains neither ``"preset"`` nor ``"steps"`` nor ``"request"``. Defaults to ``"basic_qc"`` (safe first-pass). param_overrides : dict or None Default parameter overrides applied on top of any preset or step list. Format: ``{step_code: {param: value}}``. Input keys ---------- ``sites`` / ``path`` : Sites or str Raw MT/AMT sites to process. ``request`` : str, optional Natural-language description of dataset and goals. Triggers a pre-run LLM call that recommends preset + parameter overrides. ``preset`` : str, optional Named preset — overrides constructor default. ``steps`` : list of str, optional Explicit ordered list of step codes. Ignored when ``"preset"`` is set. ``param_overrides`` : dict, optional Per-step parameter overrides — merged on top of constructor defaults. ``output_dir`` : str or None, optional Root directory for pipeline output files (EDI, plots, YAML config). Output data keys ---------------- ``sites_out`` Processed Sites — ready for downstream agents ``pipeline_result`` :class:`~pycsamt.pipeline.PipelineResult` object ``preset_used`` Name of the preset that was run (or ``"custom"``) ``steps_run`` List of step code strings that were executed ``n_sites_in`` Number of input stations ``n_sites_out`` Number of stations after processing ``n_errors`` Number of steps that raised an error ``recommendation`` Dict returned by LLM pre-run call (or ``None``) Examples -------- Guided mode:: agent = PipelineAgent() result = agent.execute({ "sites": sites, "request": "50 Hz grid noise, possible static shift, Occam2D target", "output_dir": "willy_pipeline/", }) processed = result["sites_out"] print(result.llm_interpretation) Direct mode:: agent = PipelineAgent(preset="full_processing") result = agent.execute({ "sites": sites, "param_overrides": {"NR001": {"mains_hz": 60}}, }) Chain with :class:`~.occam2d_agent.Occam2DAgent` via :class:`~.coordinator.AgentCoordinator`:: from pycsamt.agents import AgentCoordinator, PipelineAgent, Occam2DAgent coord = AgentCoordinator("willy_full") coord.add_step("pipeline", PipelineAgent(preset="full_processing"), input_fn=lambda r: {"sites": r["load"].data["sites"]}) coord.add_step("invert", Occam2DAgent(), input_fn=lambda r: {"sites": r["pipeline"].data["sites_out"]}) """ SYSTEM_PROMPT = _SYSTEM_INTERPRET def __init__( self, *, api_key: str | None = None, model: str | None = None, llm_provider: str = "claude", preset: str | None = "basic_qc", param_overrides: dict[str, dict] | None = None, ) -> None: super().__init__( "PipelineAgent", api_key=api_key, model=model, llm_provider=llm_provider, section_preset="pseudosection", ) self.preset = preset self.param_overrides = param_overrides or {} # ── execute ───────────────────────────────────────────────────────────────
[docs] def execute(self, input_data: dict[str, Any]) -> AgentResult: self._last_cost = 0.0 t0 = time.time() warnings_list: list[str] = [] # ── resolve sites ───────────────────────────────────────────────────── from ..emtools._core import ensure_sites sites_raw = input_data.get("sites") or input_data.get("path") if sites_raw is None: return AgentResult.failed( "No 'sites' or 'path' provided.", elapsed=time.time() - t0 ) try: sites = ensure_sites(sites_raw, verbose=0) except Exception as exc: return AgentResult.failed(str(exc), elapsed=time.time() - t0) n_sites_in = _count(sites) # ── resolve effective config from input_data ────────────────────────── # Priority: explicit "preset" > explicit "steps" > constructor default if "preset" in input_data: effective_preset = input_data["preset"] effective_steps: list[str] = [] elif "steps" in input_data: effective_preset = None effective_steps = list(input_data["steps"]) else: effective_preset = self.preset effective_steps = [] effective_overrides: dict[str, dict] = { **self.param_overrides, **input_data.get("param_overrides", {}), } output_dir = input_data.get("output_dir") # ── guided mode: LLM pre-run recommendation ─────────────────────────── recommendation: dict | None = None request = input_data.get("request") if request: rec_text = self.query_llm( str(request), system_message=_SYSTEM_RECOMMEND, temperature=0.1, max_tokens=512, ) if rec_text: parsed = self.extract_json(rec_text) if isinstance(parsed, dict): recommendation = parsed effective_preset = ( parsed.get("preset") or effective_preset ) effective_steps = parsed.get("steps") or effective_steps merged_ov = parsed.get("param_overrides") or {} effective_overrides = {**effective_overrides, **merged_ov} else: warnings_list.append( "LLM recommendation could not be parsed as JSON; " "falling back to default preset." ) # ── build pipeline ──────────────────────────────────────────────────── from ..pipeline import ( PYCSAMT_PIPE, Pipeline, Step, ) try: if effective_preset: pipe = Pipeline.from_preset(effective_preset) preset_used = effective_preset elif effective_steps: pipe = Pipeline( [(code, Step(code)) for code in effective_steps], name="custom", ) preset_used = "custom" else: # last resort: basic_qc is always safe pipe = Pipeline.from_preset("basic_qc") preset_used = "basic_qc" warnings_list.append( "No preset or steps specified; defaulting to 'basic_qc'." ) except Exception as exc: return AgentResult.failed( f"Could not build pipeline: {exc}", elapsed=time.time() - t0 ) # ── apply param overrides ───────────────────────────────────────────── if effective_overrides: for label, step in list(pipe._steps): code = step.spec.code if code in effective_overrides: try: merged_params = { **step.params, **effective_overrides[code], } pipe.replace(label, Step(code, **merged_params)) except Exception as exc: warnings_list.append( f"Could not apply override for step {code!r}: {exc}" ) # ── run pipeline (suppress progress to stdout) ──────────────────────── try: with PYCSAMT_PIPE.context(show_progress=False): pipe_result = pipe.run( sites, outdir=output_dir, save_plots=output_dir is not None, save_edis=output_dir is not None, save_report=output_dir is not None, ) except Exception as exc: return AgentResult.failed( f"Pipeline run failed: {exc}", hint="Check that all step codes are valid and Sites is non-empty.", elapsed=time.time() - t0, ) n_sites_out = _count(pipe_result.sites_out) steps_run = [sr.step_code for sr in pipe_result.step_results] n_errors = pipe_result.n_errors # ── build summary text for LLM ──────────────────────────────────────── step_lines = "\n".join( f" [{sr.step_code}] {sr.step_label} " f"{'OK' if sr.ok else 'ERR'} " f"{sr.n_sites_in}{sr.n_sites_out} sites " f"{sr.elapsed_sec:.2f}s" + (f" error={sr.error}" if not sr.ok else "") for sr in pipe_result.step_results ) request_line = f"User request : {request}" if request else "" summary_text = ( f"Pipeline : {preset_used}\n" f"Sites : {n_sites_in} in → {n_sites_out} out\n" f"Steps : {len(steps_run)} " f"({len(steps_run) - n_errors} OK, {n_errors} errors)\n" f"Elapsed : {pipe_result.elapsed_sec:.2f}s\n" ) if request_line: summary_text = request_line + "\n" + summary_text summary_text += "\nStep details:\n" + step_lines # ── LLM post-run interpretation ─────────────────────────────────────── llm_interp = self.query_llm( summary_text, system_message=_SYSTEM_INTERPRET, temperature=0.2, max_tokens=512, ) # ── surface pipeline warnings ───────────────────────────────────────── for sr in pipe_result.step_results: if not sr.ok: warnings_list.append( f"Step {sr.step_code!r} ({sr.step_label}) raised " f"{type(sr.error).__name__}: {sr.error}" ) elapsed = time.time() - t0 status = "success" if n_errors == 0 else "needs_review" brief = ( f"Pipeline '{preset_used}' ran {len(steps_run)} steps in " f"{pipe_result.elapsed_sec:.1f}s: " f"{n_sites_in}{n_sites_out} sites, {n_errors} errors." ) return AgentResult( status=status, summary=brief, data={ "sites_out": pipe_result.sites_out, "pipeline_result": pipe_result, "preset_used": preset_used, "steps_run": steps_run, "n_sites_in": n_sites_in, "n_sites_out": n_sites_out, "n_errors": n_errors, "recommendation": recommendation, }, warnings=warnings_list, llm_interpretation=llm_interp, elapsed_seconds=elapsed, cost_estimate_usd=self._last_cost, )
# ── helpers ─────────────────────────────────────────────────────────────────── def _count(sites: Any) -> int: """Return number of stations in *sites*, tolerating any Sites-like object.""" for attr in ("n_sites", "n_stations", "__len__"): try: val = getattr(sites, attr, None) if callable(val): return int(val()) if val is not None: return int(val) except Exception: pass try: return len(sites) except Exception: return 0