Module scenario.report

Post-hoc report generation for red-team scenario runs.

Public API: scenario.save_redteam_report(result, red_team=…, test_name=…)

Each call writes a JSON file into a timestamped batch directory under ./redteam-reports/ (one directory per Python process by default). The scenario.report.app module is a Streamlit dashboard that reads from such a directory.

Expand source code
"""Post-hoc report generation for red-team scenario runs.

Public API:
    scenario.save_redteam_report(result, red_team=..., test_name=...)

Each call writes a JSON file into a timestamped batch directory under
``./redteam-reports/`` (one directory per Python process by default).
The ``scenario.report.app`` module is a Streamlit dashboard that reads
from such a directory.
"""

from ._save import save_redteam_report, set_batch_dir, current_batch_dir
from ._aggregate import aggregate_fixes, load_or_generate as load_or_generate_fixes

# Skip the Streamlit dashboard entry point during pdoc API doc generation.
# `app` is launched via `streamlit run`, not imported, and `streamlit` is an
# optional extra (`pip install langwatch-scenario[report]`) so the docs build
# environment can't import it.
__pdoc__ = {"app": False}

__all__ = [
    "save_redteam_report",
    "set_batch_dir",
    "current_batch_dir",
    "aggregate_fixes",
    "load_or_generate_fixes",
]

Functions

def aggregate_fixes(reports: list[dict], *, model: str = 'openai/gpt-4.1', temperature: float = 0.2) ‑> dict

Return {'clusters': […]} from a single LLM call.

Expand source code
def aggregate_fixes(
    reports: list[dict],
    *,
    model: str = "openai/gpt-4.1",
    temperature: float = 0.2,
) -> dict:
    """Return {'clusters': [...]} from a single LLM call."""
    findings_block = _build_findings_block(reports)
    prompt = _AGGREGATE_PROMPT.format(findings_block=findings_block)
    resp = cast(
        ModelResponse,
        litellm.completion(
            model=model,
            messages=[{"role": "user", "content": prompt}],
            response_format={"type": "json_object"},
            temperature=temperature,
        ),
    )
    raw = cast(Choices, resp.choices[0]).message.content or "{}"
    try:
        data = json.loads(raw)
    except json.JSONDecodeError:
        m = re.search(r"\{.*\}", raw, re.DOTALL)
        data = json.loads(m.group(0)) if m else {}
    clusters = data.get("clusters") if isinstance(data, dict) else None
    if not isinstance(clusters, list):
        clusters = []
    return {"clusters": clusters}
def current_batch_dir() ‑> pathlib.Path | None

Return the current batch directory, or None if no report has been saved yet.

Expand source code
def current_batch_dir() -> Optional[Path]:
    """Return the current batch directory, or None if no report has been saved yet."""
    return _BATCH_DIR
def load_or_generate_fixes(batch_dir: Path, reports: list[dict], *, model: str = 'openai/gpt-4.1', force: bool = False) ‑> dict

Return aggregated fixes, cached to disk per batch directory.

Expand source code
def load_or_generate(
    batch_dir: Path,
    reports: list[dict],
    *,
    model: str = "openai/gpt-4.1",
    force: bool = False,
) -> dict:
    """Return aggregated fixes, cached to disk per batch directory."""
    cache = batch_dir / _CACHE_NAME
    if cache.exists() and not force:
        try:
            return json.loads(cache.read_text())
        except (OSError, json.JSONDecodeError):
            # Cache is missing/unreadable/corrupt; fall back to fresh aggregation.
            pass
    result = aggregate_fixes(reports, model=model)
    try:
        cache.write_text(json.dumps(result, indent=2))
    except OSError:
        # Best-effort cache write failure should not block returning results.
        pass
    return result
def save_redteam_report(result: Optional[ScenarioResult] = None, *, red_team: RedTeamAgent, test_name: str, criteria: Optional[Iterable[str]] = None, description: Optional[str] = None, analyzer_model: Optional[str] = None, analyze: bool = True, out_dir: Optional[Union[str, Path]] = None, error: Optional[str] = None, elapsed_seconds: Optional[float] = None, messages_override: Optional[Iterable[Any]] = None, failing_turn_hint: Optional[int] = None) ‑> pathlib.Path

Persist a red-team scenario run to JSON for dashboard consumption.

Pass result=None, error="..." to record a run that raised before completion (e.g. a per-turn check function raised an exception). The dashboard will show the card as "errored" with no transcript.

Args

result
Object returned by await run()(…), or None if the run raised an exception.
red_team
The RedTeamAgent instance used in the run (for metadata).
test_name
Short identifier for the test.
criteria
Judge criteria used, for the report display.
description
Scenario description, for the report display.
analyzer_model
Model for the suggestion pass. Defaults to the red team's metaprompt_model.
analyze
If False, skip the LLM analyzer call.
out_dir
Directory to write into. Defaults to the current batch dir (one per process, under ./redteam-reports/<timestamp>/).
error
Exception message if the run raised. Saved in place of reasoning.
elapsed_seconds
Wall-clock seconds for the run, if known.

Returns

Path to the written JSON file.

