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
146 lines
4.8 KiB
Python
146 lines
4.8 KiB
Python
"""Parse a free-text LLM diagnosis into structured root-cause fields.
|
|
|
|
Legacy fallback used when the diagnose stage's structured output is unavailable.
|
|
The model emits labelled sections (``ROOT_CAUSE:``, ``VALIDATED_CLAIMS:``, …) and
|
|
this reads each one back out. All the section-scanning shares two helpers:
|
|
:func:`_text_between` (the text after a label, up to the next section) and
|
|
:func:`_cleaned_bullets` (list items with ``*``/``-``/``•`` markers stripped).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from collections.abc import Iterator
|
|
from dataclasses import dataclass
|
|
|
|
from core.domain.types.root_cause_categories import VALID_ROOT_CAUSE_CATEGORIES
|
|
|
|
# Sections that can follow ROOT_CAUSE:, in the order they appear — used to bound
|
|
# where the root-cause text and each claim list end.
|
|
_SECTIONS_AFTER_ROOT_CAUSE = (
|
|
"ROOT_CAUSE_CATEGORY:",
|
|
"VALIDATED_CLAIMS:",
|
|
"NON_VALIDATED_CLAIMS:",
|
|
"CAUSAL_CHAIN:",
|
|
"REMEDIATION_STEPS:",
|
|
)
|
|
_REMEDIATION_STOP_HEADERS = (
|
|
"ROOT_CAUSE",
|
|
"VALIDATED",
|
|
"NON_VALIDATED",
|
|
"CAUSAL",
|
|
"ALTERNATIVE",
|
|
"REMEDIATION_STEPS",
|
|
)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class RootCauseResult:
|
|
root_cause: str
|
|
root_cause_category: str
|
|
validated_claims: list[str]
|
|
non_validated_claims: list[str]
|
|
causal_chain: list[str]
|
|
remediation_steps: list[str]
|
|
|
|
|
|
def _text_between(text: str, start: str, ends: tuple[str, ...]) -> str | None:
|
|
"""Return the text after ``start`` up to the first marker in ``ends``.
|
|
|
|
``None`` when ``start`` is absent; the full remainder when no ``ends`` match.
|
|
"""
|
|
if start not in text:
|
|
return None
|
|
section = text.split(start, 1)[1]
|
|
for end in ends:
|
|
if end in section:
|
|
return section.split(end, 1)[0]
|
|
return section
|
|
|
|
|
|
def _cleaned_bullets(section: str, strip: str = "*-• ") -> Iterator[str]:
|
|
"""Yield each non-empty line with its leading bullet/number marker stripped."""
|
|
for raw in section.strip().split("\n"):
|
|
line = raw.strip().lstrip(strip).strip()
|
|
if line:
|
|
yield line
|
|
|
|
|
|
def _extract_category(response: str) -> str:
|
|
"""First valid category found on any line after ``ROOT_CAUSE_CATEGORY:``."""
|
|
section = _text_between(response, "ROOT_CAUSE_CATEGORY:", ())
|
|
if section is None:
|
|
return "unknown"
|
|
for raw in section.split("\n"):
|
|
candidate = raw.strip().lower()
|
|
if not candidate:
|
|
continue
|
|
if candidate in VALID_ROOT_CAUSE_CATEGORIES:
|
|
return candidate
|
|
for token in re.findall(r"[a-z_][a-z0-9_]*", candidate):
|
|
if token in VALID_ROOT_CAUSE_CATEGORIES:
|
|
return str(token)
|
|
return "unknown"
|
|
|
|
|
|
def _claims(after: str, start: str, ends: tuple[str, ...], skip: tuple[str, ...]) -> list[str]:
|
|
"""Bullet items in the ``start`` section, dropping lines that begin with ``skip``."""
|
|
section = _text_between(after, start, ends)
|
|
if section is None:
|
|
return []
|
|
return [line for line in _cleaned_bullets(section) if not line.startswith(skip)]
|
|
|
|
|
|
def _remediation_steps(after: str) -> list[str]:
|
|
"""Numbered/bulleted steps after ``REMEDIATION_STEPS:``, stopping at the next header."""
|
|
section = _text_between(after, "REMEDIATION_STEPS:", ())
|
|
if section is None:
|
|
return []
|
|
steps: list[str] = []
|
|
for line in _cleaned_bullets(section, strip="*-•( "):
|
|
if line.startswith("("):
|
|
continue
|
|
if line.startswith(_REMEDIATION_STOP_HEADERS):
|
|
break
|
|
steps.append(line)
|
|
return steps
|
|
|
|
|
|
def parse_root_cause(response: str) -> RootCauseResult:
|
|
"""Parse root cause, category, and claims from an LLM diagnosis response."""
|
|
category = _extract_category(response)
|
|
|
|
after = _text_between(response, "ROOT_CAUSE:", ())
|
|
if after is None:
|
|
return RootCauseResult("Unable to determine root cause", category, [], [], [], [])
|
|
|
|
root_cause = after
|
|
for end in _SECTIONS_AFTER_ROOT_CAUSE:
|
|
if end in after:
|
|
root_cause = after.split(end, 1)[0]
|
|
break
|
|
|
|
return RootCauseResult(
|
|
root_cause=root_cause.strip(),
|
|
root_cause_category=category,
|
|
validated_claims=_claims(
|
|
after,
|
|
"VALIDATED_CLAIMS:",
|
|
("NON_VALIDATED_CLAIMS:", "CAUSAL_CHAIN:", "REMEDIATION_STEPS:"),
|
|
skip=("NON_", "CAUSAL_CHAIN", "CONFIDENCE", "ROOT_CAUSE", "REMEDIATION_STEPS"),
|
|
),
|
|
non_validated_claims=_claims(
|
|
after,
|
|
"NON_VALIDATED_CLAIMS:",
|
|
("ALTERNATIVE_HYPOTHESES_CONSIDERED:", "CAUSAL_CHAIN:", "REMEDIATION_STEPS:"),
|
|
skip=("CAUSAL_CHAIN", "ALTERNATIVE", "REMEDIATION_STEPS"),
|
|
),
|
|
causal_chain=_claims(
|
|
after, "CAUSAL_CHAIN:", ("REMEDIATION_STEPS:",), skip=("ALTERNATIVE",)
|
|
),
|
|
remediation_steps=_remediation_steps(after),
|
|
)
|
|
|
|
|
|
__all__ = ["RootCauseResult", "parse_root_cause"]
|