4b6817381b
CI (OpenClaw E2E) / openclaw test (push) Has been cancelled
CI / coverage-report (push) Has been cancelled
CI / test-kubernetes (push) Has been cancelled
CI / should-run-thorough (push) Has been cancelled
CI / test-thorough (cloudwatch-demo) (push) Has been cancelled
CI / test-thorough (flink-ecs) (push) Has been cancelled
CI / test-thorough (upstream-lambda) (push) Has been cancelled
CI / test-thorough (prefect-ecs-fargate) (push) Has been cancelled
Release / build-binaries (zip, opensre.exe, onefile, windows-latest, windows-x64) (push) Has been cancelled
Benchmark image — build + push to ECR (any adapter) / build + push (push) Has been cancelled
CI / quality (ubuntu-latest) (push) Has been cancelled
CI / test (tools-runtime) (push) Has been cancelled
CI / test (e2e-general) (push) Has been cancelled
CI / test (cli-runtime) (push) Has been cancelled
CI / test (e2e-provider-and-openclaw) (push) Has been cancelled
CI / test (integrations-and-misc) (push) Has been cancelled
Release / verify (push) Has been cancelled
Release / build-python-dist (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-15-intel, darwin-x64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-latest, darwin-arm64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04, linux-x64) (push) Has been cancelled
Release / publish-release (push) Has been cancelled
Release / publish-main-release (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-checks (no-LLM) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-live shard ${{ matrix.shard_index }} (push) Has been cancelled
Release / prepare (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04-arm, linux-arm64) (push) Has been cancelled
Synthetic Deterministic Tests / Synthetic offline (deterministic) (push) Has been cancelled
198 lines
7.0 KiB
Python
198 lines
7.0 KiB
Python
"""Miss recurrence analysis and benchmark scenario export."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
import uuid
|
|
from collections import Counter, defaultdict
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from core.domain.feedback.misses.store import parse_timestamp
|
|
from core.domain.feedback.misses.taxonomy import MissRecord, MissTaxonomy
|
|
|
|
|
|
def grouping_key(row: MissRecord) -> tuple[str, str]:
|
|
"""Canonical ``(alert_name, taxonomy)`` key used to group misses.
|
|
|
|
Both ``compute_recurrence`` and ``filter_top_misses`` go through this so
|
|
the ``opensre misses stats`` recurring-pair view and the directory layout
|
|
written by ``opensre misses export`` always agree on what counts as the
|
|
same miss.
|
|
"""
|
|
return (
|
|
row.get("alert_name", "") or "<unknown>",
|
|
row.get("taxonomy", "") or MissTaxonomy.UNKNOWN.value,
|
|
)
|
|
|
|
|
|
def compute_recurrence(misses: list[MissRecord]) -> dict[tuple[str, str], int]:
|
|
"""Count misses grouped by ``(alert_name, taxonomy)``.
|
|
|
|
A high count means the same alert keeps failing in the same way — the
|
|
strongest signal that a regression scenario is warranted.
|
|
"""
|
|
counter: Counter[tuple[str, str]] = Counter()
|
|
for row in misses:
|
|
counter[grouping_key(row)] += 1
|
|
return dict(counter)
|
|
|
|
|
|
def compute_stats(misses: list[MissRecord]) -> dict[str, Any]:
|
|
"""Summary stats used by ``opensre misses stats`` and the docs reporter.
|
|
|
|
Returns a dict with:
|
|
- ``total``: total misses in scope
|
|
- ``by_taxonomy``: count per taxonomy bucket
|
|
- ``recurring``: top ``(alert_name, taxonomy)`` pairs seen more than once
|
|
- ``unique_alerts``: distinct alert_names in scope
|
|
"""
|
|
by_taxonomy: Counter[str] = Counter()
|
|
by_alert: defaultdict[str, set[str]] = defaultdict(set)
|
|
for row in misses:
|
|
alert, taxonomy = grouping_key(row)
|
|
by_taxonomy[taxonomy] += 1
|
|
by_alert[alert].add(taxonomy)
|
|
|
|
recurrence = compute_recurrence(misses)
|
|
recurring = sorted(
|
|
((alert, tax, count) for (alert, tax), count in recurrence.items() if count > 1),
|
|
key=lambda x: x[2],
|
|
reverse=True,
|
|
)
|
|
|
|
return {
|
|
"total": len(misses),
|
|
"by_taxonomy": dict(by_taxonomy),
|
|
"recurring": recurring,
|
|
"unique_alerts": len(by_alert),
|
|
}
|
|
|
|
|
|
def filter_top_misses(misses: list[MissRecord], top: int) -> list[MissRecord]:
|
|
"""Pick the ``top`` highest-priority misses for eval conversion.
|
|
|
|
Priority order: most recurrent ``(alert_name, taxonomy)`` first; ties broken
|
|
by recency. Returns one record per pair so the resulting eval set stays
|
|
deduped — turning the *same* miss into five identical scenarios adds no
|
|
coverage.
|
|
"""
|
|
if top <= 0 or not misses:
|
|
return []
|
|
|
|
grouped: defaultdict[tuple[str, str], list[MissRecord]] = defaultdict(list)
|
|
for row in misses:
|
|
grouped[grouping_key(row)].append(row)
|
|
|
|
representative: list[tuple[int, datetime, MissRecord]] = []
|
|
for rows in grouped.values():
|
|
rows.sort(key=lambda r: parse_timestamp(r.get("timestamp")), reverse=True)
|
|
representative.append((len(rows), parse_timestamp(rows[0].get("timestamp")), rows[0]))
|
|
|
|
representative.sort(key=lambda x: (x[0], x[1]), reverse=True)
|
|
return [row for _, _, row in representative[:top]]
|
|
|
|
|
|
_SAFE_SLUG = re.compile(r"[^a-zA-Z0-9_.-]+")
|
|
|
|
|
|
def _slugify(value: str, *, fallback: str = "miss") -> str:
|
|
cleaned = _SAFE_SLUG.sub("-", value).strip("-").lower()
|
|
return cleaned or fallback
|
|
|
|
|
|
def to_benchmark_scenario(miss: MissRecord) -> dict[str, Any]:
|
|
"""Convert a miss into a benchmark scenario ``alert.json`` payload.
|
|
|
|
The shape matches benchmark scenario ``alert.json`` payloads so the
|
|
benchmark runner can consume the exported scenarios with no adapter changes.
|
|
|
|
The grading rubric lives at ``commonAnnotations.scoring_points`` — that is
|
|
where :func:`integrations.opensre.extract_scoring_points` looks for
|
|
it (``opensre investigate --evaluate``), and where
|
|
:func:`integrations.opensre.strip_scoring_points_from_alert` strips it before
|
|
handing the alert to the agent. Putting it under ``_meta`` would both be
|
|
invisible to the judge *and* leak the answer to the agent.
|
|
"""
|
|
miss_id = miss.get("miss_id", str(uuid.uuid4()))
|
|
alert_name = miss.get("alert_name") or "production miss"
|
|
root_cause = miss.get("root_cause") or ""
|
|
detail = miss.get("taxonomy_detail") or ""
|
|
taxonomy = miss.get("taxonomy") or MissTaxonomy.UNKNOWN.value
|
|
|
|
return {
|
|
"_meta": {
|
|
"purpose": "Regression scenario derived from a production miss",
|
|
"source": "opensre misses export",
|
|
"miss_id": miss_id,
|
|
"original_run_id": miss.get("run_id", ""),
|
|
"captured_at": miss.get("timestamp", ""),
|
|
"taxonomy": taxonomy,
|
|
},
|
|
"title": f"[Regression] {alert_name}",
|
|
"alert_name": alert_name,
|
|
"pipeline_name": miss.get("pipeline_name", ""),
|
|
"severity": miss.get("severity") or "warning",
|
|
"alert_source": "closed_loop_learning",
|
|
"message": detail or alert_name,
|
|
"text": detail or alert_name,
|
|
"commonLabels": {
|
|
"pipeline_name": miss.get("pipeline_name", ""),
|
|
"severity": miss.get("severity") or "warning",
|
|
"taxonomy": taxonomy,
|
|
},
|
|
"commonAnnotations": {
|
|
"summary": detail or alert_name,
|
|
"miss_id": miss_id,
|
|
"taxonomy": taxonomy,
|
|
"scoring_points": {
|
|
"expected_root_cause": root_cause,
|
|
"expected_category": miss.get("root_cause_category", ""),
|
|
"miss_notes": detail,
|
|
},
|
|
},
|
|
}
|
|
|
|
|
|
def export_scenarios(
|
|
misses: list[MissRecord],
|
|
out_dir: Path,
|
|
) -> list[Path]:
|
|
"""Write one ``alert.json`` per miss under ``out_dir/<slug>/``.
|
|
|
|
Returns the paths written. The caller is responsible for creating any
|
|
enclosing benchmark config — this function only produces the per-case
|
|
alert payloads that the existing runner already understands.
|
|
"""
|
|
written: list[Path] = []
|
|
out_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
for index, miss in enumerate(misses, start=1):
|
|
# ``or`` rather than dict.get default: a JSON null stored on disk
|
|
# returns Python None, which would crash _slugify's re.sub.
|
|
slug = _slugify(miss.get("alert_name") or "", fallback=f"miss-{index:04d}")
|
|
taxonomy_slug = _slugify(miss.get("taxonomy") or "unknown", fallback="unknown")
|
|
case_dir = out_dir / f"{index:04d}_{slug}_{taxonomy_slug}"
|
|
case_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
scenario = to_benchmark_scenario(miss)
|
|
target = case_dir / "alert.json"
|
|
target.write_text(
|
|
json.dumps(scenario, indent=2, ensure_ascii=False) + "\n", encoding="utf-8"
|
|
)
|
|
written.append(target)
|
|
|
|
return written
|
|
|
|
|
|
__all__ = [
|
|
"compute_recurrence",
|
|
"compute_stats",
|
|
"export_scenarios",
|
|
"filter_top_misses",
|
|
"grouping_key",
|
|
"to_benchmark_scenario",
|
|
]
|