Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:10:45 +08:00

339 lines
15 KiB
Python

"""Predictor LLM call: investigation free-text → paper-format ``top_3_predictions`` JSON.
After opensre's investigation produces a free-text RCA, this module runs
one additional LLM call that translates the agent's findings into the
structured ``top_3_predictions`` JSON that the paper's scorer expects::
{
"top_3_predictions": [
{"rank": 1, "fault_taxonomy": "Runtime_Fault",
"fault_object": "app/ts-auth-service",
"root_cause": "mysql_invalid_credentials"},
... (3 total)
]
}
The cloudopsbench adapter calls :func:`emit_paper_predictions` after the
investigation completes; the result is stashed into
``RunResult.final_diagnosis["top_3_predictions"]`` so the scorer at
``scoring.extract_final_answer_payload`` picks it up directly and never
falls through to the brittle keyword-inference bridge.
Mode-agnostic by design: ``opensre+llm`` passes the investigation
evidence + report as ``investigation_summary``; ``llm_alone`` would pass
an empty summary so the LLM works from the alert alone. Same predictor,
same scoring — that's the honest comparison.
"""
from __future__ import annotations
import json
import logging
import re
from typing import Any
from core.llm.shared.llm_retry import LLMCreditExhaustedError, retry_on_rate_limit
from tests.benchmarks.cloudopsbench.predictor.snapping import _snap_fault_object, _snap_root_cause
from tests.benchmarks.cloudopsbench.predictor.vocabulary import (
_FAULT_OBJECT_NAMESPACES,
_FAULT_OBJECT_NODES,
_FAULT_OBJECT_SERVICES,
_ROOT_CAUSES,
_TAXONOMY_CATEGORIES,
)
from tests.benchmarks.cloudopsbench.taxonomy import taxonomy_for_root_cause
logger = logging.getLogger(__name__)
# --------------------------------------------------------------------------- #
# Public API #
# --------------------------------------------------------------------------- #
def emit_paper_predictions(
*,
alert_text: str,
investigation_summary: str,
llm: Any,
metric_alerts: str = "",
performance_localization_hint: dict[str, str] | None = None,
) -> dict[str, Any] | None:
"""Ask the LLM to translate the investigation into paper-format predictions.
``llm`` is opensre's agent LLM client (typically the same one that ran
the investigation, obtained via ``get_llm(LLMRole.AGENT)``). We call
``llm.invoke`` with ``tools=None`` so the model produces plain text,
then parse the response.
Returns the parsed payload ``{"top_3_predictions": [...]}`` on success,
or ``None`` if the model output can't be parsed/validated. On ``None``,
the existing scorer fallback (keyword bridge) runs — no regression vs
pre-predictor behavior.
"""
system = _build_system_prompt()
user_content = _build_user_prompt(
alert_text,
investigation_summary,
metric_alerts=metric_alerts,
performance_localization_hint=performance_localization_hint,
)
try:
response = retry_on_rate_limit(
lambda: llm.invoke([{"role": "user", "content": user_content}], system=system),
label="predictor",
)
except LLMCreditExhaustedError:
# Fatal — propagate so the bench runner halts. Continuing on a
# dead account would just emit hundreds of None-results for cells
# that have no chance of scoring; the operator needs to top up
# balance first.
raise
except Exception as exc: # noqa: BLE001 — best-effort step; never block scoring
logger.warning("[predictor] LLM invocation failed: %s", exc)
return None
payload = _parse_predictions(getattr(response, "content", "") or "")
if payload is None:
logger.warning("[predictor] could not parse top_3_predictions from LLM output")
return None
return payload
# --------------------------------------------------------------------------- #
# Prompt construction #
# --------------------------------------------------------------------------- #
def _build_system_prompt() -> str:
"""Canonical predictor system prompt.
Encodes the schema (top_3_predictions with rank / fault_taxonomy /
fault_object / root_cause), the closed vocabularies from
``vocabulary.py`` interpolated inline so the model sees the exact
enum surface the scorer compares against, the
investigation-is-authoritative rule, the namespace-scope rule for
admission faults, and the performance-fault disambiguation rules.
Pure string — no per-call state, safe to cache via OpenAI's automatic
prefix cache (≥1024-token stable prefix qualifies).
"""
return (
"You are a CloudOpsBench fault-localization formatter.\n"
"Given an alert and an investigation summary, output exactly ONE JSON\n"
"object with a 'top_3_predictions' array of THREE ranked guesses for\n"
"the most likely fault localization.\n\n"
"Schema (ALL fields required on every prediction):\n"
" {\n"
' "top_3_predictions": [\n'
" {\n"
' "rank": 1,\n'
' "fault_taxonomy": <one of the taxonomies below>,\n'
' "fault_object": <canonical fault location string>,\n'
' "root_cause": <one of the root_cause enum values below>\n'
" },\n"
" ... (rank 2, rank 3)\n"
" ]\n"
" }\n\n"
"Allowed fault_taxonomy values:\n"
f" {', '.join(_TAXONOMY_CATEGORIES)}\n\n"
"Allowed root_cause values (must match exactly, snake_case):\n"
f" {', '.join(_ROOT_CAUSES)}\n"
" Plus any 'namespace_*' suffix for namespace-admission faults.\n\n"
"fault_object format — pick ONE of these shapes:\n"
f" app/<service> where service is one of: {', '.join(_FAULT_OBJECT_SERVICES)}\n"
f" node/<name> where name is one of: {', '.join(_FAULT_OBJECT_NODES)}\n"
f" namespace/<ns> where ns is one of: {', '.join(_FAULT_OBJECT_NAMESPACES)}\n\n"
"Rules:\n"
" - Output ONLY the JSON object. No prose, no markdown fences.\n"
" - If an INVESTIGATION SUMMARY is provided, it is the conclusion of a\n"
" tool-driven root-cause investigation. Treat it as AUTHORITATIVE:\n"
" rank 1 MUST be the schema-formalized version of the component and\n"
" root cause it identifies. Do NOT re-diagnose from the alert and\n"
" discard it — only deviate if the summary names no component or is\n"
" internally contradictory. (The scope rule below still applies when\n"
" choosing the fault_object level.)\n"
" - With NO investigation summary, rank 1 is your strongest hypothesis\n"
" reasoning from the alert alone.\n"
" - Ranks 2 and 3 should be plausible alternatives, not duplicates.\n"
" - fault_taxonomy MUST correspond to the chosen root_cause family.\n\n"
"Scope rule (CRITICAL — the fault lives at the level it ORIGINATES, not\n"
"where symptoms show up):\n"
" - If root_cause is any 'namespace_*' admission token (e.g.\n"
" 'namespace_memory_quota_exceeded', 'namespace_cpu_quota_exceeded',\n"
" 'namespace_pod_quota_exceeded'), fault_object MUST be\n"
" 'namespace/<X>' — NEVER 'app/<service>'. Quota / admission faults\n"
" live at the namespace; individual services are downstream victims.\n"
" - If the evidence shows MULTIPLE services in the same namespace\n"
" failing together AND the cause is a namespace-level limit (quota,\n"
" service account, network policy, resource cap), the strongest\n"
" rank-1 hypothesis is 'namespace/<X>' even if one service appears\n"
" 'first to fail'. A single-service prediction here is wrong scope.\n"
" - If the cause is genuinely an app-level misconfiguration (wrong\n"
" port, bad image reference, probe misconfig, missing secret binding\n"
" on ONE deployment), keep fault_object as 'app/<service>'. The\n"
" scope rule only fires for cross-service namespace-wide failures.\n\n"
"Performance-fault disambiguation (when metric anomalies are present):\n"
" - ``pod_cpu_overload``: rank-1 ``fault_object`` is the service whose\n"
" alert shows RESOURCE_SATURATION / cpu_cfs throttling ON THAT SERVICE.\n"
" - ``pod_network_delay``: rank-1 ``fault_object`` is the service with\n"
" the largest relative LATENCY_DEGRADATION spike (highest +%% increase\n"
" in p50/p90), NOT a different service that only shows CPU throttling.\n"
" CPU throttling on service A does not localize ``pod_network_delay``\n"
" onto A when service B has the extreme latency spike.\n"
" - Do NOT emit ``namespace_*`` quota tokens on performance alerts unless\n"
" the investigation explicitly identifies a ResourceQuota admission block.\n"
" - When an ALERT-DERIVED PERFORMANCE LOCALIZATION block is provided in\n"
" the user message, it is AUTHORITATIVE for rank-1 ``fault_object`` and\n"
" ``root_cause`` — formalize it even if the investigation summary names\n"
" a different service or a cluster-wide CPU saturation narrative.\n"
)
def _build_user_prompt(
alert_text: str,
investigation_summary: str,
*,
metric_alerts: str = "",
performance_localization_hint: dict[str, str] | None = None,
) -> str:
"""Assemble the predictor user-message body for one case.
Composes three optional blocks onto the alert text in fixed order so
the prompt prefix above the variable section stays cacheable:
1. ``ALERT:`` — required; alert text + appended ``metric_alerts``
block when present (treated as continuation of the alert).
2. ``ALERT-DERIVED PERFORMANCE LOCALIZATION`` — emitted only when
``performance_localization_hint`` is provided. Marked
AUTHORITATIVE for rank-1 to override any cluster-wide
narrative the investigation produced.
3. ``INVESTIGATION SUMMARY`` — emitted when ``investigation_summary``
is non-empty (opensre+llm path). Absent on the ``llm_alone``
path so the model reasons from the alert alone.
The closing instruction differs between the two paths: with a summary
the model is told to formalize its conclusion; without, to reason
from the alert. No model-state side effects — pure string assembly.
"""
alert_block = alert_text.strip()
if metric_alerts.strip():
alert_block = (
f"{alert_block}\n\n{metric_alerts.strip()}" if alert_block else metric_alerts.strip()
)
perf_block = ""
if performance_localization_hint:
perf_block = (
"ALERT-DERIVED PERFORMANCE LOCALIZATION (AUTHORITATIVE for rank-1\n"
"fault_object + root_cause — overrides cluster-wide investigation\n"
"narratives that name the wrong service):\n"
f" fault_object: {performance_localization_hint.get('fault_object', '')}\n"
f" root_cause: {performance_localization_hint.get('root_cause', '')}\n"
f" rationale: {performance_localization_hint.get('rationale', '')}\n\n"
)
if investigation_summary.strip():
body = (
"ALERT:\n"
f"{alert_block}\n\n"
"INVESTIGATION SUMMARY (formalize its conclusion unless the performance\n"
"localization block below overrides rank-1):\n"
f"{investigation_summary}\n\n"
f"{perf_block}"
"Set rank 1 to the localized component and root cause (apply the scope\n"
"rule for fault_object level). Emit the JSON object now."
)
else:
# llm_alone path — no prior investigation to lean on.
body = (
"ALERT:\n"
f"{alert_block}\n\n"
f"{perf_block}"
"No prior investigation evidence is available; reason from the\n"
"alert and any performance localization block above. Emit the JSON\n"
"object now."
)
return body
# --------------------------------------------------------------------------- #
# Response parsing #
# --------------------------------------------------------------------------- #
_FENCED_JSON = re.compile(r"```(?:json)?\s*(.*?)\s*```", re.DOTALL)
def _parse_predictions(text: str) -> dict[str, Any] | None:
"""Parse the LLM's text response into a validated predictions payload.
Accepts:
- bare JSON object
- JSON wrapped in ```json ... ``` or ``` ... ``` fences (common LLM output)
Returns None if the payload doesn't parse, doesn't contain
``top_3_predictions``, or contains zero usable predictions.
"""
if not text:
return None
candidate = text.strip()
match = _FENCED_JSON.search(candidate)
if match:
candidate = match.group(1).strip()
try:
parsed = json.loads(candidate)
except json.JSONDecodeError:
return None
if not isinstance(parsed, dict):
return None
predictions = parsed.get("top_3_predictions")
if not isinstance(predictions, list) or not predictions:
return None
cleaned: list[dict[str, Any]] = []
for index, prediction in enumerate(predictions[:3]):
if not isinstance(prediction, dict):
continue
fault_object = prediction.get("fault_object")
root_cause = prediction.get("root_cause")
if not isinstance(fault_object, str) or not isinstance(root_cause, str):
continue
# Derive fault_taxonomy deterministically from root_cause using the
# scorer's mapping. The LLM's guess is overridden because the paper's
# taxonomy is a function OF root_cause, not an independent dimension —
# the model often picks the surface-phase taxonomy ("Startup_Fault" for
# something that breaks during startup) instead of the root-cause
# family ("Runtime_Fault" for mysql_invalid_credentials). Without this
# override we lose a1 even on substantively-correct diagnoses.
# Lever A: snap onto the dataset's closed vocabulary before scoring so
# near-miss tokens don't auto-fail the exact-match scorer.
normalized_root_cause = _snap_root_cause(root_cause)
derived_taxonomy = taxonomy_for_root_cause(normalized_root_cause)
llm_taxonomy = (prediction.get("fault_taxonomy") or "").strip()
if llm_taxonomy and llm_taxonomy != derived_taxonomy:
logger.info(
"[predictor] rank=%d overrode LLM fault_taxonomy=%r with "
"derived=%r for root_cause=%r",
index + 1,
llm_taxonomy,
derived_taxonomy,
normalized_root_cause,
)
cleaned.append(
{
"rank": prediction.get("rank", index + 1),
"fault_taxonomy": derived_taxonomy,
"fault_object": _snap_fault_object(fault_object),
"root_cause": normalized_root_cause,
}
)
if not cleaned:
return None
return {"top_3_predictions": cleaned}