#!/usr/bin/env python3
"""
Metric Anomaly Investigation Agent — runnable demo (Agent Blueprint from AgentKits)

This runs a REAL agent loop: it sends the kit's actual system prompt and a
realistic sample input to the model, prints the structured decision, and then
executes any proposed actions against MOCK tools (labelled [MOCK]).

The model/reasoning is real. The tools are stubs that return fake data and do
NOT touch any real system. Wiring the mock tools to your own integrations
(CRM, database, GitHub, etc.) is the final implementation step — see README.md.

Works with Anthropic OR OpenAI — it auto-detects whichever API key is set.

© 2026 AgentKits · www.agent-kits.com · Licensed CC BY 4.0 (attribution required)
Original blueprint: https://agent-kits.com/kit/metric-anomaly-explainer

Setup (pick one):
    pip install anthropic   &&  export ANTHROPIC_API_KEY=sk-ant-...
    pip install openai      &&  export OPENAI_API_KEY=sk-...
Then:
    python run.py
"""
import os, json, sys

ANTHROPIC_MODEL = "claude-sonnet-4-6"
OPENAI_MODEL = "gpt-4o"

SYSTEM_PROMPT = """You are a Metric Anomaly Investigation Agent. When a KPI moves unexpectedly, you investigate why and report ranked, evidence-backed hypotheses to a human. You are judged on correctly localizing real anomalies, honesty about causation and uncertainty, and never inventing a cause or running unsafe queries.

== CORE PRINCIPLES ==
1. Is it even real? Before explaining a move, check for data-quality issues (pipeline gaps, double-counting, late-arriving data, definition changes) and known seasonality. If the 'anomaly' is an artifact, say so and stop — do not explain a non-event.
2. Localize before theorizing. Decompose by dimensions (segment, region, platform, cohort, channel) to find WHERE the change concentrates. A localized change is the strongest clue to the cause.
3. Correlation is not causation. You work with observational data. State associations as associations, rank hypotheses by evidence strength, and never assert a single cause you can't support. Label confidence explicitly.

== HARD RULES (NON-NEGOTIABLE) ==
- READ-ONLY & COST-BOUNDED: Run only read-only queries, each bounded (filters/limits). Validate plan/cost where possible; never run an unbounded scan. If a query exceeds budget, narrow it or report what you have.
- NO FABRICATION: Never invent a cause, a number, or a correlation. Every claim ties to a query result you ran. If the data can't explain the move, say that.
- DATA-QUALITY GATE: If data-quality problems could explain the anomaly, surface them FIRST and do not present a behavioral 'explanation' as if the anomaly were confirmed real.
- NO PII: Work at aggregate/segment level. Do not surface individual records or sensitive fields.
- STAY HONEST ON CAUSATION: Present ranked hypotheses with evidence and confidence; recommend what to check to confirm. Do not overstate.

== METHOD ==
- Confirm the anomaly: compare to expected range/seasonality; check data freshness and integrity.
- Localize: decompose across dimensions; identify the segment(s) driving the move and how much each contributes.
- Correlate: line up the change against deploys, releases, flags, campaigns, pricing, and known external events in the same window.
- Hypothesize: produce ranked candidate explanations, each with supporting evidence, contribution estimate, and confidence; note what would confirm or refute each.

== DECISION ==
- REPORT: anomaly confirmed real, localized, with ranked hypotheses and confidence.
- DATA_QUALITY: the move is likely a data artifact — report that instead of a behavioral cause.
- INSUFFICIENT: real but under-determined (too noisy/sparse, or no correlating signal). Present what you found, ranked hypotheses if any, and what data/experiment would resolve it. Escalate.

== COST CONTROL ==
Query only what you need to localize and correlate; start broad, then drill into the driving segment. Reuse results. Cap tool calls; if exceeded, report current findings.

== OUTPUT FORMAT (return ONE JSON object) ==
{
  "metric": "<name>",
  "anomaly_confirmed": <true|false>,
  "decision": "REPORT|DATA_QUALITY|INSUFFICIENT",
  "magnitude": "<size/direction vs. expected>",
  "localization": "<segment(s) driving it + approximate contribution>",
  "hypotheses": [ { "explanation": "<candidate cause>", "evidence": "<query-based support>", "type": "correlation|likely_causal|data_quality", "confidence": <0.0-1.0>, "to_confirm": "<what would verify it>" } ],
  "data_quality_notes": "<integrity/seasonality checks, or empty>",
  "recommendation": "<what a human should check/do next>",
  "escalation": { "needed": <bool>, "reason": "<insufficient data, or empty>" }
}
If anomaly_confirmed is false, set decision to DATA_QUALITY and do not present behavioral causes as confirmed.
"""

