feat(agent): add index bootstrap and answer feedback

This commit is contained in:
2026-09-16 22:40:06 +02:00
parent 4f5107a46a
commit b42c326d7f
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 ## Current focus
Der Docker-Compose-Stack (D21) für UI + API auf `100.103.83.12` ist lokal Automatischer Index-Bootstrap, detailliertes Q&A-Audit und Antwortbewertungen
gebaut und validiert. Offen ist nur der Zielhost-Smoke-Test samt Verifikation (D22) sind umgesetzt; das externe Zielnetz ist auf `ollama_default` korrigiert.
des tatsächlichen Ollama-DNS-Alias im externen Netz `ollama-default`; danach Offen bleibt der Zielhost-Build/Smoke-Test samt Verifikation des Ollama-DNS-
kann der dünne Odoo-Client folgen. Alias; danach kann der dünne Odoo-Client folgen.
## Completed (2026-09-14) ## 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. 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 - **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 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 `OLLAMA_URL` ist konfigurierbar (Beispiel `http://ollama:11434`, Alias auf
Zielhost noch zu verifizieren). `data/` ist schreibbar, `wissensbasis/` Zielhost noch zu verifizieren). `data/` ist schreibbar, `wissensbasis/`
read-only; Root-FS read-only, Capabilities entfernt, no-new-privileges, 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 `requirements-runtime.txt`. Compose `config --quiet`, Image-Build und
read-only Import-/Asset-Smoke-Test bestanden. Deployment: `docs/DOCKER.md`. read-only Import-/Asset-Smoke-Test bestanden. Deployment: `docs/DOCKER.md`.
Statische Deploymenttests erhöhen die Suite von 84 auf **87 Tests**. 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 - **Bake-off-Protokoll** (Skill): Modellwechsel nur über dokumentierten
Goldset-Vergleich; Kriterium: Zitier-Präzision > Verweigerungs- Goldset-Vergleich; Kriterium: Zitier-Präzision > Verweigerungs-
korrektheit > Latenz. korrektheit > Latenz.
@@ -469,7 +485,8 @@ kann der dünne Odoo-Client folgen.
- `docs/API.md` — v1-Vertrag, Authentisierung, Privacy-Grenze und Odoo-Aufruf. - `docs/API.md` — v1-Vertrag, Authentisierung, Privacy-Grenze und Odoo-Aufruf.
- `agent/api.py` — versionierte Service-Oberfläche, UI-Auslieferung und Sicherheitsgrenzen. - `agent/api.py` — versionierte Service-Oberfläche, UI-Auslieferung und Sicherheitsgrenzen.
- `web/` — dependency-freies Test-Frontend. - `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. - `docs/DOCKER.md` — Deployment, Indexaufbau und Smoke-Tests.
- `agent/eval/goldset.yaml` — Goldset (IDs gegen kb.json verifiziert). - `agent/eval/goldset.yaml` — Goldset (IDs gegen kb.json verifiziert).
- `agent/generate.py` — Grounding-Kern (Prompt, Post-Validierung). - `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. # Optional getrennt; leer bedeutet: PV_API_KEY auch für /v1/reindex verwenden.
PV_ADMIN_API_KEY= PV_ADMIN_API_KEY=
# DNS-Name und interner Port des Ollama-Containers im Netz 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` # Vor dem Start auf dem Zielhost mit `docker network inspect ollama_default`
# gegen den tatsächlichen Container-/Aliasnamen prüfen. # gegen den tatsächlichen Container-/Aliasnamen prüfen.
OLLAMA_URL=http://ollama:11434 OLLAMA_URL=http://ollama:11434
@@ -16,3 +16,10 @@ PV_PORT=8080
PV_ANSWER_MODEL=qwen3.8:27b PV_ANSWER_MODEL=qwen3.8:27b
PV_EMBED_MODEL=bge-m3 PV_EMBED_MODEL=bge-m3
PV_THINK=false 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 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` ## Docker Compose auf `100.103.83.12`
Der Stack in `compose.yaml` verbindet den Agenten mit dem bereits vorhandenen 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`. Smoke-Tests: `docs/DOCKER.md`.
```bash ```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 build
docker compose up -d docker compose up -d
@@ -74,7 +76,10 @@ docker compose up -d
UI: `http://100.103.83.12:8080/`. `data/` wird schreibbar und UI: `http://100.103.83.12:8080/`. `data/` wird schreibbar und
`wissensbasis/` read-only eingebunden; weder Index noch lokale Quellkorpora `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) ## Konfiguration (Umgebungsvariablen)
@@ -101,6 +106,11 @@ landen im Image.
| `PV_PORT` | `8080` | API-Port | | `PV_PORT` | `8080` | API-Port |
| `PV_API_KEY` | leer | Bearer-Key für `/v1/ask`; leer nur für lokale Entwicklung ohne Auth | | `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_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) ## 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 fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
from .audit import AuditStore
from .config import Config from .config import Config
from .generate import CITE_RE, answer_question from .generate import CITE_RE, answer_question
from .ingest import build_index from .ingest import build_index
@@ -97,6 +98,7 @@ class AskResponse(StrictModel):
model: str model: str
latency_ms: int latency_ms: int
regenerations: int = 0 regenerations: int = 0
ratings_enabled: bool = False
class HealthResponse(StrictModel): class HealthResponse(StrictModel):
@@ -106,6 +108,20 @@ class HealthResponse(StrictModel):
index: dict index: dict
ollama_up: bool ollama_up: bool
authentication_enabled: 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: class AppState:
@@ -113,6 +129,7 @@ class AppState:
self.cfg: Config | None = None self.cfg: Config | None = None
self.client: OllamaClient | None = None self.client: OllamaClient | None = None
self.retriever: Retriever | None = None self.retriever: Retriever | None = None
self.audit: AuditStore | None = None
self.maintenance_lock = threading.Lock() self.maintenance_lock = threading.Lock()
self.state_lock = threading.RLock() self.state_lock = threading.RLock()
@@ -139,6 +156,14 @@ class AppState:
self.retriever = Retriever(self.ensure()) self.retriever = Retriever(self.ensure())
return self.retriever 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: def reset_retriever(self) -> None:
with self.state_lock: with self.state_lock:
if self.retriever is not None: if self.retriever is not None:
@@ -152,6 +177,8 @@ async def lifespan(app: FastAPI):
yield yield
rag: AppState = app.state.rag rag: AppState = app.state.rag
rag.reset_retriever() rag.reset_retriever()
if rag.audit:
rag.audit.close()
if rag.client: if rag.client:
rag.client.close() rag.client.close()
@@ -266,7 +293,9 @@ def _extract_clarification(answer: str, refused: bool) -> str | None:
return question or 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"]: if not result["verified"]:
status = "uncertain" status = "uncertain"
elif result["refused"]: elif result["refused"]:
@@ -304,6 +333,7 @@ def _response_from_result(result: dict, request_id: str) -> AskResponse:
model=result["model"], model=result["model"],
latency_ms=result["latency_ms"], latency_ms=result["latency_ms"],
regenerations=result["regenerations"], 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.", detail="Der Wissensdienst ist vorübergehend nicht verfügbar.",
) from exc ) from exc
result.pop("draft", None) 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)]) @app.post("/v1/ask", response_model=AskResponse, dependencies=[Depends(require_api_access)])
@@ -359,6 +399,7 @@ def _health(request: Request) -> HealthResponse:
index=index, index=index,
ollama_up=ollama_up, ollama_up=ollama_up,
authentication_enabled=bool(cfg.api_key), authentication_enabled=bool(cfg.api_key),
ratings_enabled=cfg.audit_enabled,
) )
@@ -372,6 +413,28 @@ def health_compat(request: Request) -> HealthResponse:
return _health(request) 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: def _reindex(request: Request) -> dict:
rag: AppState = request.app.state.rag rag: AppState = request.app.state.rag
cfg = rag.ensure() 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 ingest [--no-embed] Index (neu) aufbauen
python -m agent.cli ask "Frage?" [--top-k N] [--json] 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 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] python -m agent.cli serve [--host 0.0.0.0]
""" """
from __future__ import annotations from __future__ import annotations
@@ -81,6 +82,21 @@ def _cmd_eval(args: argparse.Namespace, cfg: Config) -> int:
return run_eval(cfg, args) 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: def _is_loopback_bind(host: str) -> bool:
if host.casefold() == "localhost": if host.casefold() == "localhost":
return True 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("--k", type=int, default=8, help="K für Recall@k")
p_eval.add_argument("--json-out", default=None) 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 = sub.add_parser("serve", help="HTTP-API starten")
p_serve.add_argument("--host", default="127.0.0.1") 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, "ingest": _cmd_ingest,
"ask": _cmd_ask, "ask": _cmd_ask,
"eval": _cmd_eval, "eval": _cmd_eval,
"audit": _cmd_audit,
"serve": _cmd_serve, "serve": _cmd_serve,
} }
return handlers[args.cmd](args, cfg) 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 api_key: str = "" # leer = nur fuer lokale Entwicklung ohne Auth
admin_api_key: str = "" # leer = api_key auch fuer /reindex verwenden 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 @classmethod
def from_env(cls) -> Config: def from_env(cls) -> Config:
d = cls() d = cls()
@@ -126,4 +133,11 @@ class Config:
port=_env_int("PV_PORT", d.port), port=_env_int("PV_PORT", d.port),
api_key=_env_str("PV_API_KEY", d.api_key), api_key=_env_str("PV_API_KEY", d.api_key),
admin_api_key=_env_str("PV_ADMIN_API_KEY", d.admin_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_CONTEXT_BLOCKS: "${PV_CONTEXT_BLOCKS:-8}"
PV_PER_QUERY_SLOTS: "${PV_PER_QUERY_SLOTS:-2}" PV_PER_QUERY_SLOTS: "${PV_PER_QUERY_SLOTS:-2}"
PV_SURVEY_BLOCKS: "${PV_SURVEY_BLOCKS:-16}" 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: volumes:
- ./data:/app/data - ./data:/app/data
- ./wissensbasis:/app/wissensbasis:ro - ./wissensbasis:/app/wissensbasis:ro
networks: networks:
- ollama-default - ollama
read_only: true read_only: true
tmpfs: tmpfs:
- /tmp:size=64m,mode=1777 - /tmp:size=64m,mode=1777
@@ -42,6 +47,11 @@ services:
- no-new-privileges:true - no-new-privileges:true
cap_drop: cap_drop:
- ALL - ALL
logging:
driver: json-file
options:
max-size: "50m"
max-file: "5"
healthcheck: healthcheck:
test: test:
- CMD - CMD
@@ -54,9 +64,9 @@ services:
interval: 30s interval: 30s
timeout: 10s timeout: 10s
retries: 5 retries: 5
start_period: 30s start_period: 15m
networks: networks:
ollama-default: ollama:
external: true 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 | | `GET` | `/` | Eingabe im UI | Test-Frontend |
| `POST` | `/v1/ask` | Service-Key | belegte Wissensantwort | | `POST` | `/v1/ask` | Service-Key | belegte Wissensantwort |
| `POST` | `/v1/ratings` | Service-Key | Antwort bewerten |
| `GET` | `/v1/health` | öffentlich | Readiness ohne interne Hostdetails | | `GET` | `/v1/health` | öffentlich | Readiness ohne interne Hostdetails |
| `POST` | `/v1/reindex` | Admin-Key | Index nach KB-Änderung neu aufbauen | | `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 in v1 aber leer. Der Dienst errät diese Strukturen nicht aus Freitext; ihre
spätere Befüllung benötigt einen eigenen belegbaren Generierungsvertrag. spätere Befüllung benötigt einen eigenen belegbaren Generierungsvertrag.
- `grounding.data_scope=knowledge_base_only` ist in v1 unveränderlich. - `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. Der Client darf `verified=false` nicht als normale Fachantwort darstellen.
Empfohlen ist ein sichtbarer Warnzustand ohne automatische Folgeverarbeitung. 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 ## Request-Korrelation
Ein Client kann einen technisch neutralen Header mitsenden: 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 Der Compose-Stack betreibt Test-Frontend und FastAPI-Agent gemeinsam. Er startet
keinen zweiten Ollama-Container, sondern verbindet sich mit dem vorhandenen keinen zweiten Ollama-Container, sondern verbindet sich mit dem vorhandenen
externen Docker-Netz `ollama-default`. externen Docker-Netz `ollama_default`.
## Voraussetzungen ## Voraussetzungen
Auf dem Zielhost müssen vorhanden sein: Auf dem Zielhost müssen vorhanden sein:
- Docker Engine mit Compose-Plugin; - Docker Engine mit Compose-Plugin;
- das externe Netz `ollama-default`; - das externe Netz `ollama_default`;
- ein darin erreichbarer Ollama-Container; - ein darin erreichbarer Ollama-Container;
- die Modelle `qwen3.8:27b` und `bge-m3` in dieser Ollama-Instanz; - die Modelle `qwen3.8:27b` und `bge-m3` in dieser Ollama-Instanz;
- `wissensbasis/` und entweder ein vorhandenes `data/index.db` oder genügend - `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: Das Netz und seine Container/Aliase prüfen:
```bash ```bash
docker network inspect ollama-default docker network inspect ollama_default
``` ```
Der Compose-Beispielwert nimmt den DNS-Namen `ollama` und den internen 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; 3. `OLLAMA_URL` anhand des Netzwerk-Alias prüfen;
4. `PUID`/`PGID` auf den Besitzer von `data/` setzen. 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 `.env` ist gitignored und darf nicht committed werden. Compose verwendet die
Datei nur zur Interpolation der ausdrücklich in `compose.yaml` aufgelisteten Datei nur zur Interpolation der ausdrücklich in `compose.yaml` aufgelisteten
Variablen; sonstige lokale Secrets werden nicht pauschal in den Container Variablen; sonstige lokale Secrets werden nicht pauschal in den Container
durchgereicht. durchgereicht.
## Start mit vorhandenem Index ## Start und automatischer Index-Bootstrap
```bash ```bash
docker compose build docker compose build
@@ -61,6 +73,21 @@ docker compose ps
docker compose logs --follow pv-agent 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: Aufruf im Tailscale-Netz:
```text ```text
@@ -69,25 +96,16 @@ http://100.103.83.12:8080/
Im Frontend denselben Wert wie `PV_API_KEY` als Service-Key eingeben. Im Frontend denselben Wert wie `PV_API_KEY` als Service-Key eingeben.
## Initialen Index im Container bauen 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,
Falls `data/index.db` auf dem Zielhost noch fehlt: erzwungener Neuaufbau bleibt möglich:
```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:
```bash ```bash
docker compose down
rm data/index.db
docker compose up -d 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 ## Smoke-Tests
```bash ```bash
@@ -103,6 +121,41 @@ curl --fail \
der Regel, dass der Index fehlt oder Ollama unter dem konfigurierten der Regel, dass der Index fehlt oder Ollama unter dem konfigurierten
Container-DNS-Namen nicht erreichbar ist. 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 ## Sicherheitsprofil
- Port `8080` wird nur an die Tailscale-Adresse `100.103.83.12` gebunden. - 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. Header schützen die UI, ohne FastAPI `/docs` zu blockieren.
- **Docker-Deployment (2026-09-16, D21):** Ein gehärteter Compose-Service - **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 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 sein Netzwerk-DNS-Alias wird über `OLLAMA_URL` konfiguriert. Index und KB
bleiben Host-Bind-Mounts, Secrets und lokale Korpora außerhalb des Images. bleiben Host-Bind-Mounts, Secrets und lokale Korpora außerhalb des Images.
Der Stack verlangt einen Service-Key, läuft als konfigurierbare unprivilegierte Der Stack verlangt einen Service-Key, läuft als konfigurierbare unprivilegierte
UID/GID mit read-only Root-FS und besitzt einen Readiness-Healthcheck. UID/GID mit read-only Root-FS und besitzt einen Readiness-Healthcheck.
Deployment und initialer Indexaufbau: `docs/DOCKER.md`. 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`. - 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 script.status_code == 200
assert styles.status_code == 200 assert styles.status_code == 200
assert 'fetch("/v1/ask"' in script.text assert 'fetch("/v1/ask"' in script.text
assert 'fetch("/v1/ratings"' in script.text
assert "innerHTML" not in script.text assert "innerHTML" not in script.text
assert missing.status_code == 404 assert missing.status_code == 404
@@ -172,6 +173,47 @@ def test_v1_extracts_grounded_conflict_and_clarification(monkeypatch):
assert body["alternatives"] == [] 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(): def test_health_does_not_expose_internal_ollama_url():
with TestClient(app) as client: with TestClient(app) as client:
configure_state(api_key="service-secret") 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(): def test_compose_uses_external_ollama_network_and_tailscale_bind():
compose = load_compose() compose = load_compose()
service = compose["services"]["pv-agent"] service = compose["services"]["pv-agent"]
assert service["networks"] == ["ollama-default"] assert service["networks"] == ["ollama"]
assert compose["networks"]["ollama-default"] == { assert compose["networks"]["ollama"] == {
"external": True, "external": True,
"name": "ollama-default", "name": "ollama_default",
} }
assert "100.103.83.12:${PV_PORT:-8080}:8080" in service["ports"] assert "100.103.83.12:${PV_PORT:-8080}:8080" in service["ports"]
assert service["environment"]["OLLAMA_URL"] == ( assert service["environment"]["OLLAMA_URL"] == (
@@ -33,6 +33,9 @@ def test_compose_requires_auth_and_limits_container_privileges():
assert service["cap_drop"] == ["ALL"] assert service["cap_drop"] == ["ALL"]
assert "./data:/app/data" in service["volumes"] assert "./data:/app/data" in service["volumes"]
assert "./wissensbasis:/app/wissensbasis:ro" 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(): 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 "requirements-runtime.txt" in dockerfile
assert "COPY agent/" in dockerfile assert "COPY agent/" in dockerfile
assert "COPY web/" in dockerfile assert "COPY web/" in dockerfile
assert "python -m agent.bootstrap" in dockerfile
assert "COPY ." not in dockerfile assert "COPY ." not in dockerfile
+64
View File
@@ -137,6 +137,67 @@ function followUpPanel(question) {
return box; 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) { function technicalPanel(data) {
const details = node("details", "technical-panel"); const details = node("details", "technical-panel");
details.append(node("summary", "", "Technische Details")); details.append(node("summary", "", "Technische Details"));
@@ -182,6 +243,9 @@ function addAgentMessage(data) {
if (Array.isArray(data.sources) && data.sources.length) { if (Array.isArray(data.sources) && data.sources.length) {
body.append(sourcePanel(data.sources)); 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` : ""; 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`)); 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"> <div class="privacy-note">
<strong>Datenschutzgrenze</strong> <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>
</div> </div>
</aside> </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 { margin-top: 14px; padding: 12px; border-radius: 9px; background: var(--green-soft); }
.follow-up p { margin: 0 0 8px; font-size: 0.84rem; } .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; } .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); } .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; } .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; } .messages { padding: 22px 14px 6px; }
.message-user { width: 92%; } .message-user { width: 92%; }
.composer { margin: 18px 10px 0; } .composer { margin: 18px 10px 0; }
.rating-form { grid-template-columns: 1fr; }
.footnote { margin-inline: 14px; } .footnote { margin-inline: 14px; }
} }