"""
pycsamt.agents.context
======================
:class:`ContextInputAgent` — Natural-language → pycsamt workflow config.
Accepts a free-text request such as::
"Load the EDI files in /data/WILLY_DATA/L22PLT, remove static shift,
run phase tensor analysis for periods 1e-4 to 1 s, and save a report
to /output/survey_report/"
and returns a validated :class:`~pycsamt.agents._base.AgentResult` whose
``data["config"]`` is a structured dict the :class:`AgentCoordinator` can
execute directly.
When no LLM API key is available the agent falls back to a robust set of
regex patterns that cover the most common request forms.
"""
from __future__ import annotations
import logging
import os
import re
import time
from pathlib import Path
from typing import Any
from ._base import AgentResult, BaseAgent
logger = logging.getLogger(__name__)
# ── constants ─────────────────────────────────────────────────────────────────
# Workflow descriptions, keyword table, and alias map all live in the single
# source of truth: pycsamt.agents._workflows (shared with the orchestrator).
from ._workflows import ( # noqa: E402
normalise_workflow as _normalise_workflow,
)
_KNOWN_INVERSION_CODES = {
"occam2d",
"modem",
"mare2dem",
"nlcg",
"zonal",
"smooth2d",
}
_COMPONENTS = {"xy", "yx", "xx", "yy", "all", "off_diagonal"}
# ── system prompt ─────────────────────────────────────────────────────────────
_SYSTEM_PROMPT = """You are an expert MT/AMT/CSAMT workflow configuration \
interpreter for pycsamt v2.
Given a natural-language processing request, extract a structured JSON \
configuration dictionary with the following schema (include only keys \
that are clearly mentioned or can be reasonably inferred):
{
"workflow": string — one of:
qc, static_shift, phase_analysis, forward,
inversion_prep, pre_inversion, inversion_eval,
interpretation, report, full,
ai_inversion, inv1d, inv2d, inv3d,
ensemble_inversion, joint_inversion,
modem, mare2dem, occam2d,
tipper, sensitivity, rotation,
freq_decimation, batch, comparison,
full_ai_workflow,
"data_path": string — absolute or relative path to EDI
file(s) or directory,
"output_dir": string — where to write results / figures,
"period_range": [T_min_seconds, T_max_seconds],
"component": string — "xy"|"yx"|"all"|"off_diagonal",
"station": string or null,
"inversion_code": string — "occam2d"|"modem"|"mare2dem"|null,
"depth_max_km": float or null,
"n_periods": int or null,
"verbose": bool
}
Rules:
- Choose "ai_inversion" for CNN / 1-D neural-network / deep-learning
inversion requests.
- Choose "inv2d" for U-Net / 2-D neural / profile AI inversion.
- Choose "inv3d" for GCN / graph-convolutional / 3-D AI inversion.
- Choose "ensemble_inversion" for ensemble / uncertainty / Bayesian.
- Choose "joint_inversion" for joint / multi-modal / TEM+MT.
- Choose "full_ai_workflow" when both AI inversion and full pipeline
are requested together.
- If a period range is given in frequency (Hz), convert to period
(s = 1/f).
- Preserve the full absolute path exactly as given.
- Return ONLY the JSON object — no markdown fences, no prose.
"""
# ── agent ─────────────────────────────────────────────────────────────────────
[docs]
class ContextInputAgent(BaseAgent):
"""Parse a natural-language MT workflow request into a structured config.
Parameters
----------
api_key : str or None
LLM API key. When ``None`` the regex fallback is used exclusively.
model, llm_provider : str
Passed to :class:`~pycsamt.agents._base.BaseAgent`.
Examples
--------
With an API key::
agent = ContextInputAgent(api_key="sk-ant-…")
result = agent.execute({
"request": "Load EDIs from /data/L22PLT, QC them, "
"period range 1e-4 to 1 s, save to /out/qc/"
})
cfg = result["config"]
# cfg["workflow"] == "qc"
# cfg["data_path"] == "/data/L22PLT"
# cfg["period_range"] == [0.0001, 1.0]
Without an API key (regex fallback)::
agent = ContextInputAgent() # no key → regex mode
result = agent.execute({"request": "…"})
"""
SYSTEM_PROMPT = _SYSTEM_PROMPT
def __init__(
self,
*,
api_key: str | None = None,
model: str | None = None,
llm_provider: str = "claude",
) -> None:
super().__init__(
"ContextInputAgent",
api_key=api_key,
model=model,
llm_provider=llm_provider,
section_preset="pseudosection",
)
[docs]
def execute(self, input_data: dict[str, Any]) -> AgentResult:
self._last_cost = 0.0
t0 = time.time()
# accept "request" or "text" or bare string
request: str = (
input_data.get("request")
or input_data.get("text")
or input_data.get("prompt")
or ""
)
if not request:
return AgentResult.failed(
"No request text found in input_data. "
"Pass {'request': '<your request>'}.",
elapsed=time.time() - t0,
)
# Regex runs first to anchor workflow type.
# LLM enriches params but must not override
# a regex-confident (non-qc) workflow.
regex_cfg = _regex_extract(request)
regex_wf = regex_cfg.get("workflow", "")
config: dict[str, Any] | None = None
llm_raw: str | None = None
if self.api_key:
llm_raw = self.query_llm(request)
if llm_raw:
config = self.extract_json(llm_raw)
if config:
self._log.debug("LLM extraction succeeded.")
# Regex wins when it found a specific
# workflow (not the qc default). LLM
# over-generalises (e.g. maps
# freq_decimation -> ai_inversion).
if regex_wf and regex_wf != "qc":
config["workflow"] = regex_wf
if config is None:
config = regex_cfg
if not self.api_key:
self._log.debug("No API key -- using regex.")
else:
self._log.warning("LLM failed; falling back to regex.")
# ── normalise & validate ──────────────────────────────────────────────
config = _normalise_config(config, request)
warnings = _validate_config(config)
# ── build validated WorkflowPlan ──────────────────────────
from ._workflow_plan import WorkflowPlan
plan = WorkflowPlan.from_config(
config,
request=request,
provider=(self.llm_provider if self.api_key else "offline"),
)
# ── optional LLM summary ──────────────────────────────────
interpretation: str | None = None
if self.api_key and config.get("data_path"):
interp_prompt = (
"Briefly summarise in 2 sentences what the "
"following pycsamt workflow configuration "
f"will do:\n{config}"
)
interpretation = self.query_llm(
interp_prompt,
system_message=(
"You are a concise MT data processing "
"assistant. Reply in plain English, no "
"bullet points."
),
max_tokens=120,
)
elapsed = time.time() - t0
summary = (
f"Config extracted: "
f"workflow={config.get('workflow', '?')!r}, "
f"path={config.get('data_path', '?')!r}."
)
return AgentResult(
status=("success" if config.get("data_path") else "needs_review"),
summary=summary,
data={
"config": config,
"workflow_plan": plan,
"raw_request": request,
"llm_raw": llm_raw,
},
warnings=warnings,
llm_interpretation=interpretation,
elapsed_seconds=elapsed,
cost_estimate_usd=self._last_cost,
)
# ── regex extraction ──────────────────────────────────────────────────────────
def _regex_extract(text: str) -> dict[str, Any]:
"""Extract a workflow config from *text* using regex patterns.
This covers the most common natural-language patterns without LLM.
"""
cfg: dict[str, Any] = {}
t = text.lower()
# ── data path ─────────────────────────────────────────────────────────────
# Path must start with / or ~ (absolute / home-relative) to avoid matching
# plain words like "EDI" that appear before the actual path.
path_patterns = [
# keyword immediately followed by an absolute/home path
r"(?:load(?:ing)?|read(?:ing)?|from|on|in|at|path[:\s]+)"
r'\s+["\']?([/~][\w/\\\-\.]+)["\']?',
# quoted absolute path (any extension)
r'["\']([/~][\w/\\\-\.]+)["\']',
# bare absolute path — must NOT be preceded by a
# word char or slash (prevents matching inner components
# of a path like the "/willy" in "/data/willy")
r"(?<![/\w])([/~][\w/\\\-\.]{5,})",
]
for pat in path_patterns:
m = re.search(pat, text, re.IGNORECASE)
if m:
candidate = m.group(1).rstrip(".,;)")
if len(candidate) > 4:
# os.path.expanduser (not Path) so the user's original
# separators survive on Windows — Path() would rewrite
# a POSIX "/data/x" to "\data\x".
cfg["data_path"] = os.path.expanduser(candidate)
break
# ── output directory ──────────────────────────────────────────────────────
m = re.search(
r'(?:save|output|write|report)\s+(?:to\s+)?["\']?([/~\w][\w/\\\-\.]+)["\']?',
text,
re.IGNORECASE,
)
if m:
cfg["output_dir"] = os.path.expanduser(m.group(1))
# ── workflow ──────────────────────────────────────────────────────────
# Single source of truth for the keyword table lives in
# pycsamt.agents._workflows (shared with the orchestrator).
# Leave the key unset when nothing matches so _normalise_config
# applies the qc default.
from ._workflows import classify_workflow
_wf = classify_workflow(text)
if _wf:
cfg["workflow"] = _wf
# ── period range ──────────────────────────────────────────────────────────
# e.g. "1e-4 to 1 s", "0.0001 – 1.0 s", "T_min=0.001, T_max=10"
period_pat = re.compile(
r"(?:period[s]?\s*(?:range|from|between|:)?\s*)"
r"([\d\.e\+\-]+)\s*(?:to|–|-|,)\s*([\d\.e\+\-]+)",
re.IGNORECASE,
)
m = period_pat.search(text)
if m:
try:
cfg["period_range"] = [float(m.group(1)), float(m.group(2))]
except ValueError:
pass
# frequency → period conversion
freq_pat = re.compile(
r"(?:freq(?:uency)?\s*(?:range|from|:)?\s*)"
r"([\d\.e\+\-]+)\s*(?:to|–|-|,)\s*([\d\.e\+\-]+)\s*(?:hz)?",
re.IGNORECASE,
)
m = freq_pat.search(text)
if m and "period_range" not in cfg:
try:
f1, f2 = float(m.group(1)), float(m.group(2))
if f1 > 0 and f2 > 0:
cfg["period_range"] = sorted([1.0 / f1, 1.0 / f2])
except ValueError:
pass
# ── component ─────────────────────────────────────────────────────────────
comp_pat = re.compile(
r"\b(xy|yx|xx|yy|off[-_]diagonal|all(?:\s+components?)?)\b",
re.IGNORECASE,
)
m = comp_pat.search(text)
if m:
raw = m.group(1).lower().replace(" ", "_").replace("-", "_")
cfg["component"] = (
"off_diagonal" if "diagonal" in raw else raw.split("_")[0]
)
# ── inversion code ────────────────────────────────────────────────────────
for code in _KNOWN_INVERSION_CODES:
if code in t:
cfg["inversion_code"] = code
break
# ── depth ─────────────────────────────────────────────────────────────────
m = re.search(
r"depth[_\s]*(?:max)?\s*[=:]\s*([\d\.]+)\s*(km|m)?",
text,
re.IGNORECASE,
)
if m:
val = float(m.group(1))
unit = (m.group(2) or "km").lower()
cfg["depth_max_km"] = val if unit == "km" else val / 1000.0
# ── station ───────────────────────────────────────────────────────────────
m = re.search(
r"station[s]?\s*[=:\s]+([A-Za-z0-9_\-]+)",
text,
re.IGNORECASE,
)
if m:
cfg["station"] = m.group(1)
return cfg
# ── normalise & validate ──────────────────────────────────────────────────────
# _WORKFLOW_ALIASES is imported at module top from pycsamt.agents._workflows
# (single source of truth, shared with the orchestrator).
def _normalise_config(
cfg: dict[str, Any], original_text: str
) -> dict[str, Any]:
"""Fill defaults, normalise aliases, clean up types."""
# workflow alias normalisation (shared registry)
cfg["workflow"] = _normalise_workflow(cfg.get("workflow", ""))
# keep unrecognised workflows as-is — the orchestrator will
# report a proper error rather than silently running QC
if not cfg["workflow"]:
cfg["workflow"] = "qc"
# path normalisation — os.path.expanduser preserves the user's
# separators (Path() would rewrite POSIX paths to backslashes on
# Windows); only "~" is expanded.
if "data_path" in cfg:
cfg["data_path"] = os.path.expanduser(str(cfg["data_path"]))
if "output_dir" in cfg:
cfg["output_dir"] = os.path.expanduser(str(cfg["output_dir"]))
else:
cfg["output_dir"] = str(Path("pycsamt_agent_output").resolve())
# period_range
if "period_range" in cfg:
pr = cfg["period_range"]
if isinstance(pr, (list, tuple)) and len(pr) == 2:
lo, hi = float(pr[0]), float(pr[1])
cfg["period_range"] = [min(lo, hi), max(lo, hi)]
# component normalisation
comp = str(cfg.get("component", "xy")).lower()
if comp not in _COMPONENTS:
comp = "xy"
cfg["component"] = comp
# boolean verbose
cfg.setdefault("verbose", True)
cfg.setdefault("station", None)
cfg.setdefault("inversion_code", None)
return cfg
def _validate_config(cfg: dict[str, Any]) -> list[str]:
"""Return a list of warning messages for issues found in *cfg*."""
warnings: list[str] = []
data_path = cfg.get("data_path")
if not data_path:
warnings.append(
"No data path found in the request. "
"Please supply a path to EDI files or a directory."
)
elif not Path(data_path).exists():
warnings.append(
f"Data path does not exist on disk: {data_path!r}. "
"Check the path and try again."
)
pr = cfg.get("period_range")
if pr is not None:
lo, hi = pr
if lo <= 0:
warnings.append(
f"period_range lower bound must be > 0; got {lo}."
)
if hi <= lo:
warnings.append(
f"period_range upper bound {hi} ≤ lower bound {lo}."
)
return warnings
__all__ = ["ContextInputAgent"]