* fix(analyzer): escalate temperature on retries to self-recover bad JSON Ollama is non-deterministic even at temperature 0 (no fixed seed, CUDA/batch variance), so the analyzer occasionally emits non-schema JSON that fails parsing (SC0011 -> null Scene). The retry loop reused the prompt unchanged at the same temperature, so all internal retries reproduced the same failure -- only a fresh manual re-run recovered. Escalate the sampling temperature on retry attempts only (base -> +0.2 per retry, capped at 1.0); the prompt is still byte-for-byte unchanged (§12.9) and the first attempt keeps the configured temperature, so the happy path stays deterministic while a malformed run can now self-recover. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(analyzer): use pytest.approx for temperature assertions (SonarCloud S1244) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: kunail0804 <kunail0804@gmail.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
172 lines
6.1 KiB
Python
172 lines
6.1 KiB
Python
"""Analyzer backend interface and the Ollama implementation (MASTER_SPEC §12.3, §12.10).
|
|
|
|
The Scene Analyzer is the only LLM stage. The backend is kept behind a generic
|
|
:class:`AnalyzerBackend` interface so it can be swapped without touching any
|
|
downstream stage, and it never imports downstream compiler modules.
|
|
|
|
:class:`OllamaBackend` talks to a local Ollama server's ``/api/generate`` endpoint
|
|
over HTTP using only the standard library. The HTTP call is an injectable
|
|
``transport`` so the backend is fully unit-testable without a live Ollama.
|
|
|
|
Failure modes map to specific codes: connection failure -> SC0012 (Fatal),
|
|
timeout -> SC0013, and an unexpected/model response -> SC0018.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import time
|
|
import urllib.error
|
|
import urllib.request
|
|
from collections.abc import Callable
|
|
from dataclasses import dataclass
|
|
from typing import Any, Protocol, runtime_checkable
|
|
|
|
from compiler.common.config import Config
|
|
from compiler.common.message_codes import message
|
|
from compiler.common.result import Message
|
|
|
|
_DEFAULT_BASE_URL = "http://localhost:11434"
|
|
|
|
# A transport performs the HTTP POST and returns the parsed JSON body. It may
|
|
# raise TimeoutError, ConnectionError, or urllib.error.URLError/HTTPError.
|
|
Transport = Callable[[str, dict[str, Any], float], Any]
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class BackendResult:
|
|
"""A successful backend response plus telemetry for logging (§12.10)."""
|
|
|
|
text: str
|
|
model: str
|
|
duration_seconds: float
|
|
|
|
|
|
class AnalyzerBackendError(Exception):
|
|
"""Base class for backend failures; ``code`` maps to Appendix B / extensions."""
|
|
|
|
code = "SC0018"
|
|
|
|
def to_message(self, **context: Any) -> Message:
|
|
return message(self.code, str(self), **context)
|
|
|
|
|
|
class BackendUnavailableError(AnalyzerBackendError):
|
|
"""The backend could not be reached (connection failure)."""
|
|
|
|
code = "SC0012"
|
|
|
|
|
|
class BackendTimeoutError(AnalyzerBackendError):
|
|
"""The backend did not respond within the configured timeout."""
|
|
|
|
code = "SC0013"
|
|
|
|
|
|
class BackendResponseError(AnalyzerBackendError):
|
|
"""The backend returned an unexpected response or a model/HTTP error."""
|
|
|
|
code = "SC0018"
|
|
|
|
|
|
@runtime_checkable
|
|
class AnalyzerBackend(Protocol):
|
|
"""A language-model backend that turns a prompt into raw text."""
|
|
|
|
def generate(
|
|
self, prompt: str, *, temperature: float | None = None
|
|
) -> BackendResult: # pragma: no cover - interface
|
|
...
|
|
|
|
|
|
def _urllib_transport(url: str, payload: dict[str, Any], timeout: float) -> Any:
|
|
"""Default transport: POST JSON and return the parsed JSON body (stdlib only)."""
|
|
data = json.dumps(payload).encode("utf-8")
|
|
request = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"})
|
|
with urllib.request.urlopen(request, timeout=timeout) as response: # noqa: S310 (local Ollama)
|
|
return json.loads(response.read().decode("utf-8"))
|
|
|
|
|
|
class OllamaBackend:
|
|
"""Analyzer backend backed by a local Ollama server."""
|
|
|
|
def __init__(
|
|
self,
|
|
model: str,
|
|
temperature: float = 0.0,
|
|
timeout: float = 60,
|
|
base_url: str = _DEFAULT_BASE_URL,
|
|
transport: Transport | None = None,
|
|
) -> None:
|
|
self._model = model
|
|
self._temperature = temperature
|
|
self._timeout = timeout
|
|
self._base_url = base_url.rstrip("/")
|
|
self._transport = transport or _urllib_transport
|
|
|
|
@classmethod
|
|
def from_config(
|
|
cls,
|
|
config: Config,
|
|
base_url: str = _DEFAULT_BASE_URL,
|
|
transport: Transport | None = None,
|
|
) -> OllamaBackend:
|
|
"""Build a backend from a Config's ``analyzer`` section."""
|
|
return cls(
|
|
model=config.analyzer.model,
|
|
temperature=config.analyzer.temperature,
|
|
timeout=config.analyzer.timeout,
|
|
base_url=base_url,
|
|
transport=transport,
|
|
)
|
|
|
|
def generate(self, prompt: str, *, temperature: float | None = None) -> BackendResult:
|
|
"""Send ``prompt`` unmodified to Ollama and return the raw text response.
|
|
|
|
Args:
|
|
prompt: The full prompt, sent byte-for-byte unchanged.
|
|
temperature: Optional per-call sampling temperature; falls back to the
|
|
backend's configured temperature when ``None``. Used by the retry
|
|
orchestration to perturb sampling without altering the prompt.
|
|
|
|
Raises:
|
|
BackendTimeoutError: On timeout (SC0013).
|
|
BackendUnavailableError: On connection failure (SC0012).
|
|
BackendResponseError: On an HTTP error or unexpected response (SC0018).
|
|
"""
|
|
url = f"{self._base_url}/api/generate"
|
|
effective_temperature = self._temperature if temperature is None else temperature
|
|
payload = {
|
|
"model": self._model,
|
|
"prompt": prompt,
|
|
"stream": False,
|
|
"options": {"temperature": effective_temperature},
|
|
}
|
|
|
|
start = time.perf_counter()
|
|
try:
|
|
raw = self._transport(url, payload, self._timeout)
|
|
except TimeoutError as exc:
|
|
raise BackendTimeoutError(
|
|
f"Analyzer backend timed out after {self._timeout}s."
|
|
) from exc
|
|
except urllib.error.HTTPError as exc:
|
|
raise BackendResponseError(
|
|
f"Analyzer backend returned HTTP {exc.code}: {exc.reason}."
|
|
) from exc
|
|
except urllib.error.URLError as exc:
|
|
if isinstance(exc.reason, TimeoutError):
|
|
raise BackendTimeoutError(
|
|
f"Analyzer backend timed out after {self._timeout}s."
|
|
) from exc
|
|
raise BackendUnavailableError(
|
|
f"Analyzer backend is unavailable: {exc.reason}."
|
|
) from exc
|
|
except ConnectionError as exc:
|
|
raise BackendUnavailableError(f"Analyzer backend is unavailable: {exc}.") from exc
|
|
duration = time.perf_counter() - start
|
|
|
|
if not isinstance(raw, dict) or "response" not in raw:
|
|
raise BackendResponseError("Ollama response is missing the 'response' field.")
|
|
return BackendResult(text=raw["response"], model=self._model, duration_seconds=duration)
|