144 lines
5.6 KiB
Python
144 lines
5.6 KiB
Python
from __future__ import annotations
|
|
|
|
import io
|
|
import json
|
|
import re
|
|
import urllib.request
|
|
import zipfile
|
|
from typing import Any
|
|
from urllib.parse import urlparse
|
|
|
|
from failure_taxonomy import classify_failure_report
|
|
from llm_classifier import LLMAssistedClassifier
|
|
|
|
|
|
MAX_LOG_ARCHIVE_BYTES = 20_000_000
|
|
MAX_RUNTIME_LOG_BYTES = 20_000_000
|
|
MAX_ERROR_REPORT_BYTES = 1_000_000
|
|
ERROR_LINE_PATTERN = re.compile(
|
|
r"(?:\b(?:[A-Za-z_]*(?:Error|Exception)|PREFLIGHT_[A-Z_]+|OOM)\b|"
|
|
r"out of memory|not supported|unsupported|does not recognize|cannot|can.t|"
|
|
r"not found|no such file|failed to|invalid|traceback|找不到空闲卡)",
|
|
re.IGNORECASE,
|
|
)
|
|
|
|
|
|
def fetch_and_classify_failure_log(
|
|
log_url: str,
|
|
*,
|
|
task_context: dict[str, Any] | None = None,
|
|
llm_classifier: LLMAssistedClassifier | None = None,
|
|
timeout_seconds: int = 20,
|
|
) -> dict[str, Any]:
|
|
parsed_url = urlparse(log_url)
|
|
if parsed_url.scheme not in {"http", "https"} or not parsed_url.netloc:
|
|
raise ValueError("failure log URL must be HTTP(S)")
|
|
request = urllib.request.Request(
|
|
log_url,
|
|
headers={"User-Agent": "modelhub-submmit-failure-inspector/1"},
|
|
)
|
|
with urllib.request.urlopen(request, timeout=max(1, int(timeout_seconds))) as response:
|
|
archive = response.read(MAX_LOG_ARCHIVE_BYTES + 1)
|
|
if len(archive) > MAX_LOG_ARCHIVE_BYTES:
|
|
raise ValueError("failure log archive exceeds size limit")
|
|
return classify_failure_archive(
|
|
archive,
|
|
task_context=task_context,
|
|
llm_classifier=llm_classifier,
|
|
)
|
|
|
|
|
|
def classify_failure_archive(
|
|
archive: bytes,
|
|
*,
|
|
task_context: dict[str, Any] | None = None,
|
|
llm_classifier: LLMAssistedClassifier | None = None,
|
|
) -> dict[str, Any]:
|
|
report: dict[str, Any] = {}
|
|
runtime_log = ""
|
|
with zipfile.ZipFile(io.BytesIO(archive)) as bundle:
|
|
names = set(bundle.namelist())
|
|
if "error_report_raw.json" in names:
|
|
info = bundle.getinfo("error_report_raw.json")
|
|
if info.file_size <= MAX_ERROR_REPORT_BYTES:
|
|
try:
|
|
parsed = json.loads(bundle.read("error_report_raw.json"))
|
|
if isinstance(parsed, dict):
|
|
report = parsed
|
|
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
report = {}
|
|
if "pod_runtime_log.txt" in names:
|
|
info = bundle.getinfo("pod_runtime_log.txt")
|
|
if info.file_size <= MAX_RUNTIME_LOG_BYTES:
|
|
runtime_log = bundle.read("pod_runtime_log.txt").decode("utf-8", "replace")
|
|
|
|
error_lines = _extract_error_lines(runtime_log)
|
|
report_code = str(report.get("code") or "").strip() or None
|
|
classification = classify_failure_report(report_code, error_lines)
|
|
result: dict[str, Any] = {
|
|
"failureCode": report_code,
|
|
"failureSuggestion": str(report.get("suggestion") or "")[:500] or None,
|
|
"failureCategory": classification.category,
|
|
"failureScope": classification.scope,
|
|
"failureAction": classification.action,
|
|
"failureDeterministic": classification.deterministic,
|
|
"failureNeedsLlm": classification.needs_llm,
|
|
"failureClassificationReason": classification.reason,
|
|
"failureEvidence": error_lines[-12:],
|
|
}
|
|
observed_memory_gib = _extract_observed_gpu_memory_gib(error_lines)
|
|
if report_code == "PREFLIGHT_OOM" and observed_memory_gib is not None:
|
|
result["failureObservedGpuMemoryGiB"] = observed_memory_gib
|
|
if classification.needs_llm and llm_classifier is not None and llm_classifier.enabled:
|
|
llm_decision = llm_classifier.classify_failure(
|
|
task_context=dict(task_context or {}),
|
|
report_code=report_code,
|
|
suggestion=result["failureSuggestion"],
|
|
error_lines=error_lines[-12:],
|
|
)
|
|
result["failureLlmDecision"] = llm_decision
|
|
# Promote only a confident semantic decision. Deterministic rules and
|
|
# platform signatures above never reach this branch, so an LLM cannot
|
|
# override the hard classifiers.
|
|
confidence = float(llm_decision.get("confidence") or 0.0)
|
|
scope = str(llm_decision.get("scope") or "unknown")
|
|
if confidence >= 0.80 and scope != "unknown":
|
|
result.update(
|
|
{
|
|
"failureCategory": llm_decision.get("category") or result["failureCategory"],
|
|
"failureScope": scope,
|
|
"failureAction": llm_decision.get("action") or result["failureAction"],
|
|
"failureClassificationReason": f"llm:{llm_decision.get('reason') or 'semantic_classification'}",
|
|
"failureNeedsLlm": False,
|
|
}
|
|
)
|
|
return result
|
|
|
|
|
|
def _extract_error_lines(runtime_log: str) -> list[str]:
|
|
selected: list[str] = []
|
|
for raw_line in runtime_log.splitlines():
|
|
line = raw_line.strip()
|
|
if not line or not ERROR_LINE_PATTERN.search(line):
|
|
continue
|
|
line = re.sub(r"\x1b\[[0-9;]*m", "", line)
|
|
line = re.sub(r"\b[0-9a-f]{32,64}\b", "<id>", line, flags=re.IGNORECASE)
|
|
line = line[:1200]
|
|
if line not in selected:
|
|
selected.append(line)
|
|
return selected[-24:]
|
|
|
|
|
|
def _extract_observed_gpu_memory_gib(error_lines: list[str]) -> float | None:
|
|
for line in error_lines:
|
|
match = re.search(
|
|
r"=\s*(?P<available>[0-9]+(?:\.[0-9]+)?)\s*GB\s+available\b",
|
|
line,
|
|
flags=re.IGNORECASE,
|
|
)
|
|
if match:
|
|
value = float(match.group("available"))
|
|
if 0 < value <= 1024:
|
|
return value
|
|
return None
|