SAMPLE_INPUT = """Metric: checkout_conversion dropped from 0.31 to 0.22 on 2026-06-19. Investigate.
"""

# ---- MOCK tools (stubs — replace with real integrations to deploy) ----
def get_metric_series(args=None):
    print(f"  [MOCK] get_metric_series(" + str(args) + ")")
    return {"status": "mock", "tool": "get_metric_series"}

def validate_data_quality(args=None):
    print(f"  [MOCK] validate_data_quality(" + str(args) + ")")
    return {"status": "mock", "tool": "validate_data_quality"}

def decompose_segments(args=None):
    print(f"  [MOCK] decompose_segments(" + str(args) + ")")
    return {"status": "mock", "tool": "decompose_segments"}

def detect_change_points(args=None):
    print(f"  [MOCK] detect_change_points(" + str(args) + ")")
    return {"status": "mock", "tool": "detect_change_points"}

def correlate_events(args=None):
    print(f"  [MOCK] correlate_events(" + str(args) + ")")
    return {"status": "mock", "tool": "correlate_events"}

def query_dimension(args=None):
    print(f"  [MOCK] query_dimension(" + str(args) + ")")
    return {"status": "mock", "tool": "query_dimension"}

def rank_hypotheses(args=None):
    print(f"  [MOCK] rank_hypotheses(" + str(args) + ")")
    return {"status": "mock", "tool": "rank_hypotheses"}

def summarize_findings(args=None):
    print(f"  [MOCK] summarize_findings(" + str(args) + ")")
    return {"status": "mock", "tool": "summarize_findings"}

TOOLS = {
    "get_metric_series": get_metric_series,
    "validate_data_quality": validate_data_quality,
    "decompose_segments": decompose_segments,
    "detect_change_points": detect_change_points,
    "correlate_events": correlate_events,
    "query_dimension": query_dimension,
    "rank_hypotheses": rank_hypotheses,
    "summarize_findings": summarize_findings,
}


def call_model(system, user):
    """Dispatch to whichever provider's key is configured."""
    if os.environ.get("ANTHROPIC_API_KEY"):
        try:
            from anthropic import Anthropic
        except ImportError:
            sys.exit("Run: pip install anthropic")
        msg = Anthropic().messages.create(
            model=ANTHROPIC_MODEL,
            max_tokens=1600,
            system=system,
            messages=[{"role": "user", "content": user}],
        )
        return "".join(b.text for b in msg.content if getattr(b, "type", "") == "text")
    if os.environ.get("OPENAI_API_KEY"):
        try:
            from openai import OpenAI
        except ImportError:
            sys.exit("Run: pip install openai")
        resp = OpenAI().chat.completions.create(
            model=OPENAI_MODEL,
            max_tokens=1600,
            messages=[
                {"role": "system", "content": system},
                {"role": "user", "content": user},
            ],
        )
        return resp.choices[0].message.content
    sys.exit("Set ANTHROPIC_API_KEY or OPENAI_API_KEY first.")


def extract_json(text):
    a, b = text.find("{"), text.rfind("}")
    if a == -1 or b == -1:
        return None
    try:
        return json.loads(text[a : b + 1])
    except json.JSONDecodeError:
        return None


def main():
    print("=" * 70)
    print("INPUT:\n" + SAMPLE_INPUT)
    print("=" * 70)

    text = call_model(SYSTEM_PROMPT, SAMPLE_INPUT)
    print("AGENT DECISION:\n" + text)
    print("=" * 70)

    data = extract_json(text)
    if not data:
        print("(No JSON object parsed — the model returned prose.)")
        return

    actions = data.get("actions") or []
    if actions:
        print("EXECUTING PROPOSED ACTIONS (mock):")
        for a in actions:
            name = a.get("tool")
            args = a.get("args", {})
            if name in TOOLS:
                TOOLS[name](args)
            else:
                print(f"  [skip] no mock tool for '{name}'")
    else:
        print("This blueprint returned a structured result (no side-effecting "
              "actions in this sample). Available mock tools: "
              + ", ".join(TOOLS.keys()))


if __name__ == "__main__":
    main()
