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 viano_bug_analysis.tools.config import ToolError from .context import InterpretationLimits, build_context from .contracts import validate_interpretation from .errors import InterpretationError from .prompt import InterpretationPromptBuilder from .summary import render_interpretation from .joint_planning import interpretation_payload @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, correction_config=None, project_research=None, joint_config=None, joint_version=2): 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) self.correction_config = correction_config self.project_research = project_research self.joint_config = joint_config self.joint_version = joint_version if type(joint_version) is not int or joint_version not in (1,2):raise InterpretationError("INTERPRETATION_CONFIG") _correction_result = staticmethod(InterpretationResult) def _request(self, context): request = self.prompt.build(context) from .correction import augment, current_attempt attempt = current_attempt(self) if attempt: with PackageReader(self.store.latest(context["case"]["case_id"], context["case"]["package_id"]), self.store.limits) as reader: request = augment(request, reader, "interpretation", attempt) return request @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"): from .correction import dispatch return await dispatch(self, "interpretation", case_id, package_id, attempt_id, self._interpret) except LockBusy: raise except (CaseBuildError, LifecycleError, LLMError, PackageReadError, ToolError) 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 from .entity_resolution import EntityResolver resolver = EntityResolver(self) with PackageReader(latest, self.store.limits) as reader: case_ref = reader.manifest["artifacts"]["case"] raw_case = read_json(reader, case_ref, "case", self.limits.max_case_bytes) raw_context = build_context(raw_case, case_ref, self.limits) allow_new = not any(cp["step_id"] in { f"case-interpreter:v1:revision:{revision}:start", f"case-interpreter:v1:revision:{revision}:complete"} for _, cp in checkpoint_chain(reader)) if reader.manifest["state"] == State.BLOCKED: return InterpretationResult("BLOCKED", latest, revision, code=reader.manifest["routing"]["reason"], retry_not_before=reader.manifest["routing"]["retry_not_before"]) resolution = await resolver.run(raw_context, allow_new=allow_new) if resolution is not None: return resolution latest = self.store.latest(case_id, package_id) 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 = resolver.apply(reader, build_context(case, case_ref, self.limits)) frozen_ref = f"analysis/interpretations/{revision:04d}/context.json" if frozen_ref in reader.files: frozen = read_json(reader, frozen_ref, "case-interpretation-context", self.limits.max_context_bytes) if frozen.get('model_encoding'): context['model_encoding'] = frozen['model_encoding'] if frozen.get('context_selection'): context['context_selection'] = frozen['context_selection'] elif self.prompt.version(context) == 'case_interpretation_resolved_v6': context['model_encoding'] = 'shared_json_v1' context['context_selection'] = 'phase_selection_v1' if frozen_ref in reader.files: if frozen.get("joint_generation"): context["joint_generation"] = frozen["joint_generation"] if self.joint_config and self.joint_config.policy() != frozen["joint_generation"]["policy"]: raise InterpretationError("INTERPRETATION_CONTEXT_CHANGED") elif self.joint_config: from .hypothesis_builder import HypothesisBuilder prior = HypothesisBuilder(self.store, self.gateway, self.joint_config)._history(reader, checkpoint_chain(reader), revision) context["joint_generation"] = {"policy": self.joint_config.policy(), "prior_generations": prior} if self.joint_version==2:context["joint_generation"].update(version=2, recovery_version=1) prompt_version = self.prompt.version(context) 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) if len(encoded_context)>self.limits.max_context_bytes: raise InterpretationError("INTERPRETATION_CONTEXT_LIMIT") 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._request(context)) operation_id = f"case-interpretation:v1:{attempt_id}" outcome = await self.runner.run(case_id, package_id, operation_id=operation_id, request=request, validator=None if context.get("joint_generation") else lambda payload: validate_interpretation(payload,context)) 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": request["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 = interpretation_payload(call, context, reader) 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} if common['prompt_version'] in {'case_plan_v2','case_plan_statement_repair_v2'}: from .joint_v2 import issues context=read_json(reader,common['context_ref'],'case-interpretation-context',self.limits.max_context_bytes) call=read_json(reader,common['llm_result_ref'],'llm-call',self.gateway.config.max_request_bytes+self.gateway.config.max_response_bytes+16384) try:payload=interpretation_payload(call,context,reader) except InterpretationError:payload=None document['validation_issues']=issues(payload,context) 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 interpretation_payload(call, context, reader) != 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) from .correction import replay_request expected_request = replay_request(reader, expected_request, call, "interpretation") 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)