mirror of
http://100.103.83.12:3003/fegger/odoo-at-payroll.git
synced 2026-09-17 16:56:42 +00:00
feat(agent): add index bootstrap and answer feedback
This commit is contained in:
@@ -5,10 +5,10 @@ Rollender Übergabe-Log für agent-Threads. Workflow: `.agents/SKILL.md`
|
||||
|
||||
## Current focus
|
||||
|
||||
Der Docker-Compose-Stack (D21) für UI + API auf `100.103.83.12` ist lokal
|
||||
gebaut und validiert. Offen ist nur der Zielhost-Smoke-Test samt Verifikation
|
||||
des tatsächlichen Ollama-DNS-Alias im externen Netz `ollama-default`; danach
|
||||
kann der dünne Odoo-Client folgen.
|
||||
Automatischer Index-Bootstrap, detailliertes Q&A-Audit und Antwortbewertungen
|
||||
(D22) sind umgesetzt; das externe Zielnetz ist auf `ollama_default` korrigiert.
|
||||
Offen bleibt der Zielhost-Build/Smoke-Test samt Verifikation des Ollama-DNS-
|
||||
Alias; danach kann der dünne Odoo-Client folgen.
|
||||
|
||||
## Completed (2026-09-14)
|
||||
|
||||
@@ -448,7 +448,8 @@ kann der dünne Odoo-Client folgen.
|
||||
100.103.83.12` mit gesetztem `PV_API_KEY`; UI auf Port 8080. Tests 82 → 84.
|
||||
- **D21 (Docker-Compose-Deployment, 2026-09-16):** `compose.yaml` baut einen
|
||||
gemeinsamen UI-/API-Container, bindet Port 8080 nur an `100.103.83.12` und
|
||||
tritt dem externen Netz `ollama-default` bei. Ollama wird nicht dupliziert;
|
||||
trat zunächst dem externen Netz `ollama-default` bei; D22 korrigiert den
|
||||
tatsächlichen Namen auf `ollama_default`. Ollama wird nicht dupliziert;
|
||||
`OLLAMA_URL` ist konfigurierbar (Beispiel `http://ollama:11434`, Alias auf
|
||||
Zielhost noch zu verifizieren). `data/` ist schreibbar, `wissensbasis/`
|
||||
read-only; Root-FS read-only, Capabilities entfernt, no-new-privileges,
|
||||
@@ -457,6 +458,21 @@ kann der dünne Odoo-Client folgen.
|
||||
`requirements-runtime.txt`. Compose `config --quiet`, Image-Build und
|
||||
read-only Import-/Asset-Smoke-Test bestanden. Deployment: `docs/DOCKER.md`.
|
||||
Statische Deploymenttests erhöhen die Suite von 84 auf **87 Tests**.
|
||||
- **D22 (Bootstrap, Audit und Bewertungen, 2026-09-16):** Das Container-CMD
|
||||
führt vor FastAPI `agent.bootstrap` aus. Ein Index gilt nur bei vorhandenen
|
||||
Chunks und vollständiger Vektorabdeckung für `PV_EMBED_MODEL` als bereit;
|
||||
andernfalls wird er im externen Netz `ollama_default` automatisch aufgebaut.
|
||||
Embedding-Fehler stoppen den Container statt BM25-only in Produktion zu
|
||||
starten. `agent.audit` protokolliert Interaktionen und Bewertungen in
|
||||
`data/audit.db` mit konfigurierbarer 30-Tage-Aufbewahrung und optionalen
|
||||
`AUDIT`-JSON-Zeilen nach stdout. `PV_AUDIT_LOG_CONTENT=false` entfernt alle
|
||||
Freitexte inklusive indirekter Inhalte in Suchplan/Quellen/Konflikten. Die
|
||||
authentisierte API `/v1/ratings` speichert `up|down` plus optionalen Kommentar;
|
||||
das Frontend zeigt die Funktion nur bei aktiviertem Audit. Keys/Header werden
|
||||
nie geloggt. Docker-JSON-Logs rotieren bei 50 MB × 5. Letzte Einträge:
|
||||
`python -m agent.cli audit --limit 20`. Offline-Suite **93 Tests**; Compose-
|
||||
Validierung, Image-Rebuild und unprivilegierter read-only Bootstrap-Smoke-Test
|
||||
gegen den bestehenden vollständigen Index bestanden.
|
||||
- **Bake-off-Protokoll** (Skill): Modellwechsel nur über dokumentierten
|
||||
Goldset-Vergleich; Kriterium: Zitier-Präzision > Verweigerungs-
|
||||
korrektheit > Latenz.
|
||||
@@ -469,7 +485,8 @@ kann der dünne Odoo-Client folgen.
|
||||
- `docs/API.md` — v1-Vertrag, Authentisierung, Privacy-Grenze und Odoo-Aufruf.
|
||||
- `agent/api.py` — versionierte Service-Oberfläche, UI-Auslieferung und Sicherheitsgrenzen.
|
||||
- `web/` — dependency-freies Test-Frontend.
|
||||
- `compose.yaml`, `Dockerfile`, `.env.example`, `.dockerignore` — Zielhost-Stack.
|
||||
- `compose.yaml`, `Dockerfile`, `.env.example`, `.dockerignore` — Zielhost-Stack und automatischer Bootstrap.
|
||||
- `agent/bootstrap.py`, `agent/audit.py` — Indexbereitschaft, Q&A-Log und Bewertungen.
|
||||
- `docs/DOCKER.md` — Deployment, Indexaufbau und Smoke-Tests.
|
||||
- `agent/eval/goldset.yaml` — Goldset (IDs gegen kb.json verifiziert).
|
||||
- `agent/generate.py` — Grounding-Kern (Prompt, Post-Validierung).
|
||||
|
||||
@@ -7,8 +7,8 @@ PV_API_KEY=CHANGE_ME
|
||||
# Optional getrennt; leer bedeutet: PV_API_KEY auch für /v1/reindex verwenden.
|
||||
PV_ADMIN_API_KEY=
|
||||
|
||||
# DNS-Name und interner Port des Ollama-Containers im Netz ollama-default.
|
||||
# Vor dem Start auf dem Zielhost mit `docker network inspect ollama-default`
|
||||
# DNS-Name und interner Port des Ollama-Containers im Netz ollama_default.
|
||||
# Vor dem Start auf dem Zielhost mit `docker network inspect ollama_default`
|
||||
# gegen den tatsächlichen Container-/Aliasnamen prüfen.
|
||||
OLLAMA_URL=http://ollama:11434
|
||||
|
||||
@@ -16,3 +16,10 @@ PV_PORT=8080
|
||||
PV_ANSWER_MODEL=qwen3.8:27b
|
||||
PV_EMBED_MODEL=bge-m3
|
||||
PV_THINK=false
|
||||
|
||||
# Detailliertes lokales Protokoll. Fragen/Antworten koennen sensible Freitexte
|
||||
# enthalten; Zugriff auf data/audit.db und Docker-Logs entsprechend begrenzen.
|
||||
PV_AUDIT_ENABLED=true
|
||||
PV_AUDIT_LOG_CONTENT=true
|
||||
PV_AUDIT_STDOUT=true
|
||||
PV_AUDIT_RETENTION_DAYS=30
|
||||
|
||||
+1
-1
@@ -15,4 +15,4 @@ COPY web/ ./web/
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
CMD ["python", "-m", "agent.cli", "serve", "--host", "0.0.0.0"]
|
||||
CMD ["sh", "-c", "python -m agent.bootstrap && exec python -m agent.cli serve --host 0.0.0.0"]
|
||||
|
||||
@@ -62,11 +62,13 @@ oder Abrechnungsobjekte.
|
||||
## Docker Compose auf `100.103.83.12`
|
||||
|
||||
Der Stack in `compose.yaml` verbindet den Agenten mit dem bereits vorhandenen
|
||||
externen Docker-Netz `ollama-default`; Ollama wird nicht dupliziert. Setup und
|
||||
externen Docker-Netz `ollama_default`; Ollama wird nicht dupliziert. Setup und
|
||||
Smoke-Tests: `docs/DOCKER.md`.
|
||||
|
||||
```bash
|
||||
cp .env.example .env # API-Key und Ollama-DNS-Alias setzen
|
||||
cp .env.example .env # API-Key, UID/GID und Ollama-DNS-Alias setzen
|
||||
mkdir -p data
|
||||
sudo chown 1000:1000 data # an PUID:PGID aus .env anpassen
|
||||
|
||||
docker compose build
|
||||
docker compose up -d
|
||||
@@ -74,7 +76,10 @@ docker compose up -d
|
||||
|
||||
UI: `http://100.103.83.12:8080/`. `data/` wird schreibbar und
|
||||
`wissensbasis/` read-only eingebunden; weder Index noch lokale Quellkorpora
|
||||
landen im Image.
|
||||
landen im Image. Fehlt `data/index.db`, baut der Container ihn vor dem API-Start
|
||||
automatisch mit vollständigen Embeddings auf. Fragen, Antworten und Bewertungen
|
||||
werden im Compose-Profil standardmäßig nach `data/audit.db` und als
|
||||
strukturierte `AUDIT`-Zeilen in die Containerlogs geschrieben.
|
||||
|
||||
## Konfiguration (Umgebungsvariablen)
|
||||
|
||||
@@ -101,6 +106,11 @@ landen im Image.
|
||||
| `PV_PORT` | `8080` | API-Port |
|
||||
| `PV_API_KEY` | leer | Bearer-Key für `/v1/ask`; leer nur für lokale Entwicklung ohne Auth |
|
||||
| `PV_ADMIN_API_KEY` | leer | separater Bearer-Key für `/v1/reindex`; leer = `PV_API_KEY` verwenden |
|
||||
| `PV_AUDIT_ENABLED` | `false` | Interaktions- und Bewertungsprotokoll aktivieren (Compose: `true`) |
|
||||
| `PV_AUDIT_DB_PATH` | `data/audit.db` | persistente SQLite-Datei für Fragen, Antworten und Bewertungen |
|
||||
| `PV_AUDIT_LOG_CONTENT` | `true` | Freitexte speichern; `false` = nur technische Metadaten und KB-IDs |
|
||||
| `PV_AUDIT_STDOUT` | `false` | strukturierte Audit-Ereignisse zusätzlich nach stdout (Compose: `true`) |
|
||||
| `PV_AUDIT_RETENTION_DAYS` | `30` | Aufbewahrung; `0` deaktiviert automatische Löschung |
|
||||
|
||||
## Deployment auf dem Host (Ollama-Maschine)
|
||||
|
||||
|
||||
+65
-2
@@ -20,6 +20,7 @@ from fastapi.responses import FileResponse
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from .audit import AuditStore
|
||||
from .config import Config
|
||||
from .generate import CITE_RE, answer_question
|
||||
from .ingest import build_index
|
||||
@@ -97,6 +98,7 @@ class AskResponse(StrictModel):
|
||||
model: str
|
||||
latency_ms: int
|
||||
regenerations: int = 0
|
||||
ratings_enabled: bool = False
|
||||
|
||||
|
||||
class HealthResponse(StrictModel):
|
||||
@@ -106,6 +108,20 @@ class HealthResponse(StrictModel):
|
||||
index: dict
|
||||
ollama_up: bool
|
||||
authentication_enabled: bool
|
||||
ratings_enabled: bool
|
||||
|
||||
|
||||
class RatingRequest(StrictModel):
|
||||
request_id: str = Field(pattern=r"^[A-Za-z0-9._:-]{1,128}$")
|
||||
rating: Literal["up", "down"]
|
||||
feedback: str | None = Field(default=None, max_length=1000)
|
||||
|
||||
|
||||
class RatingResponse(StrictModel):
|
||||
api_version: Literal["v1"] = API_VERSION
|
||||
request_id: str
|
||||
rating: Literal["up", "down"]
|
||||
accepted: Literal[True] = True
|
||||
|
||||
|
||||
class AppState:
|
||||
@@ -113,6 +129,7 @@ class AppState:
|
||||
self.cfg: Config | None = None
|
||||
self.client: OllamaClient | None = None
|
||||
self.retriever: Retriever | None = None
|
||||
self.audit: AuditStore | None = None
|
||||
self.maintenance_lock = threading.Lock()
|
||||
self.state_lock = threading.RLock()
|
||||
|
||||
@@ -139,6 +156,14 @@ class AppState:
|
||||
self.retriever = Retriever(self.ensure())
|
||||
return self.retriever
|
||||
|
||||
def get_audit(self) -> AuditStore | None:
|
||||
with self.state_lock:
|
||||
if not self.ensure().audit_enabled:
|
||||
return None
|
||||
if self.audit is None:
|
||||
self.audit = AuditStore(self.ensure())
|
||||
return self.audit
|
||||
|
||||
def reset_retriever(self) -> None:
|
||||
with self.state_lock:
|
||||
if self.retriever is not None:
|
||||
@@ -152,6 +177,8 @@ async def lifespan(app: FastAPI):
|
||||
yield
|
||||
rag: AppState = app.state.rag
|
||||
rag.reset_retriever()
|
||||
if rag.audit:
|
||||
rag.audit.close()
|
||||
if rag.client:
|
||||
rag.client.close()
|
||||
|
||||
@@ -266,7 +293,9 @@ def _extract_clarification(answer: str, refused: bool) -> str | None:
|
||||
return question or None
|
||||
|
||||
|
||||
def _response_from_result(result: dict, request_id: str) -> AskResponse:
|
||||
def _response_from_result(
|
||||
result: dict, request_id: str, ratings_enabled: bool = False
|
||||
) -> AskResponse:
|
||||
if not result["verified"]:
|
||||
status = "uncertain"
|
||||
elif result["refused"]:
|
||||
@@ -304,6 +333,7 @@ def _response_from_result(result: dict, request_id: str) -> AskResponse:
|
||||
model=result["model"],
|
||||
latency_ms=result["latency_ms"],
|
||||
regenerations=result["regenerations"],
|
||||
ratings_enabled=ratings_enabled,
|
||||
)
|
||||
|
||||
|
||||
@@ -326,7 +356,17 @@ def _ask(req: AskRequest, request: Request) -> AskResponse:
|
||||
detail="Der Wissensdienst ist vorübergehend nicht verfügbar.",
|
||||
) from exc
|
||||
result.pop("draft", None)
|
||||
return _response_from_result(result, request_id)
|
||||
response = _response_from_result(
|
||||
result, request_id, ratings_enabled=cfg.audit_enabled
|
||||
)
|
||||
audit = rag.get_audit()
|
||||
if audit is not None:
|
||||
try:
|
||||
audit.record_interaction(response.model_dump(mode="json"))
|
||||
except Exception:
|
||||
# Die Fachantwort darf bei einem reinen Audit-Fehler nicht verloren gehen.
|
||||
logger.exception("Audit-Protokollierung fehlgeschlagen request_id=%s", request_id)
|
||||
return response
|
||||
|
||||
|
||||
@app.post("/v1/ask", response_model=AskResponse, dependencies=[Depends(require_api_access)])
|
||||
@@ -359,6 +399,7 @@ def _health(request: Request) -> HealthResponse:
|
||||
index=index,
|
||||
ollama_up=ollama_up,
|
||||
authentication_enabled=bool(cfg.api_key),
|
||||
ratings_enabled=cfg.audit_enabled,
|
||||
)
|
||||
|
||||
|
||||
@@ -372,6 +413,28 @@ def health_compat(request: Request) -> HealthResponse:
|
||||
return _health(request)
|
||||
|
||||
|
||||
@app.post(
|
||||
"/v1/ratings",
|
||||
response_model=RatingResponse,
|
||||
dependencies=[Depends(require_api_access)],
|
||||
)
|
||||
def rate_answer(req: RatingRequest, request: Request) -> RatingResponse:
|
||||
rag: AppState = request.app.state.rag
|
||||
audit = rag.get_audit()
|
||||
if audit is None:
|
||||
raise HTTPException(status_code=503, detail="Bewertungen sind nicht aktiviert.")
|
||||
try:
|
||||
audit.record_rating(req.request_id, req.rating, req.feedback)
|
||||
except KeyError as exc:
|
||||
raise HTTPException(status_code=404, detail="Antwort nicht gefunden.") from exc
|
||||
except Exception as exc:
|
||||
logger.exception("Bewertung fehlgeschlagen request_id=%s", req.request_id)
|
||||
raise HTTPException(
|
||||
status_code=503, detail="Bewertung konnte nicht gespeichert werden."
|
||||
) from exc
|
||||
return RatingResponse(request_id=req.request_id, rating=req.rating)
|
||||
|
||||
|
||||
def _reindex(request: Request) -> dict:
|
||||
rag: AppState = request.app.state.rag
|
||||
cfg = rag.ensure()
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
"""Persistente, strukturierte Interaktions- und Bewertungsprotokolle."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from .config import Config
|
||||
|
||||
SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS interactions (
|
||||
request_id TEXT PRIMARY KEY,
|
||||
created_at INTEGER NOT NULL,
|
||||
question TEXT,
|
||||
answer TEXT,
|
||||
status TEXT NOT NULL,
|
||||
verified INTEGER NOT NULL,
|
||||
refused INTEGER NOT NULL,
|
||||
citations_json TEXT NOT NULL,
|
||||
sources_json TEXT NOT NULL,
|
||||
conflicts_json TEXT NOT NULL,
|
||||
planned_queries_json TEXT NOT NULL,
|
||||
model TEXT NOT NULL,
|
||||
latency_ms INTEGER NOT NULL,
|
||||
n_context INTEGER NOT NULL,
|
||||
regenerations INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS ratings (
|
||||
request_id TEXT PRIMARY KEY,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
rating TEXT NOT NULL CHECK (rating IN ('up', 'down')),
|
||||
feedback TEXT,
|
||||
FOREIGN KEY (request_id) REFERENCES interactions(request_id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS interactions_created_at_idx
|
||||
ON interactions(created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS ratings_updated_at_idx
|
||||
ON ratings(updated_at DESC);
|
||||
"""
|
||||
|
||||
|
||||
class AuditStore:
|
||||
def __init__(self, cfg: Config):
|
||||
self.cfg = cfg
|
||||
path = Path(cfg.audit_db_path)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._lock = threading.RLock()
|
||||
self._con = sqlite3.connect(path, check_same_thread=False)
|
||||
self._con.row_factory = sqlite3.Row
|
||||
self._con.execute("PRAGMA foreign_keys = ON")
|
||||
self._con.execute("PRAGMA journal_mode = WAL")
|
||||
self._con.executescript(SCHEMA)
|
||||
self._delete_expired()
|
||||
|
||||
def close(self) -> None:
|
||||
with self._lock:
|
||||
self._con.close()
|
||||
|
||||
def _delete_expired(self) -> None:
|
||||
if self.cfg.audit_retention_days <= 0:
|
||||
return
|
||||
cutoff = int(time.time()) - self.cfg.audit_retention_days * 86_400
|
||||
with self._lock:
|
||||
self._con.execute("DELETE FROM interactions WHERE created_at < ?", (cutoff,))
|
||||
self._con.commit()
|
||||
|
||||
def record_interaction(self, payload: dict) -> None:
|
||||
now = int(time.time())
|
||||
include_content = self.cfg.audit_log_content
|
||||
question = payload.get("question") if include_content else None
|
||||
answer = payload.get("answer") if include_content else None
|
||||
sources = payload.get("sources", []) if include_content else []
|
||||
conflicts = payload.get("conflicts", []) if include_content else []
|
||||
planned_queries = payload.get("planned_queries", []) if include_content else []
|
||||
values = (
|
||||
payload["request_id"],
|
||||
now,
|
||||
question,
|
||||
answer,
|
||||
payload["status"],
|
||||
int(bool(payload["verified"])),
|
||||
int(bool(payload["refused"])),
|
||||
json.dumps(payload.get("citations", []), ensure_ascii=False),
|
||||
json.dumps(sources, ensure_ascii=False),
|
||||
json.dumps(conflicts, ensure_ascii=False),
|
||||
json.dumps(planned_queries, ensure_ascii=False),
|
||||
payload.get("model", ""),
|
||||
int(payload.get("latency_ms", 0)),
|
||||
int(payload.get("n_context", 0)),
|
||||
int(payload.get("regenerations", 0)),
|
||||
)
|
||||
with self._lock:
|
||||
self._con.execute(
|
||||
"INSERT OR REPLACE INTO interactions("
|
||||
"request_id, created_at, question, answer, status, verified, refused, "
|
||||
"citations_json, sources_json, conflicts_json, planned_queries_json, "
|
||||
"model, latency_ms, n_context, regenerations"
|
||||
") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
values,
|
||||
)
|
||||
self._con.commit()
|
||||
if self.cfg.audit_stdout:
|
||||
if include_content:
|
||||
event = {"event": "agent_interaction", "created_at": now, **payload}
|
||||
else:
|
||||
event = {
|
||||
"event": "agent_interaction",
|
||||
"created_at": now,
|
||||
"request_id": payload["request_id"],
|
||||
"status": payload["status"],
|
||||
"verified": payload["verified"],
|
||||
"refused": payload["refused"],
|
||||
"citations": payload.get("citations", []),
|
||||
"model": payload.get("model", ""),
|
||||
"latency_ms": payload.get("latency_ms", 0),
|
||||
"n_context": payload.get("n_context", 0),
|
||||
"regenerations": payload.get("regenerations", 0),
|
||||
}
|
||||
print("AUDIT " + json.dumps(event, ensure_ascii=False), flush=True)
|
||||
|
||||
def record_rating(self, request_id: str, rating: str, feedback: str | None) -> None:
|
||||
now = int(time.time())
|
||||
stored_feedback = feedback if self.cfg.audit_log_content else None
|
||||
with self._lock:
|
||||
exists = self._con.execute(
|
||||
"SELECT 1 FROM interactions WHERE request_id = ?", (request_id,)
|
||||
).fetchone()
|
||||
if exists is None:
|
||||
raise KeyError(request_id)
|
||||
self._con.execute(
|
||||
"INSERT INTO ratings(request_id, created_at, updated_at, rating, feedback) "
|
||||
"VALUES (?, ?, ?, ?, ?) "
|
||||
"ON CONFLICT(request_id) DO UPDATE SET "
|
||||
"updated_at=excluded.updated_at, rating=excluded.rating, "
|
||||
"feedback=excluded.feedback",
|
||||
(request_id, now, now, rating, stored_feedback),
|
||||
)
|
||||
self._con.commit()
|
||||
if self.cfg.audit_stdout:
|
||||
print(
|
||||
"AUDIT "
|
||||
+ json.dumps(
|
||||
{
|
||||
"event": "agent_rating",
|
||||
"created_at": now,
|
||||
"request_id": request_id,
|
||||
"rating": rating,
|
||||
"feedback": stored_feedback,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
flush=True,
|
||||
)
|
||||
|
||||
def recent(self, limit: int = 20) -> list[dict]:
|
||||
with self._lock:
|
||||
rows = self._con.execute(
|
||||
"SELECT i.*, r.rating, r.feedback, r.updated_at AS rating_updated_at "
|
||||
"FROM interactions i LEFT JOIN ratings r USING(request_id) "
|
||||
"ORDER BY i.created_at DESC LIMIT ?",
|
||||
(limit,),
|
||||
).fetchall()
|
||||
out = []
|
||||
for row in rows:
|
||||
item = dict(row)
|
||||
for field in (
|
||||
"citations_json",
|
||||
"sources_json",
|
||||
"conflicts_json",
|
||||
"planned_queries_json",
|
||||
):
|
||||
item[field.removesuffix("_json")] = json.loads(item.pop(field))
|
||||
item["verified"] = bool(item["verified"])
|
||||
item["refused"] = bool(item["refused"])
|
||||
out.append(item)
|
||||
return out
|
||||
@@ -0,0 +1,84 @@
|
||||
"""Container-Bootstrap: vollständigen Produktionsindex vor API-Start sicherstellen."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
from .config import Config
|
||||
from .ingest import build_index
|
||||
from .ollama_client import OllamaClient
|
||||
|
||||
|
||||
def index_is_ready(cfg: Config) -> bool:
|
||||
path = Path(cfg.db_path)
|
||||
if not path.is_file() or path.stat().st_size == 0:
|
||||
return False
|
||||
try:
|
||||
con = sqlite3.connect(f"file:{path}?mode=ro", uri=True)
|
||||
try:
|
||||
n_chunks = con.execute("SELECT COUNT(*) FROM chunks").fetchone()[0]
|
||||
if n_chunks <= 0:
|
||||
return False
|
||||
if cfg.embed_off:
|
||||
return True
|
||||
n_dense = con.execute(
|
||||
"SELECT COUNT(*) FROM chunks c JOIN vectors v "
|
||||
"ON v.content_hash = c.content_hash AND v.model = ?",
|
||||
(cfg.embed_model,),
|
||||
).fetchone()[0]
|
||||
return n_dense == n_chunks
|
||||
finally:
|
||||
con.close()
|
||||
except (OSError, sqlite3.Error):
|
||||
return False
|
||||
|
||||
|
||||
def ensure_index(cfg: Config) -> dict:
|
||||
if index_is_ready(cfg):
|
||||
result = {"event": "index_ready", "db_path": cfg.db_path}
|
||||
print(json.dumps(result, ensure_ascii=False), flush=True)
|
||||
return result
|
||||
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"event": "index_bootstrap_started",
|
||||
"db_path": cfg.db_path,
|
||||
"embed_model": None if cfg.embed_off else cfg.embed_model,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
flush=True,
|
||||
)
|
||||
client = None
|
||||
if not cfg.embed_off:
|
||||
client = OllamaClient(
|
||||
cfg.ollama_url,
|
||||
embed_timeout_s=cfg.embed_timeout_s,
|
||||
chat_timeout_s=cfg.chat_timeout_s,
|
||||
)
|
||||
try:
|
||||
stats = build_index(cfg, client=client)
|
||||
finally:
|
||||
if client is not None:
|
||||
client.close()
|
||||
if stats.embed_error:
|
||||
raise RuntimeError(
|
||||
"Index-Bootstrap ohne vollständige Embeddings abgebrochen: "
|
||||
+ stats.embed_error
|
||||
)
|
||||
if not index_is_ready(cfg):
|
||||
raise RuntimeError("Index-Bootstrap abgeschlossen, Index ist aber unvollständig")
|
||||
result = {"event": "index_bootstrap_completed", **stats.as_dict()}
|
||||
print(json.dumps(result, ensure_ascii=False), flush=True)
|
||||
return result
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ensure_index(Config.from_env())
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -3,6 +3,7 @@
|
||||
python -m agent.cli ingest [--no-embed] Index (neu) aufbauen
|
||||
python -m agent.cli ask "Frage?" [--top-k N] [--json]
|
||||
python -m agent.cli eval [--answers] [--limit N] [--k 8] [--json-out FILE]
|
||||
python -m agent.cli audit [--limit N]
|
||||
python -m agent.cli serve [--host 0.0.0.0]
|
||||
"""
|
||||
from __future__ import annotations
|
||||
@@ -81,6 +82,21 @@ def _cmd_eval(args: argparse.Namespace, cfg: Config) -> int:
|
||||
return run_eval(cfg, args)
|
||||
|
||||
|
||||
def _cmd_audit(args: argparse.Namespace, cfg: Config) -> int:
|
||||
from .audit import AuditStore
|
||||
|
||||
if not cfg.audit_enabled:
|
||||
print("[Fehler] Audit ist nicht aktiviert (PV_AUDIT_ENABLED=true).", file=sys.stderr)
|
||||
return 2
|
||||
store = AuditStore(cfg)
|
||||
try:
|
||||
rows = store.recent(limit=args.limit)
|
||||
finally:
|
||||
store.close()
|
||||
print(json.dumps(rows, indent=2, ensure_ascii=False))
|
||||
return 0
|
||||
|
||||
|
||||
def _is_loopback_bind(host: str) -> bool:
|
||||
if host.casefold() == "localhost":
|
||||
return True
|
||||
@@ -124,6 +140,9 @@ def main(argv: list[str] | None = None) -> int:
|
||||
p_eval.add_argument("--k", type=int, default=8, help="K für Recall@k")
|
||||
p_eval.add_argument("--json-out", default=None)
|
||||
|
||||
p_audit = sub.add_parser("audit", help="Letzte Fragen, Antworten und Bewertungen")
|
||||
p_audit.add_argument("--limit", type=int, default=20)
|
||||
|
||||
p_serve = sub.add_parser("serve", help="HTTP-API starten")
|
||||
p_serve.add_argument("--host", default="127.0.0.1")
|
||||
|
||||
@@ -132,6 +151,7 @@ def main(argv: list[str] | None = None) -> int:
|
||||
"ingest": _cmd_ingest,
|
||||
"ask": _cmd_ask,
|
||||
"eval": _cmd_eval,
|
||||
"audit": _cmd_audit,
|
||||
"serve": _cmd_serve,
|
||||
}
|
||||
return handlers[args.cmd](args, cfg)
|
||||
|
||||
@@ -92,6 +92,13 @@ class Config:
|
||||
api_key: str = "" # leer = nur fuer lokale Entwicklung ohne Auth
|
||||
admin_api_key: str = "" # leer = api_key auch fuer /reindex verwenden
|
||||
|
||||
# Audit / Feedback (Inhalte koennen personenbezogene Freitexte enthalten)
|
||||
audit_enabled: bool = False
|
||||
audit_db_path: str = "data/audit.db"
|
||||
audit_log_content: bool = True
|
||||
audit_stdout: bool = False
|
||||
audit_retention_days: int = 30
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> Config:
|
||||
d = cls()
|
||||
@@ -126,4 +133,11 @@ class Config:
|
||||
port=_env_int("PV_PORT", d.port),
|
||||
api_key=_env_str("PV_API_KEY", d.api_key),
|
||||
admin_api_key=_env_str("PV_ADMIN_API_KEY", d.admin_api_key),
|
||||
audit_enabled=_env_bool("PV_AUDIT_ENABLED", d.audit_enabled),
|
||||
audit_db_path=_env_str("PV_AUDIT_DB_PATH", d.audit_db_path),
|
||||
audit_log_content=_env_bool("PV_AUDIT_LOG_CONTENT", d.audit_log_content),
|
||||
audit_stdout=_env_bool("PV_AUDIT_STDOUT", d.audit_stdout),
|
||||
audit_retention_days=_env_int(
|
||||
"PV_AUDIT_RETENTION_DAYS", d.audit_retention_days
|
||||
),
|
||||
)
|
||||
+14
-4
@@ -30,11 +30,16 @@ services:
|
||||
PV_CONTEXT_BLOCKS: "${PV_CONTEXT_BLOCKS:-8}"
|
||||
PV_PER_QUERY_SLOTS: "${PV_PER_QUERY_SLOTS:-2}"
|
||||
PV_SURVEY_BLOCKS: "${PV_SURVEY_BLOCKS:-16}"
|
||||
PV_AUDIT_ENABLED: "${PV_AUDIT_ENABLED:-true}"
|
||||
PV_AUDIT_DB_PATH: /app/data/audit.db
|
||||
PV_AUDIT_LOG_CONTENT: "${PV_AUDIT_LOG_CONTENT:-true}"
|
||||
PV_AUDIT_STDOUT: "${PV_AUDIT_STDOUT:-true}"
|
||||
PV_AUDIT_RETENTION_DAYS: "${PV_AUDIT_RETENTION_DAYS:-30}"
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
- ./wissensbasis:/app/wissensbasis:ro
|
||||
networks:
|
||||
- ollama-default
|
||||
- ollama
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp:size=64m,mode=1777
|
||||
@@ -42,6 +47,11 @@ services:
|
||||
- no-new-privileges:true
|
||||
cap_drop:
|
||||
- ALL
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "5"
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD
|
||||
@@ -54,9 +64,9 @@ services:
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
start_period: 15m
|
||||
|
||||
networks:
|
||||
ollama-default:
|
||||
ollama:
|
||||
external: true
|
||||
name: ollama-default
|
||||
name: ollama_default
|
||||
|
||||
@@ -23,6 +23,7 @@ Aufbewahrung. Ein allgemeiner API-Key allein reicht dafür nicht aus.
|
||||
|---|---|---|---|
|
||||
| `GET` | `/` | Eingabe im UI | Test-Frontend |
|
||||
| `POST` | `/v1/ask` | Service-Key | belegte Wissensantwort |
|
||||
| `POST` | `/v1/ratings` | Service-Key | Antwort bewerten |
|
||||
| `GET` | `/v1/health` | öffentlich | Readiness ohne interne Hostdetails |
|
||||
| `POST` | `/v1/reindex` | Admin-Key | Index nach KB-Änderung neu aufbauen |
|
||||
|
||||
@@ -147,10 +148,40 @@ Response (gekürzt):
|
||||
in v1 aber leer. Der Dienst errät diese Strukturen nicht aus Freitext; ihre
|
||||
spätere Befüllung benötigt einen eigenen belegbaren Generierungsvertrag.
|
||||
- `grounding.data_scope=knowledge_base_only` ist in v1 unveränderlich.
|
||||
- `ratings_enabled` zeigt, ob diese Antwort über `/v1/ratings` bewertet werden
|
||||
kann.
|
||||
|
||||
Der Client darf `verified=false` nicht als normale Fachantwort darstellen.
|
||||
Empfohlen ist ein sichtbarer Warnzustand ohne automatische Folgeverarbeitung.
|
||||
|
||||
## `POST /v1/ratings`
|
||||
|
||||
Eine zuvor protokollierte Antwort kann über ihre `request_id` bewertet werden:
|
||||
|
||||
```json
|
||||
{
|
||||
"request_id": "odoo-request-123",
|
||||
"rating": "up",
|
||||
"feedback": "Die Quellen beantworten die Frage nachvollziehbar."
|
||||
}
|
||||
```
|
||||
|
||||
`rating` ist `up` oder `down`, `feedback` optional und auf 1.000 Zeichen
|
||||
begrenzt. Pro Antwort wird eine Bewertung gespeichert; ein weiterer Request
|
||||
aktualisiert sie. Unbekannte Request-IDs liefern `404`, deaktiviertes Audit
|
||||
`503`. Das Frontend blendet die Bewertungsfunktion nur bei
|
||||
`ratings_enabled=true` ein.
|
||||
|
||||
## Audit-Protokoll
|
||||
|
||||
Bei `PV_AUDIT_ENABLED=true` werden Interaktionen und Bewertungen in der über
|
||||
`PV_AUDIT_DB_PATH` festgelegten SQLite-Datei gespeichert. Mit
|
||||
`PV_AUDIT_STDOUT=true` werden strukturierte JSON-Ereignisse zusätzlich mit dem
|
||||
Präfix `AUDIT ` nach stdout geschrieben. `PV_AUDIT_LOG_CONTENT=false` entfernt
|
||||
Freitext einschließlich Frage, Antwort, Quellenbeschreibungen, Konflikten,
|
||||
Suchplan und Bewertungskommentar; technische Metriken und KB-IDs bleiben.
|
||||
API-Key und Authorization-Header werden nie protokolliert.
|
||||
|
||||
## Request-Korrelation
|
||||
|
||||
Ein Client kann einen technisch neutralen Header mitsenden:
|
||||
|
||||
+71
-18
@@ -2,14 +2,14 @@
|
||||
|
||||
Der Compose-Stack betreibt Test-Frontend und FastAPI-Agent gemeinsam. Er startet
|
||||
keinen zweiten Ollama-Container, sondern verbindet sich mit dem vorhandenen
|
||||
externen Docker-Netz `ollama-default`.
|
||||
externen Docker-Netz `ollama_default`.
|
||||
|
||||
## Voraussetzungen
|
||||
|
||||
Auf dem Zielhost müssen vorhanden sein:
|
||||
|
||||
- Docker Engine mit Compose-Plugin;
|
||||
- das externe Netz `ollama-default`;
|
||||
- das externe Netz `ollama_default`;
|
||||
- ein darin erreichbarer Ollama-Container;
|
||||
- die Modelle `qwen3.8:27b` und `bge-m3` in dieser Ollama-Instanz;
|
||||
- `wissensbasis/` und entweder ein vorhandenes `data/index.db` oder genügend
|
||||
@@ -18,7 +18,7 @@ Auf dem Zielhost müssen vorhanden sein:
|
||||
Das Netz und seine Container/Aliase prüfen:
|
||||
|
||||
```bash
|
||||
docker network inspect ollama-default
|
||||
docker network inspect ollama_default
|
||||
```
|
||||
|
||||
Der Compose-Beispielwert nimmt den DNS-Namen `ollama` und den internen
|
||||
@@ -47,12 +47,24 @@ Dann `.env` anpassen:
|
||||
3. `OLLAMA_URL` anhand des Netzwerk-Alias prüfen;
|
||||
4. `PUID`/`PGID` auf den Besitzer von `data/` setzen.
|
||||
|
||||
Vor dem ersten Start das gitignored Bind-Mount mit diesen IDs anlegen (Beispiel
|
||||
für `1000:1000`):
|
||||
|
||||
```bash
|
||||
mkdir -p data
|
||||
sudo chown 1000:1000 data
|
||||
```
|
||||
|
||||
Ohne diesen Schritt kann Docker ein fehlendes Verzeichnis als `root` anlegen;
|
||||
der absichtlich unprivilegierte Agent könnte dann weder `index.db` noch
|
||||
`audit.db` schreiben.
|
||||
|
||||
`.env` ist gitignored und darf nicht committed werden. Compose verwendet die
|
||||
Datei nur zur Interpolation der ausdrücklich in `compose.yaml` aufgelisteten
|
||||
Variablen; sonstige lokale Secrets werden nicht pauschal in den Container
|
||||
durchgereicht.
|
||||
|
||||
## Start mit vorhandenem Index
|
||||
## Start und automatischer Index-Bootstrap
|
||||
|
||||
```bash
|
||||
docker compose build
|
||||
@@ -61,6 +73,21 @@ docker compose ps
|
||||
docker compose logs --follow pv-agent
|
||||
```
|
||||
|
||||
Vor dem API-Start führt das Image automatisch `python -m agent.bootstrap` aus.
|
||||
Der Bootstrap prüft, ob `data/index.db` Chunks und vollständige Vektoren für das
|
||||
konfigurierte Embedding-Modell enthält. Fehlt der Index oder ist er
|
||||
unvollständig, wird er aus dem read-only eingebundenen `wissensbasis/` über
|
||||
Ollama neu erzeugt. Erst danach startet FastAPI. Schlägt die Einbettung fehl,
|
||||
beendet sich der Container bewusst mit Fehler, statt einen unvollständigen
|
||||
Produktionsindex zu verwenden.
|
||||
|
||||
Der Index wird absichtlich beim **ersten Containerstart**, nicht in einem
|
||||
Dockerfile-`RUN` erzeugt: Nur zur Laufzeit ist das externe Netz
|
||||
`ollama_default` zuverlässig verfügbar, und der Index bleibt als Hostdatenstand
|
||||
in `./data`, statt veraltet im Image zu liegen. Beim ersten Lauf kann der Start
|
||||
mehrere Minuten dauern; der Healthcheck hat dafür eine Startfrist von 15
|
||||
Minuten.
|
||||
|
||||
Aufruf im Tailscale-Netz:
|
||||
|
||||
```text
|
||||
@@ -69,25 +96,16 @@ http://100.103.83.12:8080/
|
||||
|
||||
Im Frontend denselben Wert wie `PV_API_KEY` als Service-Key eingeben.
|
||||
|
||||
## Initialen Index im Container bauen
|
||||
|
||||
Falls `data/index.db` auf dem Zielhost noch fehlt:
|
||||
|
||||
```bash
|
||||
mkdir -p data
|
||||
docker compose run --rm pv-agent python -m agent.cli ingest
|
||||
```
|
||||
|
||||
Der Lauf verwendet `bge-m3` über `OLLAMA_URL` und schreibt den Index in das
|
||||
Bind-Mount `./data`. Danach den Dienst starten:
|
||||
Der Prozess läuft als `PUID:PGID`. `data/` muss für diese IDs schreibbar sein,
|
||||
damit Bootstrap, Audit-Log und `/v1/reindex` funktionieren. Ein manueller,
|
||||
erzwungener Neuaufbau bleibt möglich:
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
rm data/index.db
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Der Prozess läuft als `PUID:PGID`. `data/` muss für diese IDs schreibbar sein,
|
||||
insbesondere wenn `/v1/reindex` verwendet werden soll.
|
||||
|
||||
## Smoke-Tests
|
||||
|
||||
```bash
|
||||
@@ -103,6 +121,41 @@ curl --fail \
|
||||
der Regel, dass der Index fehlt oder Ollama unter dem konfigurierten
|
||||
Container-DNS-Namen nicht erreichbar ist.
|
||||
|
||||
## Fragen-, Antwort- und Bewertungsprotokoll
|
||||
|
||||
Compose aktiviert standardmäßig ein detailliertes Audit:
|
||||
|
||||
- `data/audit.db`: persistente SQLite-Datenbank mit Request-ID, Frage, Antwort,
|
||||
Status, Zitaten, Quellen, Konflikten, Suchplan, Modell, Laufzeit und
|
||||
Regenerierungen;
|
||||
- Tabelle `ratings`: Daumen hoch/runter plus optionaler Kommentar;
|
||||
- `docker compose logs --follow pv-agent`: dieselben Ereignisse als mit
|
||||
`AUDIT ` präfixierte JSON-Zeilen für die Betriebsdiagnose; Docker rotiert
|
||||
diese Logs bei 50 MB und behält fünf Dateien.
|
||||
|
||||
Letzte Einträge strukturiert anzeigen:
|
||||
|
||||
```bash
|
||||
docker compose exec pv-agent python -m agent.cli audit --limit 20
|
||||
```
|
||||
|
||||
Standardaufbewahrung: 30 Tage; Bereinigung erfolgt beim Öffnen des Audit-Stores.
|
||||
Konfiguration:
|
||||
|
||||
```dotenv
|
||||
PV_AUDIT_ENABLED=true
|
||||
PV_AUDIT_LOG_CONTENT=true
|
||||
PV_AUDIT_STDOUT=true
|
||||
PV_AUDIT_RETENTION_DAYS=30
|
||||
```
|
||||
|
||||
Fragen und Antworten können sensible Freitexte enthalten. Zugriff auf
|
||||
`data/audit.db`, Backups und Docker-Logs ist deshalb auf Administratoren zu
|
||||
beschränken. Mit `PV_AUDIT_LOG_CONTENT=false` bleiben nur technische Metadaten
|
||||
und KB-IDs erhalten; Frage, Antwort, Quellenbeschreibungen, Konflikttext,
|
||||
Suchplan und Bewertungskommentar werden dann nicht gespeichert oder nach stdout
|
||||
geschrieben. API-Keys und Authorization-Header werden nie protokolliert.
|
||||
|
||||
## Sicherheitsprofil
|
||||
|
||||
- Port `8080` wird nur an die Tailscale-Adresse `100.103.83.12` gebunden.
|
||||
|
||||
+10
-1
@@ -367,10 +367,19 @@ tests/ # pytest: Ingest-, Retrieval-, Grounding-Unit-Tests
|
||||
Header schützen die UI, ohne FastAPI `/docs` zu blockieren.
|
||||
- **Docker-Deployment (2026-09-16, D21):** Ein gehärteter Compose-Service
|
||||
betreibt UI und API gemeinsam auf `100.103.83.12:8080` und nutzt das externe
|
||||
Netz `ollama-default`. Ollama bleibt ein separater bestehender Container;
|
||||
Netz `ollama_default`. Ollama bleibt ein separater bestehender Container;
|
||||
sein Netzwerk-DNS-Alias wird über `OLLAMA_URL` konfiguriert. Index und KB
|
||||
bleiben Host-Bind-Mounts, Secrets und lokale Korpora außerhalb des Images.
|
||||
Der Stack verlangt einen Service-Key, läuft als konfigurierbare unprivilegierte
|
||||
UID/GID mit read-only Root-FS und besitzt einen Readiness-Healthcheck.
|
||||
Deployment und initialer Indexaufbau: `docs/DOCKER.md`.
|
||||
- **Bootstrap, Audit und Bewertungen (2026-09-16, D22):** Vor jedem API-Start
|
||||
prüft der Container, ob Chunks und Embeddings im persistenten Index vollständig
|
||||
sind, und baut einen fehlenden/unvollständigen Index automatisch über Ollama
|
||||
auf; bei Embedding-Fehlern startet die API nicht. Interaktionen werden
|
||||
strukturiert in `data/audit.db` und optional als JSON nach stdout geloggt;
|
||||
Inhaltsprotokoll und 30-Tage-Aufbewahrung sind konfigurierbar. Eine
|
||||
authentisierte `/v1/ratings`-API sowie die UI erfassen Daumen hoch/runter und
|
||||
optionales Feedback pro Request-ID. Im Metadatenmodus werden auch indirekte
|
||||
Freitexte aus Quellen, Konflikten und Suchplan entfernt.
|
||||
- Betrieb: `agent/README.md`.
|
||||
|
||||
@@ -91,6 +91,7 @@ def test_frontend_assets_use_v1_api_and_unknown_assets_are_hidden():
|
||||
assert script.status_code == 200
|
||||
assert styles.status_code == 200
|
||||
assert 'fetch("/v1/ask"' in script.text
|
||||
assert 'fetch("/v1/ratings"' in script.text
|
||||
assert "innerHTML" not in script.text
|
||||
assert missing.status_code == 404
|
||||
|
||||
@@ -172,6 +173,47 @@ def test_v1_extracts_grounded_conflict_and_clarification(monkeypatch):
|
||||
assert body["alternatives"] == []
|
||||
|
||||
|
||||
def test_v1_rating_is_persisted_for_logged_answer(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr("agent.api.answer_question", lambda *args, **kwargs: answer_result())
|
||||
with TestClient(app) as client:
|
||||
app.state.rag.cfg = Config(
|
||||
api_key="service-secret",
|
||||
audit_enabled=True,
|
||||
audit_db_path=str(tmp_path / "audit.db"),
|
||||
)
|
||||
app.state.rag.client = FakeClient()
|
||||
app.state.rag.retriever = FakeRetriever()
|
||||
headers = {
|
||||
"Authorization": "Bearer service-secret",
|
||||
"X-Request-ID": "rated-answer-1",
|
||||
}
|
||||
answer = client.post("/v1/ask", json={"question": "Was gilt?"}, headers=headers)
|
||||
assert answer.status_code == 200
|
||||
assert answer.json()["ratings_enabled"] is True
|
||||
|
||||
rating = client.post(
|
||||
"/v1/ratings",
|
||||
json={
|
||||
"request_id": "rated-answer-1",
|
||||
"rating": "up",
|
||||
"feedback": "Hilfreich und nachvollziehbar",
|
||||
},
|
||||
headers={"Authorization": "Bearer service-secret"},
|
||||
)
|
||||
assert rating.status_code == 200
|
||||
assert rating.json()["accepted"] is True
|
||||
row = app.state.rag.audit.recent()[0]
|
||||
assert row["rating"] == "up"
|
||||
assert row["feedback"] == "Hilfreich und nachvollziehbar"
|
||||
|
||||
missing = client.post(
|
||||
"/v1/ratings",
|
||||
json={"request_id": "missing-answer", "rating": "down"},
|
||||
headers={"Authorization": "Bearer service-secret"},
|
||||
)
|
||||
assert missing.status_code == 404
|
||||
|
||||
|
||||
def test_health_does_not_expose_internal_ollama_url():
|
||||
with TestClient(app) as client:
|
||||
configure_state(api_key="service-secret")
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
"""Tests für Index-Bootstrap sowie Interaktions- und Bewertungsprotokoll."""
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
|
||||
import pytest
|
||||
|
||||
from agent.audit import AuditStore
|
||||
from agent.bootstrap import ensure_index, index_is_ready
|
||||
from agent.config import Config
|
||||
|
||||
|
||||
def interaction_payload() -> dict:
|
||||
return {
|
||||
"api_version": "v1",
|
||||
"request_id": "test-request-1",
|
||||
"status": "answered",
|
||||
"question": "Was gilt?",
|
||||
"answer": "Belegte Antwort [lb-min-01].",
|
||||
"refused": False,
|
||||
"verified": True,
|
||||
"citations": ["lb-min-01"],
|
||||
"sources": [{"id": "lb-min-01", "title": "Quelle"}],
|
||||
"conflicts": [],
|
||||
"planned_queries": [{"text": "Was gilt?"}],
|
||||
"model": "test-model",
|
||||
"latency_ms": 42,
|
||||
"n_context": 1,
|
||||
"regenerations": 0,
|
||||
}
|
||||
|
||||
|
||||
def test_audit_persists_interaction_and_rating(tmp_path):
|
||||
cfg = Config(
|
||||
audit_enabled=True,
|
||||
audit_db_path=str(tmp_path / "audit.db"),
|
||||
audit_retention_days=30,
|
||||
)
|
||||
store = AuditStore(cfg)
|
||||
try:
|
||||
store.record_interaction(interaction_payload())
|
||||
store.record_rating("test-request-1", "down", "Quelle war nicht passend")
|
||||
rows = store.recent()
|
||||
finally:
|
||||
store.close()
|
||||
|
||||
assert len(rows) == 1
|
||||
assert rows[0]["question"] == "Was gilt?"
|
||||
assert rows[0]["answer"].startswith("Belegte Antwort")
|
||||
assert rows[0]["citations"] == ["lb-min-01"]
|
||||
assert rows[0]["rating"] == "down"
|
||||
assert rows[0]["feedback"] == "Quelle war nicht passend"
|
||||
|
||||
|
||||
def test_audit_can_omit_free_text_and_still_log_metadata(tmp_path, capsys):
|
||||
cfg = Config(
|
||||
audit_enabled=True,
|
||||
audit_db_path=str(tmp_path / "audit.db"),
|
||||
audit_log_content=False,
|
||||
audit_stdout=True,
|
||||
)
|
||||
store = AuditStore(cfg)
|
||||
try:
|
||||
store.record_interaction(interaction_payload())
|
||||
store.record_rating("test-request-1", "up", "soll nicht gespeichert werden")
|
||||
row = store.recent()[0]
|
||||
finally:
|
||||
store.close()
|
||||
|
||||
output = capsys.readouterr().out
|
||||
assert row["question"] is None
|
||||
assert row["answer"] is None
|
||||
assert row["feedback"] is None
|
||||
assert "Was gilt?" not in output
|
||||
assert "soll nicht gespeichert werden" not in output
|
||||
assert '"event": "agent_interaction"' in output
|
||||
assert '"event": "agent_rating"' in output
|
||||
|
||||
|
||||
def test_rating_requires_existing_interaction(tmp_path):
|
||||
store = AuditStore(Config(audit_db_path=str(tmp_path / "audit.db")))
|
||||
try:
|
||||
with pytest.raises(KeyError):
|
||||
store.record_rating("missing", "up", None)
|
||||
finally:
|
||||
store.close()
|
||||
|
||||
|
||||
def test_bootstrap_recognizes_existing_mini_index(mini_index):
|
||||
assert index_is_ready(mini_index) is True
|
||||
dense_cfg = dataclasses.replace(mini_index, embed_off=False)
|
||||
assert index_is_ready(dense_cfg) is False
|
||||
|
||||
|
||||
def test_bootstrap_builds_missing_index(mini_cfg, tmp_path):
|
||||
cfg = dataclasses.replace(mini_cfg, db_path=str(tmp_path / "new-index.db"))
|
||||
assert index_is_ready(cfg) is False
|
||||
result = ensure_index(cfg)
|
||||
assert result["event"] == "index_bootstrap_completed"
|
||||
assert index_is_ready(cfg) is True
|
||||
assert ensure_index(cfg)["event"] == "index_ready"
|
||||
@@ -13,10 +13,10 @@ def load_compose() -> dict:
|
||||
def test_compose_uses_external_ollama_network_and_tailscale_bind():
|
||||
compose = load_compose()
|
||||
service = compose["services"]["pv-agent"]
|
||||
assert service["networks"] == ["ollama-default"]
|
||||
assert compose["networks"]["ollama-default"] == {
|
||||
assert service["networks"] == ["ollama"]
|
||||
assert compose["networks"]["ollama"] == {
|
||||
"external": True,
|
||||
"name": "ollama-default",
|
||||
"name": "ollama_default",
|
||||
}
|
||||
assert "100.103.83.12:${PV_PORT:-8080}:8080" in service["ports"]
|
||||
assert service["environment"]["OLLAMA_URL"] == (
|
||||
@@ -33,6 +33,9 @@ def test_compose_requires_auth_and_limits_container_privileges():
|
||||
assert service["cap_drop"] == ["ALL"]
|
||||
assert "./data:/app/data" in service["volumes"]
|
||||
assert "./wissensbasis:/app/wissensbasis:ro" in service["volumes"]
|
||||
assert service["environment"]["PV_AUDIT_ENABLED"] == "${PV_AUDIT_ENABLED:-true}"
|
||||
assert service["environment"]["PV_AUDIT_DB_PATH"] == "/app/data/audit.db"
|
||||
assert service["logging"]["options"] == {"max-size": "50m", "max-file": "5"}
|
||||
|
||||
|
||||
def test_docker_context_excludes_secrets_and_runtime_data():
|
||||
@@ -44,4 +47,5 @@ def test_docker_context_excludes_secrets_and_runtime_data():
|
||||
assert "requirements-runtime.txt" in dockerfile
|
||||
assert "COPY agent/" in dockerfile
|
||||
assert "COPY web/" in dockerfile
|
||||
assert "python -m agent.bootstrap" in dockerfile
|
||||
assert "COPY ." not in dockerfile
|
||||
|
||||
@@ -137,6 +137,67 @@ function followUpPanel(question) {
|
||||
return box;
|
||||
}
|
||||
|
||||
function ratingPanel(answerRequestId) {
|
||||
const panel = node("div", "rating-panel");
|
||||
panel.append(node("strong", "", "War diese Antwort hilfreich?"));
|
||||
const actions = node("div", "rating-actions");
|
||||
const up = node("button", "secondary rating-choice", "👍 Ja");
|
||||
const down = node("button", "secondary rating-choice", "👎 Nein");
|
||||
up.type = "button";
|
||||
down.type = "button";
|
||||
actions.append(up, down);
|
||||
panel.append(actions);
|
||||
|
||||
const form = node("div", "rating-form");
|
||||
form.hidden = true;
|
||||
const feedback = node("textarea", "rating-feedback");
|
||||
feedback.rows = 2;
|
||||
feedback.maxLength = 1000;
|
||||
feedback.placeholder = "Optional: Was war hilfreich oder was hat gefehlt?";
|
||||
const send = node("button", "primary", "Bewertung senden");
|
||||
send.type = "button";
|
||||
const status = node("span", "rating-status");
|
||||
form.append(feedback, send, status);
|
||||
panel.append(form);
|
||||
|
||||
let selection = null;
|
||||
const select = (value, selected, other) => {
|
||||
selection = value;
|
||||
selected.classList.add("is-selected");
|
||||
other.classList.remove("is-selected");
|
||||
form.hidden = false;
|
||||
feedback.focus();
|
||||
};
|
||||
up.addEventListener("click", () => select("up", up, down));
|
||||
down.addEventListener("click", () => select("down", down, up));
|
||||
send.addEventListener("click", async () => {
|
||||
if (!selection) return;
|
||||
send.disabled = true;
|
||||
status.textContent = "Wird gespeichert …";
|
||||
try {
|
||||
const response = await fetch("/v1/ratings", {
|
||||
method: "POST",
|
||||
headers: apiHeaders(),
|
||||
body: JSON.stringify({
|
||||
request_id: answerRequestId,
|
||||
rating: selection,
|
||||
feedback: feedback.value.trim() || null,
|
||||
}),
|
||||
});
|
||||
if (!response.ok) throw new Error(await errorDetail(response));
|
||||
status.textContent = "Danke, Bewertung gespeichert.";
|
||||
up.disabled = true;
|
||||
down.disabled = true;
|
||||
feedback.disabled = true;
|
||||
send.hidden = true;
|
||||
} catch (error) {
|
||||
status.textContent = error.message || "Bewertung konnte nicht gespeichert werden.";
|
||||
send.disabled = false;
|
||||
}
|
||||
});
|
||||
return panel;
|
||||
}
|
||||
|
||||
function technicalPanel(data) {
|
||||
const details = node("details", "technical-panel");
|
||||
details.append(node("summary", "", "Technische Details"));
|
||||
@@ -182,6 +243,9 @@ function addAgentMessage(data) {
|
||||
if (Array.isArray(data.sources) && data.sources.length) {
|
||||
body.append(sourcePanel(data.sources));
|
||||
}
|
||||
if (data.ratings_enabled && data.request_id) {
|
||||
body.append(ratingPanel(data.request_id));
|
||||
}
|
||||
|
||||
const seconds = typeof data.latency_ms === "number" ? `${(data.latency_ms / 1000).toFixed(1)} s` : "–";
|
||||
body.append(node("div", "answer-meta", `${data.model || "Modell"} · ${seconds} · ${data.citations?.length || 0} Zitate`));
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
|
||||
<div class="privacy-note">
|
||||
<strong>Datenschutzgrenze</strong>
|
||||
<span>Keine Namen, Personalnummern oder Lohndaten eingeben. Diese Testversion verarbeitet nur Fachfragen.</span>
|
||||
<span>Keine Namen, Personalnummern oder Lohndaten eingeben. Fragen, Antworten und Bewertungen werden für die Qualitätsprüfung protokolliert.</span>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -122,6 +122,13 @@ details summary { color: var(--green-dark); font-size: 0.83rem; font-weight: 700
|
||||
.follow-up { margin-top: 14px; padding: 12px; border-radius: 9px; background: var(--green-soft); }
|
||||
.follow-up p { margin: 0 0 8px; font-size: 0.84rem; }
|
||||
.query-list { margin: 8px 0 0; padding-left: 20px; color: var(--muted); font-size: 0.76rem; line-height: 1.5; }
|
||||
.rating-panel { display: grid; gap: 9px; margin-top: 16px; padding: 13px; border: 1px solid var(--line); border-radius: 10px; background: #fafbf8; }
|
||||
.rating-panel > strong { font-size: 0.8rem; }
|
||||
.rating-actions { display: flex; gap: 8px; }
|
||||
.rating-choice.is-selected { border-color: var(--green); color: var(--green-dark); background: var(--green-soft); }
|
||||
.rating-form { display: grid; grid-template-columns: 1fr auto; gap: 8px; align-items: end; }
|
||||
.rating-feedback { width: 100%; min-height: 62px; resize: vertical; padding: 9px; border: 1px solid var(--line); border-radius: 8px; color: var(--ink); background: white; }
|
||||
.rating-status { grid-column: 1 / -1; color: var(--muted); font-size: 0.72rem; }
|
||||
|
||||
.loading-row { display: flex; align-items: center; gap: 10px; color: var(--muted); }
|
||||
.loader { width: 18px; height: 18px; border: 2px solid #cbd3cd; border-top-color: var(--green); border-radius: 50%; animation: spin 0.8s linear infinite; }
|
||||
@@ -162,6 +169,7 @@ details summary { color: var(--green-dark); font-size: 0.83rem; font-weight: 700
|
||||
.messages { padding: 22px 14px 6px; }
|
||||
.message-user { width: 92%; }
|
||||
.composer { margin: 18px 10px 0; }
|
||||
.rating-form { grid-template-columns: 1fr; }
|
||||
.footnote { margin-inline: 14px; }
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user