Source code for pycsamt.agents.report

"""
pycsamt.agents.report
=====================

:class:`ReportAgent` — Assemble all agent results into a survey report.

The report is built in three formats:

* **Markdown** — always produced; human-readable plain text + embedded image
  paths.
* **HTML** — produced when ``markdown`` package is installed.
* **PDF** — produced when ``weasyprint`` or ``pdfkit`` is installed.

The agent queries the LLM once per section (optional) to write a narrative
paragraph, then assembles everything into a structured document:

    1. Title & metadata
    2. Data loading summary
    3. QC summary + figure
    4. Static-shift correction summary + figure
    5. Phase tensor analysis summary + figures
    6. Forward modelling summary + figure
    7. Recommendations
"""

from __future__ import annotations

import os
import time
from datetime import datetime
from pathlib import Path
from typing import Any

from ._base import AgentResult, BaseAgent

_SYSTEM_PROMPT = """\
You are a geophysics technical writer specialising in MT surveys.
Write clear, concise report sections in formal scientific English.
Use complete sentences. No markdown headings inside your response.
Keep each section to 3–5 sentences.
"""

# ── section writers ───────────────────────────────────────────────────────────

_SECTION_PROMPTS = {
    "loading": (
        "Write a 3-sentence data loading section for an MT survey report. "
        "Include: number of stations, period range, and data completeness. "
        "Data: {data}"
    ),
    "qc": (
        "Write a 3-sentence QC section for an MT survey report. "
        "Include: overall quality rating, number of flagged stations, "
        "and recommended remediation. Data: {data}"
    ),
    "static_shift": (
        "Write a 3-sentence static-shift section for an MT survey report. "
        "Include: detection method, mean correction magnitude, and stations "
        "most affected. Data: {data}"
    ),
    "phase_analysis": (
        "Write a 4-sentence phase tensor section for an MT survey report. "
        "Include: dominant dimensionality, consensus strike, 3-D fraction, "
        "and whether rotation is recommended. Data: {data}"
    ),
    "forward": (
        "Write a 3-sentence forward modelling section for an MT survey report. "
        "Include: model description, synthetic response quality, and RMS "
        "misfit if available. Data: {data}"
    ),
    "recommendations": (
        "Write a 4-sentence recommendations section for an MT survey report. "
        "Cover: next processing step, inversion strategy, areas needing "
        "additional data, and confidence in the results. Data: {data}"
    ),
}


