Files
VianoBugAnalysis/src/viano_bug_analysis/analysis/interpreter.py
T

226 lines
16 KiB
Python

from viano_bug_analysis.telemetry.decorators import observed
import hashlib
from dataclasses import dataclass
from pathlib import Path
from viano_bug_analysis.case import CaseBuilder, CaseBuilderLimits
from viano_bug_analysis.case.contracts import read_json, require_file, validate
from viano_bug_analysis.case.errors import CaseBuildError
from viano_bug_analysis.lifecycle import LifecycleError, State
from viano_bug_analysis.lifecycle.checkpoints import checkpoint_chain
from viano_bug_analysis.lifecycle.errors import LockBusy
from viano_bug_analysis.lifecycle.locking import FileLock
from viano_bug_analysis.lifecycle.storage import json_bytes, key
from viano_bug_analysis.llm import CaseLLMRunner, LLMError
from viano_bug_analysis.package import PackageReader, PackageReadError
from viano_bug_analysis.package.manifest import parse_manifest
from viano_bug_analysis.package.validator import ARCHIVE_ERRORS
from .context import InterpretationLimits, build_context
from .contracts import validate_interpretation
from .errors import InterpretationError
from .prompt import InterpretationPromptBuilder, PROMPT_VERSION
from .summary import render_interpretation
@dataclass(frozen=True)
class InterpretationResult:
status: str
package: Path
analysis_revision: int
interpretation_ref: str | None = None
summary_ref: str | None = None
reused: bool = False
retry_not_before: str | None = None
code: str | None = None
class CaseInterpreter:
def __init__(self, store, gateway, limits: InterpretationLimits | None = None, *, case_limits=None, clock=None):
self.store = store
self.gateway = gateway
self.limits = limits or InterpretationLimits()
self.case_builder = CaseBuilder(store, case_limits or CaseBuilderLimits(max_case_bytes=self.limits.max_case_bytes))
self.prompt = InterpretationPromptBuilder()
self.runner = CaseLLMRunner(store, gateway, clock=clock)
@observed("case_interpret")
async def interpret(self, case_id: str, package_id: str, *, attempt_id: str = "default") -> InterpretationResult:
if not isinstance(attempt_id, str) or not attempt_id.strip():
raise InterpretationError("INTERPRETATION_ATTEMPT")
try:
# No global worker lock spans the LLM request. Other cases can progress.
with FileLock(self.store.workspace.runtime / "locks" / f"interpretation-{key(case_id)}.lock"):
return await self._interpret(case_id, package_id, attempt_id)
except LockBusy:
raise
except (CaseBuildError, LifecycleError, LLMError, PackageReadError) as error:
raise InterpretationError(getattr(error, "code", "INTERPRETATION_PACKAGE")) from None
except ARCHIVE_ERRORS + (ValueError, TypeError, KeyError, UnicodeError, RecursionError):
raise InterpretationError("INTERPRETATION_INPUT") from None
async def _interpret(self, case_id, package_id, attempt_id):
latest = self.store.latest(case_id, package_id)
if latest is None:
raise InterpretationError("INTERPRETATION_CASE_NOT_READY")
with PackageReader(latest, self.store.limits) as reader:
manifest = reader.manifest
revision = int(manifest["analysis_revision"])
builder_id = f"case-builder:v1:revision:{revision}:complete"
built = next((cp for _, cp in checkpoint_chain(reader) if cp["step_id"] == builder_id), None)
if not built or not manifest["artifacts"].get("case") or built["details"].get("case_ref") != manifest["artifacts"]["case"]:
raise InterpretationError("INTERPRETATION_CASE_NOT_READY")
# Verify the confirmed case against all raw inputs before any LLM call.
fresh = self.case_builder.build(case_id, package_id)
latest = fresh.package
with PackageReader(latest, self.store.limits) as reader:
manifest = reader.manifest
case_ref = manifest["artifacts"]["case"]
case = read_json(reader, case_ref, "case", self.limits.max_case_bytes)
if any(case["case"][field] != manifest[field] for field in ("case_id", "package_id", "analysis_revision")):
raise InterpretationError("INTERPRETATION_CASE_IDENTITY")
context = build_context(case, case_ref, self.limits)
chain = checkpoint_chain(reader)
complete_id = f"case-interpreter:v1:revision:{revision}:complete"
completed = next((cp for _, cp in chain if cp["step_id"] == complete_id), None)
if completed:
return self._reuse(reader, context, completed, complete_id)
if manifest["state"] == State.BLOCKED:
return InterpretationResult("BLOCKED", latest, revision, retry_not_before=manifest["routing"]["retry_not_before"], code="LLM_UNAVAILABLE" if manifest["routing"]["reason"] == "LLM_UNAVAILABLE" else "CASE_BLOCKED")
if manifest["state"] not in {State.BUILDING_CASE, State.INTERPRETING_CASE}:
raise InterpretationError("INTERPRETATION_PHASE")
base = f"analysis/interpretations/{revision:04d}"
context_ref = base + "/context.json"
encoded_context = json_bytes(context)
context_hash = hashlib.sha256(encoded_context).hexdigest()
start_id = f"case-interpreter:v1:revision:{revision}:start"
start_details = {"operation": "case-interpreting", "interpreter_version": "1", "prompt_version": PROMPT_VERSION,
"case_ref": case_ref, "case_sha256": context["case_sha256"],
"context_ref": context_ref, "context_sha256": context_hash}
started = next((cp for _, cp in chain if cp["step_id"] == start_id), None)
if started:
if started["details"] != start_details:
raise InterpretationError("INTERPRETATION_CONTEXT_CHANGED")
stored_context = read_json(reader, context_ref, "case-interpretation-context", self.limits.max_context_bytes)
if stored_context != context:
raise InterpretationError("INTERPRETATION_CONTEXT_CHANGED")
else:
latest = self.store.advance(case_id, package_id, State.INTERPRETING_CASE, step_id=start_id,
details=start_details, additions={context_ref: encoded_context},
artifacts={"interpretation_context": context_ref, "case_interpretation": None, "case_interpretation_summary": None},
expected_generation=manifest["package_generation"])
request = self.gateway.validate_request(self.prompt.build(context))
operation_id = f"case-interpretation:v1:{attempt_id}"
outcome = await self.runner.run(case_id, package_id, operation_id=operation_id, request=request)
if outcome.status not in {"SUCCESS", "ERROR"}:
return InterpretationResult(outcome.status, outcome.package, revision,
retry_not_before=outcome.retry_not_before, code=outcome.code)
with PackageReader(outcome.package, self.store.limits) as reader:
current = reader.manifest
if current["state"] != State.INTERPRETING_CASE or current["analysis_revision"] != revision or current["artifacts"]["case"] != case_ref:
raise InterpretationError("INTERPRETATION_PHASE")
call = read_json(reader, outcome.result_ref, "llm-call", self.gateway.config.max_response_bytes + self.gateway.config.max_request_bytes + 16384)
request_hash = hashlib.sha256(json_bytes(request)).hexdigest()
if (call["case_id"] != case_id or call["package_id"] != package_id or call["analysis_revision"] != revision
or call["phase"] != State.INTERPRETING_CASE or call["status"] != outcome.status
or call["input_request_hash"] != request_hash or call["operation_key"] != key(f"{revision}:{operation_id}")):
raise InterpretationError("INTERPRETATION_LLM_BINDING")
common = {"format_version": 1, "interpreter_version": "1", "prompt_version": PROMPT_VERSION,
"case_id": case_id, "package_id": package_id, "analysis_revision": revision,
"case_ref": case_ref, "case_sha256": context["case_sha256"],
"context_ref": context_ref, "context_sha256": context_hash, "llm_result_ref": outcome.result_ref}
if outcome.status == "ERROR":
return self._reject(reader, common, attempt_id, outcome.code)
try:
payload = call["outcome"]["document"]
validate_interpretation(payload, context)
document = {**common, "format": "viano-case-interpretation",
"expected_behavior_completeness": context["expected_behavior_completeness"],
"expected_behavior_provenance": context["expected_behavior_provenance"],
"llm_metadata": call["outcome"]["metadata"], "interpretation": payload}
validate(document, "case-interpretation")
encoded = json_bytes(document)
summary = render_interpretation(document).encode("utf-8")
if len(encoded) > self.limits.max_output_bytes:
raise InterpretationError("INTERPRETATION_OUTPUT_LIMIT")
if len(summary) > self.limits.max_summary_bytes:
raise InterpretationError("INTERPRETATION_SUMMARY_LIMIT")
except InterpretationError as error:
return self._reject(reader, common, attempt_id, error.code)
interpretation_ref = base + "/case_interpretation.json"
summary_ref = base + "/case_interpretation.md"
details = {"operation": "case-interpreted", "interpreter_version": "1", "case_ref": case_ref,
"case_sha256": context["case_sha256"], "context_ref": context_ref, "context_sha256": context_hash,
"interpretation_ref": interpretation_ref, "summary_ref": summary_ref, "llm_result_ref": outcome.result_ref}
result = self.store.advance(case_id, package_id, State.INTERPRETING_CASE, step_id=complete_id,
details=details, additions={interpretation_ref: encoded, summary_ref: summary},
artifacts={"case_interpretation": interpretation_ref, "case_interpretation_summary": summary_ref,
"interpretation_context": context_ref},
expected_generation=current["package_generation"])
return InterpretationResult("COMPLETED", result, revision, interpretation_ref, summary_ref)
def _reject(self, reader, common, attempt_id, code):
document = {**common, "format": "viano-case-interpretation-error", "code": code}
validate(document, "case-interpretation-error")
error_ref = f"analysis/interpretations/{common['analysis_revision']:04d}/errors/{key(attempt_id)}.json"
step_id = f"case-interpreter:v1:revision:{common['analysis_revision']}:attempt:{key(attempt_id)}:rejected"
reused = any(cp["step_id"] == step_id for _, cp in checkpoint_chain(reader))
details = {"operation": "case-interpretation-rejected", "interpreter_version": "1", "context_ref": common["context_ref"],
"error_ref": error_ref, "llm_result_ref": common["llm_result_ref"], "code": code}
result = self.store.advance(common["case_id"], common["package_id"], State.INTERPRETING_CASE, step_id=step_id,
details=details, additions={error_ref: json_bytes(document)},
expected_generation=reader.manifest["package_generation"])
self.runner.logger.error("Case interpretation rejected case_id=%s code=%s", common["case_id"], code)
return InterpretationResult("ERROR", result, common["analysis_revision"], reused=reused, code=code)
def _reuse(self, reader, context, checkpoint, step_id):
details = checkpoint["details"]
document = read_json(reader, details["interpretation_ref"], "case-interpretation", self.limits.max_output_bytes)
stored_context = read_json(reader, details["context_ref"], "case-interpretation-context", self.limits.max_context_bytes)
if stored_context != context or document["case_sha256"] != context["case_sha256"]:
raise InterpretationError("INTERPRETATION_CONTEXT_CHANGED")
if any(document[field] != reader.manifest[field] for field in ("case_id", "package_id", "analysis_revision")):
raise InterpretationError("INTERPRETATION_CASE_IDENTITY")
validate_interpretation(document["interpretation"], context)
if document["context_sha256"] != hashlib.sha256(json_bytes(context)).hexdigest():
raise InterpretationError("INTERPRETATION_CONTEXT_CHANGED")
expected_fields = {"case_ref": context["case_ref"], "case_sha256": context["case_sha256"],
"context_ref": details["context_ref"], "context_sha256": document["context_sha256"],
"llm_result_ref": details["llm_result_ref"]}
if any(document[field] != value or details.get(field) != value for field, value in expected_fields.items()):
raise InterpretationError("INTERPRETATION_LLM_BINDING")
if (document["expected_behavior_completeness"] != context["expected_behavior_completeness"]
or document["expected_behavior_provenance"] != context["expected_behavior_provenance"]):
raise InterpretationError("INTERPRETATION_LLM_BINDING")
call = read_json(reader, document["llm_result_ref"], "llm-call", self.gateway.config.max_response_bytes + self.gateway.config.max_request_bytes + 16384)
if (call["status"] != "SUCCESS" or call["phase"] != State.INTERPRETING_CASE
or any(call[field] != document[field] for field in ("case_id", "package_id", "analysis_revision"))
or call["outcome"].get("document") != document["interpretation"] or call["outcome"].get("metadata") != document["llm_metadata"]):
raise InterpretationError("INTERPRETATION_LLM_BINDING")
request_ref = document["llm_result_ref"].rsplit("/", 1)[0] + "/request.json"
require_file(reader, request_ref)
with reader._archive.open(request_ref) as stream:
encoded_request = reader.validator._read_bounded(stream, min(self.gateway.config.max_request_bytes, self.store.limits.max_file_bytes))
saved_request = parse_manifest(encoded_request)
if not isinstance(saved_request, dict):
raise InterpretationError("INTERPRETATION_LLM_BINDING")
expected_request = self.prompt.build(context)
expected_request["privacy"] = saved_request.get("privacy")
if saved_request != expected_request or call["input_request_hash"] != hashlib.sha256(encoded_request).hexdigest():
raise InterpretationError("INTERPRETATION_LLM_BINDING")
summary = render_interpretation(document).encode("utf-8")
if len(summary) > self.limits.max_summary_bytes:
raise InterpretationError("INTERPRETATION_SUMMARY_LIMIT")
require_file(reader, details["summary_ref"])
with reader._archive.open(details["summary_ref"]) as stream:
stored_summary = reader.validator._read_bounded(stream, min(self.limits.max_summary_bytes, self.store.limits.max_file_bytes))
artifacts = {"case_interpretation": details["interpretation_ref"], "case_interpretation_summary": details["summary_ref"],
"interpretation_context": details["context_ref"]}
if stored_summary != summary or any(reader.manifest["artifacts"].get(name) != ref for name, ref in artifacts.items()):
raise InterpretationError("INTERPRETATION_OUTPUT_CHANGED")
latest = self.store.advance(document["case_id"], document["package_id"], State.INTERPRETING_CASE, step_id=step_id,
details=details, additions={details["interpretation_ref"]: json_bytes(document), details["summary_ref"]: summary}, artifacts=artifacts)
if latest != reader.path:
raise InterpretationError("INTERPRETATION_GENERATION_CHANGED")
return InterpretationResult("COMPLETED", latest, document["analysis_revision"], details["interpretation_ref"], details["summary_ref"], True)