diff --git a/agent/README.md b/agent/README.md index e44320e..a9aae6a 100644 --- a/agent/README.md +++ b/agent/README.md @@ -77,8 +77,9 @@ 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. 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 +automatisch mit vollständigen Embeddings auf. Fragen, Antworten, Bewertungen +und unabhängige Antwortkommentare werden im Compose-Profil standardmäßig nach +`data/audit.db` und als strukturierte `AUDIT`-Zeilen in die Containerlogs geschrieben. ## Konfiguration (Umgebungsvariablen) diff --git a/agent/api.py b/agent/api.py index 6b13299..d2c6f26 100644 --- a/agent/api.py +++ b/agent/api.py @@ -124,6 +124,18 @@ class RatingResponse(StrictModel): accepted: Literal[True] = True +class CommentRequest(StrictModel): + request_id: str = Field(pattern=r"^[A-Za-z0-9._:-]{1,128}$") + comment: str = Field(min_length=1, max_length=2000) + + +class CommentResponse(StrictModel): + api_version: Literal["v1"] = API_VERSION + request_id: str + comment_id: int + accepted: Literal[True] = True + + class AppState: def __init__(self) -> None: self.cfg: Config | None = None @@ -435,6 +447,34 @@ def rate_answer(req: RatingRequest, request: Request) -> RatingResponse: return RatingResponse(request_id=req.request_id, rating=req.rating) +@app.post( + "/v1/comments", + response_model=CommentResponse, + dependencies=[Depends(require_api_access)], +) +def comment_answer(req: CommentRequest, request: Request) -> CommentResponse: + rag: AppState = request.app.state.rag + audit = rag.get_audit() + if audit is None: + raise HTTPException(status_code=503, detail="Kommentare sind nicht aktiviert.") + comment = req.comment.strip() + if not comment: + raise HTTPException(status_code=422, detail="Kommentar darf nicht leer sein.") + try: + comment_id = audit.record_comment(req.request_id, comment) + except KeyError as exc: + raise HTTPException(status_code=404, detail="Antwort nicht gefunden.") from exc + except Exception as exc: + logger.exception("Kommentar fehlgeschlagen request_id=%s", req.request_id) + raise HTTPException( + status_code=503, detail="Kommentar konnte nicht gespeichert werden." + ) from exc + return CommentResponse( + request_id=req.request_id, + comment_id=comment_id, + ) + + def _reindex(request: Request) -> dict: rag: AppState = request.app.state.rag cfg = rag.ensure() diff --git a/agent/audit.py b/agent/audit.py index 9999367..bf57957 100644 --- a/agent/audit.py +++ b/agent/audit.py @@ -35,10 +35,19 @@ CREATE TABLE IF NOT EXISTS ratings ( feedback TEXT, FOREIGN KEY (request_id) REFERENCES interactions(request_id) ON DELETE CASCADE ); +CREATE TABLE IF NOT EXISTS comments ( + comment_id INTEGER PRIMARY KEY AUTOINCREMENT, + request_id TEXT NOT NULL, + created_at INTEGER NOT NULL, + comment 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); +CREATE INDEX IF NOT EXISTS comments_request_created_idx + ON comments(request_id, created_at DESC); """ @@ -94,11 +103,20 @@ class AuditStore: ) with self._lock: self._con.execute( - "INSERT OR REPLACE INTO interactions(" + "INSERT 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) " + "ON CONFLICT(request_id) DO UPDATE SET " + "created_at=excluded.created_at, question=excluded.question, " + "answer=excluded.answer, status=excluded.status, " + "verified=excluded.verified, refused=excluded.refused, " + "citations_json=excluded.citations_json, sources_json=excluded.sources_json, " + "conflicts_json=excluded.conflicts_json, " + "planned_queries_json=excluded.planned_queries_json, " + "model=excluded.model, latency_ms=excluded.latency_ms, " + "n_context=excluded.n_context, regenerations=excluded.regenerations", values, ) self._con.commit() @@ -155,6 +173,40 @@ class AuditStore: flush=True, ) + def record_comment(self, request_id: str, comment: str) -> int: + now = int(time.time()) + stored_comment = comment 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) + cursor = self._con.execute( + "INSERT INTO comments(request_id, created_at, comment) VALUES (?, ?, ?)", + (request_id, now, stored_comment), + ) + self._con.commit() + if cursor.lastrowid is None: + raise RuntimeError("Kommentar wurde ohne ID gespeichert") + comment_id = int(cursor.lastrowid) + if self.cfg.audit_stdout: + print( + "AUDIT " + + json.dumps( + { + "event": "agent_comment", + "created_at": now, + "request_id": request_id, + "comment_id": comment_id, + "comment": stored_comment, + }, + ensure_ascii=False, + ), + flush=True, + ) + return comment_id + def recent(self, limit: int = 20) -> list[dict]: with self._lock: rows = self._con.execute( @@ -163,6 +215,16 @@ class AuditStore: "ORDER BY i.created_at DESC LIMIT ?", (limit,), ).fetchall() + comments_by_request: dict[str, list[dict]] = {} + for row in rows: + comment_rows = self._con.execute( + "SELECT comment_id, created_at, comment FROM comments " + "WHERE request_id = ? ORDER BY created_at, comment_id", + (row["request_id"],), + ).fetchall() + comments_by_request[row["request_id"]] = [ + dict(comment_row) for comment_row in comment_rows + ] out = [] for row in rows: item = dict(row) @@ -175,5 +237,6 @@ class AuditStore: item[field.removesuffix("_json")] = json.loads(item.pop(field)) item["verified"] = bool(item["verified"]) item["refused"] = bool(item["refused"]) + item["comments"] = comments_by_request[item["request_id"]] out.append(item) return out diff --git a/docs/API.md b/docs/API.md index 83423d1..f80f3e8 100644 --- a/docs/API.md +++ b/docs/API.md @@ -24,6 +24,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 | +| `POST` | `/v1/comments` | Service-Key | Kommentar zu einer Antwort protokollieren | | `GET` | `/v1/health` | öffentlich | Readiness ohne interne Hostdetails | | `POST` | `/v1/reindex` | Admin-Key | Index nach KB-Änderung neu aufbauen | @@ -172,10 +173,27 @@ aktualisiert sie. Unbekannte Request-IDs liefern `404`, deaktiviertes Audit `503`. Das Frontend blendet die Bewertungsfunktion nur bei `ratings_enabled=true` ein. +## `POST /v1/comments` + +Kommentare sind von der Daumenbewertung unabhängig. Pro Antwort können mehrere +Kommentare protokolliert werden: + +```json +{ + "request_id": "odoo-request-123", + "comment": "Bitte diesen Fall in das Goldset aufnehmen." +} +``` + +Der Kommentar wird getrimmt, darf nicht leer sein und ist auf 2.000 Zeichen +begrenzt. Die Antwort enthält eine fortlaufende `comment_id`. Unbekannte +Request-IDs liefern `404`, deaktiviertes Audit `503`. + ## 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_DB_PATH` festgelegten SQLite-Datei gespeichert. Mehrere Kommentare pro Antwort liegen +in der Tabelle `comments`. 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, diff --git a/docs/DOCKER.md b/docs/DOCKER.md index c81c755..c847455 100644 --- a/docs/DOCKER.md +++ b/docs/DOCKER.md @@ -128,7 +128,8 @@ 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; +- Tabelle `ratings`: Daumen hoch/runter plus optionales Bewertungsfeedback; +- Tabelle `comments`: mehrere unabhängige Kommentare pro Antwort; - `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. @@ -153,7 +154,7 @@ 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 +Suchplan, Bewertungsfeedback und Kommentare werden dann nicht gespeichert oder nach stdout geschrieben. API-Keys und Authorization-Header werden nie protokolliert. ## Sicherheitsprofil diff --git a/tests/test_api.py b/tests/test_api.py index 7baa922..8c04a50 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -92,6 +92,7 @@ def test_frontend_assets_use_v1_api_and_unknown_assets_are_hidden(): assert styles.status_code == 200 assert 'fetch("/v1/ask"' in script.text assert 'fetch("/v1/ratings"' in script.text + assert 'fetch("/v1/comments"' in script.text assert "innerHTML" not in script.text assert missing.status_code == 404 @@ -202,9 +203,23 @@ def test_v1_rating_is_persisted_for_logged_answer(monkeypatch, tmp_path): ) assert rating.status_code == 200 assert rating.json()["accepted"] is True + comment = client.post( + "/v1/comments", + json={ + "request_id": "rated-answer-1", + "comment": "Bitte diesen Fall ins Goldset aufnehmen.", + }, + headers={"Authorization": "Bearer service-secret"}, + ) + assert comment.status_code == 200 + assert comment.json()["comment_id"] > 0 + row = app.state.rag.audit.recent()[0] assert row["rating"] == "up" assert row["feedback"] == "Hilfreich und nachvollziehbar" + assert row["comments"][0]["comment"] == ( + "Bitte diesen Fall ins Goldset aufnehmen." + ) missing = client.post( "/v1/ratings", @@ -212,6 +227,18 @@ def test_v1_rating_is_persisted_for_logged_answer(monkeypatch, tmp_path): headers={"Authorization": "Bearer service-secret"}, ) assert missing.status_code == 404 + missing_comment = client.post( + "/v1/comments", + json={"request_id": "missing-answer", "comment": "Nicht vorhanden"}, + headers={"Authorization": "Bearer service-secret"}, + ) + assert missing_comment.status_code == 404 + blank_comment = client.post( + "/v1/comments", + json={"request_id": "rated-answer-1", "comment": " "}, + headers={"Authorization": "Bearer service-secret"}, + ) + assert blank_comment.status_code == 422 def test_health_does_not_expose_internal_ollama_url(): diff --git a/tests/test_audit.py b/tests/test_audit.py index 0f759cc..70b0e1b 100644 --- a/tests/test_audit.py +++ b/tests/test_audit.py @@ -40,6 +40,15 @@ def test_audit_persists_interaction_and_rating(tmp_path): try: store.record_interaction(interaction_payload()) store.record_rating("test-request-1", "down", "Quelle war nicht passend") + first_comment_id = store.record_comment( + "test-request-1", "Bitte mit einer anderen Quelle prüfen." + ) + second_comment_id = store.record_comment( + "test-request-1", "Der Stand ist für mich besonders wichtig." + ) + # Retry mit derselben Request-ID aktualisiert die Interaktion, ohne ihre + # bereits gespeicherten Bewertungen oder Kommentare zu löschen. + store.record_interaction(interaction_payload()) rows = store.recent() finally: store.close() @@ -50,6 +59,11 @@ def test_audit_persists_interaction_and_rating(tmp_path): assert rows[0]["citations"] == ["lb-min-01"] assert rows[0]["rating"] == "down" assert rows[0]["feedback"] == "Quelle war nicht passend" + assert [item["comment_id"] for item in rows[0]["comments"]] == [ + first_comment_id, + second_comment_id, + ] + assert rows[0]["comments"][0]["comment"].startswith("Bitte mit") def test_audit_can_omit_free_text_and_still_log_metadata(tmp_path, capsys): @@ -63,6 +77,7 @@ def test_audit_can_omit_free_text_and_still_log_metadata(tmp_path, capsys): try: store.record_interaction(interaction_payload()) store.record_rating("test-request-1", "up", "soll nicht gespeichert werden") + store.record_comment("test-request-1", "auch dieser Kommentar ist privat") row = store.recent()[0] finally: store.close() @@ -71,10 +86,13 @@ def test_audit_can_omit_free_text_and_still_log_metadata(tmp_path, capsys): assert row["question"] is None assert row["answer"] is None assert row["feedback"] is None + assert row["comments"][0]["comment"] is None assert "Was gilt?" not in output assert "soll nicht gespeichert werden" not in output + assert "auch dieser Kommentar ist privat" not in output assert '"event": "agent_interaction"' in output assert '"event": "agent_rating"' in output + assert '"event": "agent_comment"' in output def test_rating_requires_existing_interaction(tmp_path): @@ -82,6 +100,8 @@ def test_rating_requires_existing_interaction(tmp_path): try: with pytest.raises(KeyError): store.record_rating("missing", "up", None) + with pytest.raises(KeyError): + store.record_comment("missing", "Kommentar") finally: store.close() diff --git a/web/app.js b/web/app.js index a16bcc2..9eebb13 100644 --- a/web/app.js +++ b/web/app.js @@ -139,60 +139,75 @@ function followUpPanel(question) { function ratingPanel(answerRequestId) { const panel = node("div", "rating-panel"); - panel.append(node("strong", "", "War diese Antwort hilfreich?")); + panel.append(node("strong", "", "Antwort bewerten oder kommentieren")); + const actions = node("div", "rating-actions"); - const up = node("button", "secondary rating-choice", "👍 Ja"); - const down = node("button", "secondary rating-choice", "👎 Nein"); + const up = node("button", "secondary rating-choice", "👍 Hilfreich"); + const down = node("button", "secondary rating-choice", "👎 Nicht hilfreich"); + const ratingStatus = node("span", "rating-status"); up.type = "button"; down.type = "button"; - actions.append(up, down); + actions.append(up, down, ratingStatus); 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 …"; + const sendRating = async (value, selected, other) => { + up.disabled = true; + down.disabled = true; + ratingStatus.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, - }), + body: JSON.stringify({ request_id: answerRequestId, rating: value }), }); 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; + selected.classList.add("is-selected"); + other.classList.remove("is-selected"); + ratingStatus.textContent = "Bewertung gespeichert."; } catch (error) { - status.textContent = error.message || "Bewertung konnte nicht gespeichert werden."; - send.disabled = false; + ratingStatus.textContent = error.message || "Bewertung konnte nicht gespeichert werden."; + } finally { + up.disabled = false; + down.disabled = false; + } + }; + up.addEventListener("click", () => sendRating("up", up, down)); + down.addEventListener("click", () => sendRating("down", down, up)); + + const commentForm = node("div", "comment-form"); + const comment = node("textarea", "comment-input"); + comment.rows = 3; + comment.maxLength = 2000; + comment.placeholder = "Kommentar zu dieser Antwort …"; + comment.setAttribute("aria-label", "Kommentar zu dieser Antwort"); + const sendComment = node("button", "primary", "Kommentar speichern"); + sendComment.type = "button"; + const commentStatus = node("span", "comment-status"); + commentForm.append(comment, sendComment, commentStatus); + panel.append(commentForm); + + sendComment.addEventListener("click", async () => { + const text = comment.value.trim(); + if (!text) { + commentStatus.textContent = "Bitte zuerst einen Kommentar eingeben."; + comment.focus(); + return; + } + sendComment.disabled = true; + commentStatus.textContent = "Wird gespeichert …"; + try { + const response = await fetch("/v1/comments", { + method: "POST", + headers: apiHeaders(), + body: JSON.stringify({ request_id: answerRequestId, comment: text }), + }); + if (!response.ok) throw new Error(await errorDetail(response)); + comment.value = ""; + commentStatus.textContent = "Kommentar gespeichert. Weitere Kommentare sind möglich."; + } catch (error) { + commentStatus.textContent = error.message || "Kommentar konnte nicht gespeichert werden."; + } finally { + sendComment.disabled = false; } }); return panel; diff --git a/web/index.html b/web/index.html index e91a9fa..2bfdcbd 100644 --- a/web/index.html +++ b/web/index.html @@ -59,7 +59,7 @@
Datenschutzgrenze - Keine Namen, Personalnummern oder Lohndaten eingeben. Fragen, Antworten und Bewertungen werden für die Qualitätsprüfung protokolliert. + Keine Namen, Personalnummern oder Lohndaten eingeben. Fragen, Antworten, Bewertungen und Kommentare werden für die Qualitätsprüfung protokolliert.
diff --git a/web/styles.css b/web/styles.css index ced7639..9769027 100644 --- a/web/styles.css +++ b/web/styles.css @@ -124,11 +124,12 @@ details summary { color: var(--green-dark); font-size: 0.83rem; font-weight: 700 .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-actions { display: flex; flex-wrap: wrap; align-items: center; 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; } +.rating-status, .comment-status { color: var(--muted); font-size: 0.72rem; } +.comment-form { display: grid; grid-template-columns: 1fr auto; gap: 8px; align-items: end; padding-top: 10px; border-top: 1px solid var(--line); } +.comment-input { width: 100%; min-height: 72px; resize: vertical; padding: 9px; border: 1px solid var(--line); border-radius: 8px; color: var(--ink); background: white; } +.comment-status { grid-column: 1 / -1; } .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; } @@ -169,7 +170,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; } + .comment-form { grid-template-columns: 1fr; } .footnote { margin-inline: 14px; } }