Ctrl K
ComunidadGuías…

soc_ai/model_probe.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""Structured-output contract probe for candidate analyst backends.

``soc-ai model-probe`` answers, in minutes, the question that previously took a
debugging session: *can this model actually drive soc-ai's triage contract, and
under which settings?* It runs the REAL synthesizer agent (same builders and
prompt the pipeline uses) against a canned scenario N times and tallies the
outcomes into the failure classes this project has recorded from live serving
models — schema-retry exhaustion, no tool call, HTTP failures, timeouts.

Run it before pointing prod at a new analyst backend, and again for each
candidate setting::

    soc-ai model-probe --model qwen3.6-35b-cpu
    soc-ai model-probe --model qwen3.6-35b-cpu --output-mode native
    soc-ai model-probe --model qwen3.6-35b-cpu --tool-choice required
    soc-ai model-probe --model new-thing -n 12 --min-ok 10   # CI-gateable

The probe reports ``served_backend`` from the gateway's own attribution
headers, so an aliased or fallback-routed model name cannot misattribute the
result (the exact trap that derailed the 2026-08-03 investigation).
"""

from __future__ import annotations

import time
from collections.abc import Callable, Coroutine
from typing import Any

from soc_ai.config import Settings

# A realistic, self-contained benign-DNS triage scenario. Deliberately easy:
# the probe measures CONTRACT compliance (schema, tool call, JSON), not verdict
# quality — an easy scenario keeps a failed probe unambiguous about what broke.
PROBE_SCENARIO = """Alert: ET POLICY Observed DNS Query to .top TLD
src=10.0.0.42 dest=8.8.8.8 host=WKSTN-114 time=2026-08-03T14:02:11Z

Evidence gathered:
- dns.question.name = cdn-assets-01.top
- 3 queries in 60s, all NOERROR, resolving to 104.21.x.x (Cloudflare)
- Destination reputation: no hits on any blocklist
- Process: chrome.exe, user browsing session active
- No subsequent connection to the resolved IP beyond TLS 443
- Host has no other alerts in the last 30 days