Expand source code
def save_redteam_report(
    result: Optional[ScenarioResult] = None,
    *,
    red_team: RedTeamAgent,
    test_name: str,
    criteria: Optional[Iterable[str]] = None,
    description: Optional[str] = None,
    analyzer_model: Optional[str] = None,
    analyze: bool = True,
    out_dir: Optional[Union[str, Path]] = None,
    error: Optional[str] = None,
    elapsed_seconds: Optional[float] = None,
    messages_override: Optional[Iterable[Any]] = None,
    failing_turn_hint: Optional[int] = None,
) -> Path:
    """Persist a red-team scenario run to JSON for dashboard consumption.

    Pass ``result=None, error="..."`` to record a run that raised before
    completion (e.g. a per-turn check function raised an exception). The
    dashboard will show the card as "errored" with no transcript.

    Args:
        result: Object returned by ``await scenario.run(...)``, or None if
            the run raised an exception.
        red_team: The RedTeamAgent instance used in the run (for metadata).
        test_name: Short identifier for the test.
        criteria: Judge criteria used, for the report display.
        description: Scenario description, for the report display.
        analyzer_model: Model for the suggestion pass. Defaults to the
            red team's ``metaprompt_model``.
        analyze: If False, skip the LLM analyzer call.
        out_dir: Directory to write into. Defaults to the current batch dir
            (one per process, under ``./redteam-reports/<timestamp>/``).
        error: Exception message if the run raised. Saved in place of reasoning.
        elapsed_seconds: Wall-clock seconds for the run, if known.

    Returns:
        Path to the written JSON file.
    """
    dest_dir = Path(out_dir).resolve() if out_dir else _default_batch_dir()
    dest_dir.mkdir(parents=True, exist_ok=True)

    strategy = _strategy_name(red_team)
    if messages_override is not None:
        messages = [_serialize_message(m) for m in messages_override]
    elif result is not None:
        messages = [_serialize_message(m) for m in (result.messages or [])]
    else:
        messages = []

    analysis: dict = {
        "failing_turn_index": failing_turn_hint,
        "failure_summary": "",
        "suggestions": [],
        "severity": "medium",
        "severity_rationale": "",
        "break_severity": "none",
        "break_rationale": "",
    }
    if analyze and messages:
        _success = bool(result.success) if result else False
        _reasoning = (result.reasoning if result else None) or (error or "")
        _failed = list((result.failed_criteria if result else []) or [])
        try:
            analysis = _analyze(
                target=red_team.target,
                strategy=strategy,
                turns=red_team.total_turns,
                criteria=list(criteria or []),
                success=_success,
                reasoning=_reasoning,
                failed_criteria=_failed,
                transcript=_transcript_for_prompt(messages),
                model=analyzer_model or red_team.metaprompt_model,
            )
            if analysis.get("failing_turn_index") is None and failing_turn_hint is not None:
                analysis["failing_turn_index"] = failing_turn_hint
        except Exception as e:
            analysis["failure_summary"] = f"(analyzer failed: {e})"

    if error is not None:
        success = False
        reasoning = f"EXCEPTION: {error}"
        passed_criteria: list[str] = []
        failed_criteria: list[str] = list(criteria or [])
        total_time = elapsed_seconds
        agent_time = None
        # If we have a transcript, the exception came from a per-turn check
        # catching the agent break red-handed — that's a defeat, not an infra
        # error. Classify as "broke".
        status = "broke" if messages else "errored"
        if not analysis["failure_summary"]:
            analysis["failure_summary"] = reasoning
    else:
        success = bool(result.success) if result else False
        reasoning = (result.reasoning if result else "") or ""
        passed_criteria = list((result.passed_criteria if result else []) or [])
        failed_criteria = list((result.failed_criteria if result else []) or [])
        total_time = (result.total_time if result else None) or elapsed_seconds
        agent_time = result.agent_time if result else None
        status = "held" if success else "broke"

    report = {
        "test_name": test_name,
        "description": description or "",
        "strategy": strategy,
        "target": red_team.target,
        "total_turns": red_team.total_turns,
        "attacker_model": red_team._model,
        "metaprompt_model": red_team.metaprompt_model,
        "criteria": list(criteria or []),
        "status": status,
        "success": success,
        "reasoning": reasoning,
        "passed_criteria": passed_criteria,
        "failed_criteria": failed_criteria,
        "total_time": total_time,
        "agent_time": agent_time,
        "messages": messages,
        "failing_turn_index": analysis["failing_turn_index"],
        "failure_summary": analysis["failure_summary"],
        "suggestions": analysis["suggestions"],
        "severity": analysis["severity"],
        "severity_rationale": analysis.get("severity_rationale", ""),
        "break_severity": analysis.get("break_severity", "none"),
        "break_rationale": analysis.get("break_rationale", ""),
        "timestamp": datetime.now().isoformat(timespec="seconds"),
    }

    stamp = f"{int(time.time() * 1000)}"
    safe_name = re.sub(r"[^A-Za-z0-9_-]+", "_", test_name).strip("_") or "run"
    filename = f"{stamp}_{safe_name}_{strategy}.json"
    path = dest_dir / filename
    path.write_text(json.dumps(report, indent=2, default=str))
    return path
def set_batch_dir(path: Union[str, Path]) ‑> pathlib.Path

Override the batch directory. Subsequent save_redteam_report() calls without an explicit out_dir will write here.

Expand source code
def set_batch_dir(path: Union[str, Path]) -> Path:
    """Override the batch directory. Subsequent ``save_redteam_report`` calls
    without an explicit ``out_dir`` will write here."""
    global _BATCH_DIR
    _BATCH_DIR = Path(path).resolve()
    _BATCH_DIR.mkdir(parents=True, exist_ok=True)
    return _BATCH_DIR