From c005b9b3062dbe9e0e69580c4d9b1632446d8dd5 Mon Sep 17 00:00:00 2001 From: Florian Egger Date: Wed, 16 Sep 2026 22:40:06 +0200 Subject: [PATCH] feat(agent): add index bootstrap and answer feedback --- .agents/MEMORY.md | 29 +++++-- .env.example | 11 ++- Dockerfile | 2 +- agent/README.md | 16 +++- agent/api.py | 67 ++++++++++++++- agent/audit.py | 179 +++++++++++++++++++++++++++++++++++++++ agent/bootstrap.py | 84 ++++++++++++++++++ agent/cli.py | 20 +++++ agent/config.py | 14 +++ compose.yaml | 18 +++- docs/API.md | 31 +++++++ docs/DOCKER.md | 89 +++++++++++++++---- planung.md | 11 ++- tests/test_api.py | 42 +++++++++ tests/test_audit.py | 101 ++++++++++++++++++++++ tests/test_deployment.py | 10 ++- web/app.js | 64 ++++++++++++++ web/index.html | 2 +- web/styles.css | 8 ++ 19 files changed, 757 insertions(+), 41 deletions(-) create mode 100644 agent/audit.py create mode 100644 agent/bootstrap.py create mode 100644 tests/test_audit.py diff --git a/.agents/MEMORY.md b/.agents/MEMORY.md index d5737e9..8b22a8b 100644 --- a/.agents/MEMORY.md +++ b/.agents/MEMORY.md @@ -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). diff --git a/.env.example b/.env.example index 6339d90..4ffba63 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/Dockerfile b/Dockerfile index d30729a..f7d3148 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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"] diff --git a/agent/README.md b/agent/README.md index 4a6d9c3..e44320e 100644 --- a/agent/README.md +++ b/agent/README.md @@ -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) diff --git a/agent/api.py b/agent/api.py index faf3c9d..6b13299 100644 --- a/agent/api.py +++ b/agent/api.py @@ -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() diff --git a/agent/audit.py b/agent/audit.py new file mode 100644 index 0000000..9999367 --- /dev/null +++ b/agent/audit.py @@ -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 diff --git a/agent/bootstrap.py b/agent/bootstrap.py new file mode 100644 index 0000000..739aa9e --- /dev/null +++ b/agent/bootstrap.py @@ -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()) diff --git a/agent/cli.py b/agent/cli.py index 49bf3a6..d238e4a 100644 --- a/agent/cli.py +++ b/agent/cli.py @@ -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) diff --git a/agent/config.py b/agent/config.py index 0b1357c..cddbd2d 100644 --- a/agent/config.py +++ b/agent/config.py @@ -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 + ), ) \ No newline at end of file diff --git a/compose.yaml b/compose.yaml index 76b0791..a051b8a 100644 --- a/compose.yaml +++ b/compose.yaml @@ -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 diff --git a/docs/API.md b/docs/API.md index 9a8a2c5..83423d1 100644 --- a/docs/API.md +++ b/docs/API.md @@ -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: diff --git a/docs/DOCKER.md b/docs/DOCKER.md index f687fe1..c81c755 100644 --- a/docs/DOCKER.md +++ b/docs/DOCKER.md @@ -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. diff --git a/planung.md b/planung.md index 6538389..c16ab6f 100644 --- a/planung.md +++ b/planung.md @@ -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`. diff --git a/tests/test_api.py b/tests/test_api.py index f8edf0d..7baa922 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -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") diff --git a/tests/test_audit.py b/tests/test_audit.py new file mode 100644 index 0000000..0f759cc --- /dev/null +++ b/tests/test_audit.py @@ -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" diff --git a/tests/test_deployment.py b/tests/test_deployment.py index 85611c5..a44cb27 100644 --- a/tests/test_deployment.py +++ b/tests/test_deployment.py @@ -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 diff --git a/web/app.js b/web/app.js index 3e34dca..a16bcc2 100644 --- a/web/app.js +++ b/web/app.js @@ -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`)); diff --git a/web/index.html b/web/index.html index 537f95b..e91a9fa 100644 --- a/web/index.html +++ b/web/index.html @@ -59,7 +59,7 @@
Datenschutzgrenze - Keine Namen, Personalnummern oder Lohndaten eingeben. Diese Testversion verarbeitet nur Fachfragen. + Keine Namen, Personalnummern oder Lohndaten eingeben. Fragen, Antworten und Bewertungen werden für die Qualitätsprüfung protokolliert.
diff --git a/web/styles.css b/web/styles.css index c2b0343..ced7639 100644 --- a/web/styles.css +++ b/web/styles.css @@ -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; } }