Write the TriageReport now."""


def classify_probe_exception(exc: BaseException) -> str:
    """Map an exception from a probe attempt to a failure-class label.

    The labels line up with the failure taxonomy in the lesser-model runbook so
    a probe report reads as a diagnosis: ``schema_retry_exhausted`` points at
    output-shape wobble (try ``--output-mode native``), ``http_5xx`` at the
    serving stack, ``timeout`` at generation speed vs the configured budgets.
    """
    from pydantic_ai.exceptions import ModelHTTPError, UnexpectedModelBehavior  # noqa: PLC0415

    if isinstance(exc, ModelHTTPError):
        return f"http_{exc.status_code}"
    if isinstance(exc, UnexpectedModelBehavior):
        msg = str(exc).lower()
        if "retries" in msg:
            return "schema_retry_exhausted"
        return "unexpected_model_behavior"
    if isinstance(exc, TimeoutError):
        return "timeout"
    return type(exc).__name__


def summarize_probe(
    *,
    model: str,
    output_mode: str,
    tool_choice_required: bool,
    outcomes: list[tuple[str, str]],
    served_backend: dict[str, Any] | None,
    elapsed_s: float,
) -> dict[str, Any]:
    """Fold raw (label, detail) outcomes into the probe report dict."""
    tally: dict[str, int] = {}
    failures: list[str] = []
    for label, detail in outcomes:
        tally[label] = tally.get(label, 0) + 1
        if label != "OK" and len(failures) < 3:
            failures.append(detail)
    ok = tally.get("OK", 0)
    return {
        "model": model,
        "output_mode": output_mode,
        "tool_choice_required": tool_choice_required,
        "n": len(outcomes),
        "ok": ok,
        "usable_rate": (ok / len(outcomes)) if outcomes else 0.0,
        "tally": tally,
        "failures": failures,
        "served_backend": served_backend,
        "elapsed_s": round(elapsed_s, 1),
    }


async def _run_once(agent: Any, prompt: str) -> tuple[str, str]:
    """One probe attempt: run the agent, return (label, detail)."""
    try:
        result = await agent.run(prompt)
        out = result.output
        return ("OK", f"{out.verdict} conf={out.confidence}")
    except Exception as exc:
        detail = " ".join(str(exc).split())
        return (classify_probe_exception(exc), detail)


async def probe_model(
    settings: Settings,
    *,
    n: int = 6,
    output_mode: str = "tool",
    run_once: Callable[[Any, str], Coroutine[Any, Any, tuple[str, str]]] | None = None,
) -> dict[str, Any]:
    """Probe ``settings.analyst_model`` with the real synth agent, N attempts.

    Attempts run SEQUENTIALLY on purpose: a lesser backend (single llama.cpp
    slot, small vLLM) queues concurrent requests, which would fold queue wait
    into the timing and can flip timeout outcomes — sequential keeps each
    attempt's result attributable to the model alone.

    ``run_once`` is injectable for tests; the default runs the agent for real.
    """
    from soc_ai.agent._gateway_retry import capture_backend_attribution  # noqa: PLC0415
    from soc_ai.agent.models import build_synthesizer_model  # noqa: PLC0415
    from soc_ai.agent.orchestrator import build_synth_first_agent  # noqa: PLC0415

    runner = run_once if run_once is not None else _run_once
    agent = build_synth_first_agent(
        build_synthesizer_model(settings, temperature=settings.synthesizer_temperature),
        output_mode=output_mode,
    )
    outcomes: list[tuple[str, str]] = []
    started = time.monotonic()
    with capture_backend_attribution() as attribution:
        for _ in range(n):
            outcomes.append(await runner(agent, PROBE_SCENARIO))
    return summarize_probe(
        model=settings.analyst_model,
        output_mode=output_mode,
        tool_choice_required=settings.analyst_tool_choice_required,
        outcomes=outcomes,
        served_backend=dict(attribution) if attribution else None,
        elapsed_s=time.monotonic() - started,
    )


def format_probe_report(rep: dict[str, Any]) -> str:
    """Human-readable probe report for the CLI."""
    lines = [
        f"model            : {rep['model']}",
        f"output mode      : {rep['output_mode']}"
        + ("  (tool_choice=required)" if rep["tool_choice_required"] else ""),
        f"usable           : {rep['ok']}/{rep['n']}  ({rep['usable_rate']:.0%})",
        f"tally            : {rep['tally']}",
        f"elapsed          : {rep['elapsed_s']}s",
    ]
    backend = rep.get("served_backend") or {}
    if backend.get("api_base"):
        served = backend["api_base"]
        fallbacks = backend.get("attempted_fallbacks")
        lines.append(
            f"served by        : {served}"
            + (f"  (fallbacks attempted: {fallbacks})" if fallbacks not in (None, "0") else "")
        )
    for f in rep.get("failures", []):
        lines.append(f"  failure: {f}")
    return "\n".join(lines)


# ── Battery: the four knob configurations + a deterministic recommendation ───

# Fixed probe order. Baseline first — it is the comparison anchor and, on a
# healthy backend, also the fastest way to confirm the battery itself works.
BATTERY_CONFIGS: tuple[tuple[str, bool], ...] = (
    ("tool", False),  # baseline: today's prod behavior
    ("native", False),
    ("prompted", False),
    ("tool", True),
)

# Equal-rate speed bar: recommend on speed alone only when the candidate is at
# least this much faster than baseline (elapsed <= baseline * 0.75).
_SPEEDUP_BAR = 0.75


def _config_label(mode: str, required: bool) -> str:
    return f"{mode}+required" if required else mode


def recommend(configs: list[dict[str, Any]]) -> dict[str, Any] | None:
    """Pick the configuration to recommend over the baseline, or None.

    Deterministic and explainable by design: rank by usable rate desc then
    elapsed asc; recommend only a STRICT improvement over the baseline (higher
    rate, or equal rate and >= 25% faster). ``prompted`` — the
    weakest-guarantees mode — is recommended only when it is the sole
    configuration at the top usable rate, so it never displaces a tied
    ``native``/``tool`` result on speed. Returns the two knob values plus a
    human-readable reason, or None when the baseline stands (including when
    everything failed — "configure nothing" is the honest answer to that).
    """
    baseline = next(
        (c for c in configs if c["output_mode"] == "tool" and not c["tool_choice_required"]),
        None,
    )
    if baseline is None or not configs:
        return None

    candidates = [c for c in configs if c is not baseline and c["usable_rate"] > 0]
    if not candidates:
        return None
    top_rate = max(c["usable_rate"] for c in candidates)
    top = [c for c in candidates if c["usable_rate"] == top_rate]
    # Demote prompted unless it is the sole top-rate configuration.
    non_prompted = [c for c in top if c["output_mode"] != "prompted"]
    if non_prompted:
        top = non_prompted
    best = min(top, key=lambda c: c["elapsed_s"])

    label = _config_label(best["output_mode"], best["tool_choice_required"])
    rate_str = f"{best['ok']}/{best['n']}"
    if best["usable_rate"] > baseline["usable_rate"]:
        reason = f"{label}: {rate_str} usable vs baseline {baseline['ok']}/{baseline['n']}"
    elif (
        best["usable_rate"] == baseline["usable_rate"]
        and baseline["elapsed_s"] > 0
        and best["elapsed_s"] <= baseline["elapsed_s"] * _SPEEDUP_BAR
    ):
        speedup = baseline["elapsed_s"] / max(best["elapsed_s"], 0.1)
        reason = f"{label}: {rate_str} usable, {speedup:.1f}x faster than tool mode"
    else:
        return None

    return {
        "synthesizer_output_mode": best["output_mode"],
        "analyst_tool_choice_required": best["tool_choice_required"],
        "config": label,
        "reason": reason,
    }


async def run_battery(
    settings: Settings,
    *,
    model: str | None = None,
    n: int = 2,
    run_once: Callable[[Any, str], Coroutine[Any, Any, tuple[str, str]]] | None = None,
    per_attempt_timeout_s: float = 120.0,
    on_progress: Callable[[str, int, int], None] | None = None,
) -> dict[str, Any]:
    """Probe *model* under every battery configuration; attach a recommendation.

    Sequential across configurations for the same reason probe attempts are
    sequential within one. ``per_attempt_timeout_s`` bounds each attempt so one
    hung backend read cannot wedge the whole battery (a timed-out attempt lands
    as a ``timeout`` outcome, it does not abort the run). ``on_progress`` is
    called with (config label, 1-based index, total) before each configuration —
    the API's status object hooks in there.
    """
    import asyncio  # noqa: PLC0415

    base_runner = run_once if run_once is not None else _run_once

    async def bounded_runner(agent: Any, prompt: str) -> tuple[str, str]:
        try:
            return await asyncio.wait_for(base_runner(agent, prompt), per_attempt_timeout_s)
        except TimeoutError:
            return ("timeout", f"attempt exceeded {per_attempt_timeout_s:.0f}s")

    probe_settings = settings
    if model:
        probe_settings = settings.model_copy(update={"analyst_model": model})

    started = time.monotonic()
    configs: list[dict[str, Any]] = []
    total = len(BATTERY_CONFIGS)
    for i, (mode, required) in enumerate(BATTERY_CONFIGS, start=1):
        if on_progress is not None:
            on_progress(_config_label(mode, required), i, total)
        cfg_settings = probe_settings.model_copy(update={"analyst_tool_choice_required": required})
        configs.append(
            await probe_model(cfg_settings, n=n, output_mode=mode, run_once=bounded_runner)
        )

    return {
        "model": probe_settings.analyst_model,
        "n_per_config": n,
        "configs": configs,
        "recommendation": recommend(configs),
        "elapsed_s": round(time.monotonic() - started, 1),
    }