feat(agent): log answer comments

This commit is contained in:
2026-09-17 00:02:31 +02:00
parent c005b9b306
commit d9c612e354
10 changed files with 241 additions and 55 deletions
+3 -2
View File
@@ -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)
+40
View File
@@ -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()
+65 -2
View File
@@ -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
+19 -1
View File
@@ -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,
+3 -2
View File
@@ -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
+27
View File
@@ -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():
+20
View File
@@ -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()
+57 -42
View File
@@ -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;
+1 -1
View File
@@ -59,7 +59,7 @@
<div class="privacy-note">
<strong>Datenschutzgrenze</strong>
<span>Keine Namen, Personalnummern oder Lohndaten eingeben. Fragen, Antworten und Bewertungen werden für die Qualitätsprüfung protokolliert.</span>
<span>Keine Namen, Personalnummern oder Lohndaten eingeben. Fragen, Antworten, Bewertungen und Kommentare werden für die Qualitätsprüfung protokolliert.</span>
</div>
</div>
</aside>
+6 -5
View File
@@ -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; }
}