[docs] class ReportAgent(BaseAgent): """Generate a structured MT survey report from agent results. Parameters ---------- api_key, model, llm_provider : str report_title : str Title for the report. formats : list of {"md", "html", "pdf"} Output formats. Default ``["md", "html"]``. Input keys ---------- ``results`` : dict Keyed by agent step name → :class:`AgentResult`. Expected keys: ``"load"``, ``"qc"``, ``"static_shift"``, ``"phase_analysis"``, ``"forward"`` (all optional). ``output_dir`` : str ``title`` : str, optional — overrides constructor default Output data keys ---------------- ``report_md`` str — full markdown text ``report_html`` str or None ``report_path_md`` str — path to .md file ``report_path_html`` str or None ``sections`` dict — section text keyed by name Examples -------- >>> agent = ReportAgent(api_key="sk-ant-…") >>> result = agent.execute({ ... "results": {"load": load_result, "qc": qc_result}, ... "output_dir": "/out/report", ... "title": "WILLY_DATA AMT Survey — L22PLT", ... }) >>> print(result["report_path_md"]) /out/report/survey_report.md """ SYSTEM_PROMPT = _SYSTEM_PROMPT def __init__( self, *, api_key: str | None = None, model: str | None = None, llm_provider: str = "claude", report_title: str = "MT/AMT Survey Report", formats: list[str] | None = None, ) -> None: super().__init__( "ReportAgent", api_key=api_key, model=model, llm_provider=llm_provider, section_preset="publication", ) self.report_title = report_title self.formats = [f.lower() for f in (formats or ["md", "html"])]
[docs] def execute(self, input_data: dict[str, Any]) -> AgentResult: self._last_cost = 0.0 t0 = time.time() warnings: list[str] = [] output_dir = input_data.get("output_dir", "pycsamt_report") title = input_data.get("title", self.report_title) results = input_data.get("results", {}) os.makedirs(output_dir, exist_ok=True) # ── copy figures to report dir ──────────────────────────────────────── fig_refs: dict[str, str] = {} # section_name → relative image path for step_name, res in results.items(): if not isinstance(res, AgentResult): continue fps = res.get("figure_paths") or {} if isinstance(fps, dict): for fig_name, src_path in fps.items(): if src_path and Path(src_path).exists(): dst = os.path.join( output_dir, f"{step_name}_{fig_name}.png" ) try: import shutil shutil.copy2(src_path, dst) fig_refs[f"{step_name}_{fig_name}"] = ( os.path.basename(dst) ) except Exception as exc: warnings.append( f"Could not copy figure {src_path}: {exc}" ) # ── build section texts ─────────────────────────────────────────────── sections: dict[str, str] = {} sections["loading"] = self._section_loading(results, warnings) sections["qc"] = self._section_qc(results, warnings) sections["static_shift"] = self._section_ss(results, warnings) sections["phase_analysis"] = self._section_pt(results, warnings) sections["forward"] = self._section_fwd(results, warnings) sections["recommendations"] = self._section_rec( results, sections, warnings ) # ── assemble markdown ───────────────────────────────────────────────── md = _build_markdown(title, sections, fig_refs, results) # ── write markdown ──────────────────────────────────────────────────── md_path = os.path.join(output_dir, "survey_report.md") try: Path(md_path).write_text(md, encoding="utf-8") except Exception as exc: warnings.append(f"Could not write markdown report: {exc}") md_path = None # ── write HTML ──────────────────────────────────────────────────────── html: str | None = None html_path: str | None = None if "html" in self.formats: try: import markdown as _md_pkg html = _md_pkg.markdown( md, extensions=["tables", "fenced_code"] ) html = _HTML_TEMPLATE.format(title=title, body=html) html_path = os.path.join(output_dir, "survey_report.html") Path(html_path).write_text(html, encoding="utf-8") except ImportError: warnings.append( "markdown package not installed; HTML report skipped. " "Install with: pip install markdown" ) except Exception as exc: warnings.append(f"HTML report failed: {exc}") elapsed = time.time() - t0 n_sec = sum(1 for v in sections.values() if v.strip()) return AgentResult( status="success", summary=( f"Report generated: {n_sec} sections, " f"{len(fig_refs)} figure(s). " f"Saved to {output_dir}." ), data={ "report_md": md, "report_html": html, "report_path_md": md_path, "report_path_html": html_path, "sections": sections, "figure_refs": fig_refs, "output_dir": output_dir, }, warnings=warnings, elapsed_seconds=elapsed, cost_estimate_usd=self._last_cost, )
# ── section builders ────────────────────────────────────────────────────── def _llm_section(self, section_key: str, data_str: str) -> str: """Query the LLM for one report section, or return a fallback.""" if not self.api_key: return "" prompt = _SECTION_PROMPTS[section_key].format(data=data_str) text = self.query_llm(prompt, max_tokens=200) return text or "" def _section_loading(self, results: dict, warnings: list) -> str: load_r = results.get("load") or results.get("loader") if load_r is None: return "No data loading results available." n_st = load_r.get("n_stations", "?") stats = load_r.get("summary_stats") or {} t_min = stats.get("global_t_min_s") t_max = stats.get("global_t_max_s") per_str = ( f"{t_min:.2e}{t_max:.2e} s" if t_min and t_max else "unknown" ) base = ( f"{n_st} stations were loaded. " f"Period range: {per_str}. " f"Mean QC score: {stats.get('mean_qc_score', '?'):.0f}/100." ) if self.api_key: llm_text = self._llm_section("loading", str(stats)) return llm_text or base return base def _section_qc(self, results: dict, warnings: list) -> str: qc_r = results.get("qc") or results.get("data_qc") if qc_r is None: return "No QC analysis results available." n_flagged = qc_r.get("n_flagged", 0) flagged = qc_r.get("flagged_stations", []) base = ( f"QC analysis identified {n_flagged} station(s) below threshold. " + (f"Flagged: {', '.join(flagged)}. " if flagged else "") + "All other stations met minimum data quality criteria." ) if self.api_key: llm_text = self._llm_section( "qc", f"n_flagged={n_flagged}, flagged={flagged}" ) return llm_text or base return base def _section_ss(self, results: dict, warnings: list) -> str: ss_r = results.get("static_shift") or results.get("ss") if ss_r is None: return "No static-shift correction results available." ds = ss_r.get("delta_stats") or {} base = ( f"Static-shift correction was applied. " f"Mean correction: {ds.get('mean', '?'):.3f} log₁₀(Ω·m). " f"{ds.get('n_shifted', '?')} station(s) showed significant shift (>0.05)." ) if self.api_key: llm_text = self._llm_section("static_shift", str(ds)) return llm_text or base return base def _section_pt(self, results: dict, warnings: list) -> str: pt_r = results.get("phase_analysis") or results.get("pt") if pt_r is None: return "No phase tensor analysis results available." n_1d = pt_r.get("n_1d", 0) n_2d = pt_r.get("n_2d", 0) n_3d = pt_r.get("n_3d", 0) st = pt_r.get("strike_consensus", float("nan")) iqr = pt_r.get("strike_iqr", float("nan")) base = ( f"Phase tensor analysis found predominantly " f"{'1-D' if n_1d >= n_2d and n_1d >= n_3d else '2-D' if n_2d >= n_3d else '3-D'} " f"character ({n_1d}/{n_2d}/{n_3d} 1-D/2-D/3-D observations). " f"Consensus geoelectric strike: {st:.1f}° ± {iqr:.1f}°." ) if self.api_key: data_str = f"1D={n_1d}, 2D={n_2d}, 3D={n_3d}, strike={st:.1f}, iqr={iqr:.1f}" llm_text = self._llm_section("phase_analysis", data_str) return llm_text or base return base def _section_fwd(self, results: dict, warnings: list) -> str: fwd_r = results.get("forward") or results.get("fwd") if fwd_r is None: return "No forward modelling results available." rms = fwd_r.get("rms") base = ( "A 1-D MT forward model was computed. " + ( f"Data–model RMS misfit: {rms:.3f} log₁₀(Ω·m). " if rms else "" ) + "The synthetic response covers the full available period range." ) if self.api_key: llm_text = self._llm_section("forward", f"rms={rms}") return llm_text or base return base def _section_rec( self, results: dict, sections: dict[str, str], warnings: list, ) -> str: if self.api_key: summary = " | ".join( f"{k}: {v[:100]}" for k, v in sections.items() if v.strip() ) llm_text = self._llm_section("recommendations", summary) if llm_text: return llm_text return ( "Based on the QC and phase tensor results, proceed with " "profile-oriented 2-D inversion using the consensus strike. " "Apply static-shift correction to the EDIs before export. " "Consider independent Bostick depth estimates to guide the " "mesh parametrisation." )
# ── markdown assembler ──────────────────────────────────────────────────────── def _build_markdown( title: str, sections: dict[str, str], fig_refs: dict[str, str], results: dict, ) -> str: now = datetime.now().strftime("%Y-%m-%d %H:%M") lines = [ f"# {title}", "", f"*Generated by pycsamt ReportAgent — {now}*", "", "---", "", ] _SEC_TITLES = { "loading": "1. Data Loading", "qc": "2. Quality Control", "static_shift": "3. Static-Shift Correction", "phase_analysis": "4. Phase Tensor Analysis", "forward": "5. Forward Modelling", "recommendations": "6. Recommendations", } _FIG_KEYS = { "qc": ["qc_confidence_section", "qc_confidence_profile"], "static_shift": [ "static_shift_ss_summary", "static_shift_ss_comparison", ], "phase_analysis": [ "phase_analysis_pt_psection", "phase_analysis_strike_analysis", "phase_analysis_survey_fingerprint", ], "forward": ["forward_response_and_model", "forward_response"], } for sec_key, sec_title in _SEC_TITLES.items(): text = sections.get(sec_key, "").strip() lines.append(f"## {sec_title}") lines.append("") if text: lines.append(text) else: lines.append("*No results available for this section.*") lines.append("") # embed figures for this section for fig_key in _FIG_KEYS.get(sec_key, []): if fig_key in fig_refs: lines.append(f"![{fig_key}]({fig_refs[fig_key]})") lines.append("") # warnings appendix all_warnings: list[str] = [] for res in results.values(): if isinstance(res, AgentResult): all_warnings.extend(res.warnings or []) if all_warnings: lines.append("---") lines.append("## Appendix: Processing Warnings") lines.append("") for w in all_warnings: lines.append(f"- {w}") lines.append("") return "\n".join(lines) _HTML_TEMPLATE = """\ <!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8"> <title>{title}</title> <style> body {{ font-family: Georgia, serif; max-width: 900px; margin: 40px auto; padding: 0 20px; color: #222; line-height: 1.6; }} h1 {{ color: #1a3a5c; border-bottom: 2px solid #1a3a5c; }} h2 {{ color: #2c5f8a; margin-top: 2em; }} img {{ max-width: 100%; height: auto; display: block; margin: 1em 0; }} li {{ margin-bottom: 0.3em; }} code {{ background: #f4f4f4; padding: 2px 6px; border-radius: 3px; }} hr {{ border: none; border-top: 1px solid #ccc; margin: 2em 0; }} </style> </head> <body> {body} </body> </html> """ __all__ = ["ReportAgent"]