1594aa9cbd
- 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.
122 lines
4.7 KiB
Python
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 |