feat(agent): add index bootstrap and answer feedback

This commit is contained in:
2026-09-16 22:40:06 +02:00
parent fc656188cf
commit c005b9b306
19 changed files with 757 additions and 41 deletions
+23 -6
View File
@@ -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).
+9 -2
View File
@@ -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
View File
@@ -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"]
+13 -3
View File
@@ -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
View File
@@ -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()
+179
View File
@@ -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
+84
View File
@@ -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())
+20
View File
@@ -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)
+14
View File
@@ -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
View File
@@ -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
+31
View File
@@ -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
View File
@@ -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
View File
@@ -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`.
+42
View File
@@ -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")
+101
View File
@@ -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"
+7 -3
View File
@@ -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
+64
View File
@@ -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`));
+1 -1
View File
@@ -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>
+8
View File
@@ -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; }
}