Files
pv-agent/agent/ollama_client.py
fegger 1594aa9cbd length-Retry: abgeschnittene Antworten sind die q-024-Ursache, kein Thinking (D14)
- Diagnose: q-024-Flakiness war KEIN Think-Ghost (thinking-Feld leer,
  keine Tags), sondern num_predict=1024 — lange belegte Antworten
  brachen bei done_reason=length ab (3/3 Sondenlaeufe), Zitationen
  wurden unvollstaendig, CITE_RE matchte partielle IDs -> Verletzung ->
  Regenerierungs-Eskalation -> UNCERTAIN.
- Fix: num_predict 1024 -> 2048; OllamaClient.chat_full() liefert
  (content, done_reason); chat_with_length_retry() wiederholt bei
  length einmal mit 2x Budget (technischer Retry, kein Regel-
  Regenerierungszaehler) - im Antwort-, Map- und Regenerierungspfad.
- Voll-Eval (46 Fragen): Zitier-Praezision 100 %, Verweigerung korrekt
  100 % (46/46) - erstmals alle M3-Gates erfuellt; erwartete Quelle
  92,7 %; Latenz mean 39,6 s / p95 78 s (vollstaendige statt
  abgeschnittener Antworten). q-024: 4/4 stabil.
- Tests 59 -> 60. Report lokal data/eval-qwen38-lengthfix.json.
2026-09-15 14:14:27 +02:00

122 lines
4.7 KiB
Python

"""Schmaler Ollama-HTTP-Client (Chat + Embeddings).
Keine Tools, kein Browsing, keine Web-Hooks — der Agent hat architektonisch
keinen Weg aus der Wissensbasis hinaus (Grounding-Regel 1).
"""
from __future__ import annotations
from typing import Any
import httpx
class OllamaError(RuntimeError):
pass
class OllamaClient:
def __init__(self, base_url: str, embed_timeout_s: float = 240.0,
chat_timeout_s: float = 300.0):
self.base_url = base_url.rstrip("/")
self.embed_timeout_s = embed_timeout_s
self.chat_timeout_s = chat_timeout_s
# Per-Request-Timeouts mit kurzem Connect-Budget — ein unerreichbarer
# Server muss in Sekunden, nicht Minuten scheitern.
self.embed_timeout = httpx.Timeout(embed_timeout_s, connect=10.0)
self.chat_timeout = httpx.Timeout(chat_timeout_s, connect=10.0)
self._client = httpx.Client(timeout=self.chat_timeout)
def close(self) -> None:
self._client.close()
# -- Infrastruktur ------------------------------------------------------
def is_up(self) -> bool:
try:
r = self._client.get(f"{self.base_url}/api/tags", timeout=5.0)
return r.status_code == 200
except httpx.HTTPError:
return False
def list_models(self) -> list[str]:
r = self._client.get(f"{self.base_url}/api/tags")
r.raise_for_status()
return [m.get("model", "") for m in r.json().get("models", [])]
# -- Embeddings ---------------------------------------------------------
def embed(self, model: str, texts: list[str]) -> list[list[float]]:
"""Batch-Embeddings via /api/embed; Fallback auf Legacy /api/embeddings."""
r = self._client.post(
f"{self.base_url}/api/embed",
json={"model": model, "input": texts},
timeout=self.embed_timeout,
)
if r.status_code == 404:
out: list[list[float]] = []
for t in texts:
rr = self._client.post(
f"{self.base_url}/api/embeddings",
json={"model": model, "prompt": t},
timeout=self.embed_timeout,
)
rr.raise_for_status()
emb = rr.json().get("embedding")
if not emb:
raise OllamaError("legacy /api/embeddings returned no embedding")
out.append(emb)
return out
r.raise_for_status()
emb = r.json().get("embeddings")
if not isinstance(emb, list) or len(emb) != len(texts):
raise OllamaError(f"unexpected /api/embed response for {len(texts)} inputs")
return emb
# -- Chat ---------------------------------------------------------------
def chat_full(self, model: str, messages: list[dict],
temperature: float = 0.1, num_ctx: int = 16384,
num_predict: int = 1024,
think: bool = False) -> tuple[str, str]:
"""POST /api/chat; liefert (content, done_reason). done_reason ==
'length' bedeutet: Antwort wurde bei num_predict abgeschnitten —
Zitationen koennen dann unvollstaendig sein (Think-Ghost-Ursache
q-024, D13-Follow-up)."""
body: dict[str, Any] = {
"model": model,
"messages": messages,
"stream": False,
"options": {
"temperature": temperature,
"num_ctx": num_ctx,
"num_predict": num_predict,
},
}
if think is not None:
body["think"] = bool(think)
r = self._client.post(
f"{self.base_url}/api/chat", json=body, timeout=self.chat_timeout
)
if r.status_code in (400, 404) and "think" in body:
# Ältere Ollama-Versionen kennen das think-Flag nicht -> Retry ohne.
body.pop("think")
r = self._client.post(
f"{self.base_url}/api/chat", json=body, timeout=self.chat_timeout
)
r.raise_for_status()
data = r.json()
msg = data.get("message") or {}
content = msg.get("content") or ""
if not content.strip():
raise OllamaError(f"empty response from {model} (keys: {list(data.keys())})")
return content, str(data.get("done_reason") or "stop")
def chat(self, model: str, messages: list[dict],
temperature: float = 0.1, num_ctx: int = 16384,
num_predict: int = 1024, think: bool = False) -> str:
"""POST /api/chat, stream=False; `think`-Flag mit 404/400-Fallback."""
content, _ = self.chat_full(
model, messages, temperature=temperature, num_ctx=num_ctx,
num_predict=num_predict, think=think,
)
return content