mirror of
http://100.103.83.12:3003/fegger/pv-agent.git
synced 2026-09-17 21:36:24 +00:00
feat(agent): log answer comments
This commit is contained in:
+3
-2
@@ -77,8 +77,9 @@ 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. Fehlt `data/index.db`, baut der Container ihn vor dem API-Start
|
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
|
automatisch mit vollständigen Embeddings auf. Fragen, Antworten, Bewertungen
|
||||||
werden im Compose-Profil standardmäßig nach `data/audit.db` und als
|
und unabhängige Antwortkommentare werden im Compose-Profil standardmäßig nach
|
||||||
|
`data/audit.db` und als
|
||||||
strukturierte `AUDIT`-Zeilen in die Containerlogs geschrieben.
|
strukturierte `AUDIT`-Zeilen in die Containerlogs geschrieben.
|
||||||
|
|
||||||
## Konfiguration (Umgebungsvariablen)
|
## Konfiguration (Umgebungsvariablen)
|
||||||
|
|||||||
@@ -124,6 +124,18 @@ class RatingResponse(StrictModel):
|
|||||||
accepted: Literal[True] = True
|
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:
|
class AppState:
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.cfg: Config | None = 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)
|
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:
|
def _reindex(request: Request) -> dict:
|
||||||
rag: AppState = request.app.state.rag
|
rag: AppState = request.app.state.rag
|
||||||
cfg = rag.ensure()
|
cfg = rag.ensure()
|
||||||
|
|||||||
+65
-2
@@ -35,10 +35,19 @@ CREATE TABLE IF NOT EXISTS ratings (
|
|||||||
feedback TEXT,
|
feedback TEXT,
|
||||||
FOREIGN KEY (request_id) REFERENCES interactions(request_id) ON DELETE CASCADE
|
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
|
CREATE INDEX IF NOT EXISTS interactions_created_at_idx
|
||||||
ON interactions(created_at DESC);
|
ON interactions(created_at DESC);
|
||||||
CREATE INDEX IF NOT EXISTS ratings_updated_at_idx
|
CREATE INDEX IF NOT EXISTS ratings_updated_at_idx
|
||||||
ON ratings(updated_at DESC);
|
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:
|
with self._lock:
|
||||||
self._con.execute(
|
self._con.execute(
|
||||||
"INSERT OR REPLACE INTO interactions("
|
"INSERT INTO interactions("
|
||||||
"request_id, created_at, question, answer, status, verified, refused, "
|
"request_id, created_at, question, answer, status, verified, refused, "
|
||||||
"citations_json, sources_json, conflicts_json, planned_queries_json, "
|
"citations_json, sources_json, conflicts_json, planned_queries_json, "
|
||||||
"model, latency_ms, n_context, regenerations"
|
"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,
|
values,
|
||||||
)
|
)
|
||||||
self._con.commit()
|
self._con.commit()
|
||||||
@@ -155,6 +173,40 @@ class AuditStore:
|
|||||||
flush=True,
|
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]:
|
def recent(self, limit: int = 20) -> list[dict]:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
rows = self._con.execute(
|
rows = self._con.execute(
|
||||||
@@ -163,6 +215,16 @@ class AuditStore:
|
|||||||
"ORDER BY i.created_at DESC LIMIT ?",
|
"ORDER BY i.created_at DESC LIMIT ?",
|
||||||
(limit,),
|
(limit,),
|
||||||
).fetchall()
|
).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 = []
|
out = []
|
||||||
for row in rows:
|
for row in rows:
|
||||||
item = dict(row)
|
item = dict(row)
|
||||||
@@ -175,5 +237,6 @@ class AuditStore:
|
|||||||
item[field.removesuffix("_json")] = json.loads(item.pop(field))
|
item[field.removesuffix("_json")] = json.loads(item.pop(field))
|
||||||
item["verified"] = bool(item["verified"])
|
item["verified"] = bool(item["verified"])
|
||||||
item["refused"] = bool(item["refused"])
|
item["refused"] = bool(item["refused"])
|
||||||
|
item["comments"] = comments_by_request[item["request_id"]]
|
||||||
out.append(item)
|
out.append(item)
|
||||||
return out
|
return out
|
||||||
|
|||||||
+19
-1
@@ -24,6 +24,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 |
|
| `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 |
|
| `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 |
|
||||||
|
|
||||||
@@ -172,10 +173,27 @@ aktualisiert sie. Unbekannte Request-IDs liefern `404`, deaktiviertes Audit
|
|||||||
`503`. Das Frontend blendet die Bewertungsfunktion nur bei
|
`503`. Das Frontend blendet die Bewertungsfunktion nur bei
|
||||||
`ratings_enabled=true` ein.
|
`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
|
## Audit-Protokoll
|
||||||
|
|
||||||
Bei `PV_AUDIT_ENABLED=true` werden Interaktionen und Bewertungen in der über
|
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
|
`PV_AUDIT_STDOUT=true` werden strukturierte JSON-Ereignisse zusätzlich mit dem
|
||||||
Präfix `AUDIT ` nach stdout geschrieben. `PV_AUDIT_LOG_CONTENT=false` entfernt
|
Präfix `AUDIT ` nach stdout geschrieben. `PV_AUDIT_LOG_CONTENT=false` entfernt
|
||||||
Freitext einschließlich Frage, Antwort, Quellenbeschreibungen, Konflikten,
|
Freitext einschließlich Frage, Antwort, Quellenbeschreibungen, Konflikten,
|
||||||
|
|||||||
+3
-2
@@ -128,7 +128,8 @@ Compose aktiviert standardmäßig ein detailliertes Audit:
|
|||||||
- `data/audit.db`: persistente SQLite-Datenbank mit Request-ID, Frage, Antwort,
|
- `data/audit.db`: persistente SQLite-Datenbank mit Request-ID, Frage, Antwort,
|
||||||
Status, Zitaten, Quellen, Konflikten, Suchplan, Modell, Laufzeit und
|
Status, Zitaten, Quellen, Konflikten, Suchplan, Modell, Laufzeit und
|
||||||
Regenerierungen;
|
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
|
- `docker compose logs --follow pv-agent`: dieselben Ereignisse als mit
|
||||||
`AUDIT ` präfixierte JSON-Zeilen für die Betriebsdiagnose; Docker rotiert
|
`AUDIT ` präfixierte JSON-Zeilen für die Betriebsdiagnose; Docker rotiert
|
||||||
diese Logs bei 50 MB und behält fünf Dateien.
|
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
|
`data/audit.db`, Backups und Docker-Logs ist deshalb auf Administratoren zu
|
||||||
beschränken. Mit `PV_AUDIT_LOG_CONTENT=false` bleiben nur technische Metadaten
|
beschränken. Mit `PV_AUDIT_LOG_CONTENT=false` bleiben nur technische Metadaten
|
||||||
und KB-IDs erhalten; Frage, Antwort, Quellenbeschreibungen, Konflikttext,
|
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.
|
geschrieben. API-Keys und Authorization-Header werden nie protokolliert.
|
||||||
|
|
||||||
## Sicherheitsprofil
|
## Sicherheitsprofil
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ def test_frontend_assets_use_v1_api_and_unknown_assets_are_hidden():
|
|||||||
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 'fetch("/v1/ratings"' in script.text
|
||||||
|
assert 'fetch("/v1/comments"' 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
|
||||||
|
|
||||||
@@ -202,9 +203,23 @@ def test_v1_rating_is_persisted_for_logged_answer(monkeypatch, tmp_path):
|
|||||||
)
|
)
|
||||||
assert rating.status_code == 200
|
assert rating.status_code == 200
|
||||||
assert rating.json()["accepted"] is True
|
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]
|
row = app.state.rag.audit.recent()[0]
|
||||||
assert row["rating"] == "up"
|
assert row["rating"] == "up"
|
||||||
assert row["feedback"] == "Hilfreich und nachvollziehbar"
|
assert row["feedback"] == "Hilfreich und nachvollziehbar"
|
||||||
|
assert row["comments"][0]["comment"] == (
|
||||||
|
"Bitte diesen Fall ins Goldset aufnehmen."
|
||||||
|
)
|
||||||
|
|
||||||
missing = client.post(
|
missing = client.post(
|
||||||
"/v1/ratings",
|
"/v1/ratings",
|
||||||
@@ -212,6 +227,18 @@ def test_v1_rating_is_persisted_for_logged_answer(monkeypatch, tmp_path):
|
|||||||
headers={"Authorization": "Bearer service-secret"},
|
headers={"Authorization": "Bearer service-secret"},
|
||||||
)
|
)
|
||||||
assert missing.status_code == 404
|
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():
|
def test_health_does_not_expose_internal_ollama_url():
|
||||||
|
|||||||
@@ -40,6 +40,15 @@ def test_audit_persists_interaction_and_rating(tmp_path):
|
|||||||
try:
|
try:
|
||||||
store.record_interaction(interaction_payload())
|
store.record_interaction(interaction_payload())
|
||||||
store.record_rating("test-request-1", "down", "Quelle war nicht passend")
|
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()
|
rows = store.recent()
|
||||||
finally:
|
finally:
|
||||||
store.close()
|
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]["citations"] == ["lb-min-01"]
|
||||||
assert rows[0]["rating"] == "down"
|
assert rows[0]["rating"] == "down"
|
||||||
assert rows[0]["feedback"] == "Quelle war nicht passend"
|
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):
|
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:
|
try:
|
||||||
store.record_interaction(interaction_payload())
|
store.record_interaction(interaction_payload())
|
||||||
store.record_rating("test-request-1", "up", "soll nicht gespeichert werden")
|
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]
|
row = store.recent()[0]
|
||||||
finally:
|
finally:
|
||||||
store.close()
|
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["question"] is None
|
||||||
assert row["answer"] is None
|
assert row["answer"] is None
|
||||||
assert row["feedback"] is None
|
assert row["feedback"] is None
|
||||||
|
assert row["comments"][0]["comment"] is None
|
||||||
assert "Was gilt?" not in output
|
assert "Was gilt?" not in output
|
||||||
assert "soll nicht gespeichert werden" 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_interaction"' in output
|
||||||
assert '"event": "agent_rating"' in output
|
assert '"event": "agent_rating"' in output
|
||||||
|
assert '"event": "agent_comment"' in output
|
||||||
|
|
||||||
|
|
||||||
def test_rating_requires_existing_interaction(tmp_path):
|
def test_rating_requires_existing_interaction(tmp_path):
|
||||||
@@ -82,6 +100,8 @@ def test_rating_requires_existing_interaction(tmp_path):
|
|||||||
try:
|
try:
|
||||||
with pytest.raises(KeyError):
|
with pytest.raises(KeyError):
|
||||||
store.record_rating("missing", "up", None)
|
store.record_rating("missing", "up", None)
|
||||||
|
with pytest.raises(KeyError):
|
||||||
|
store.record_comment("missing", "Kommentar")
|
||||||
finally:
|
finally:
|
||||||
store.close()
|
store.close()
|
||||||
|
|
||||||
|
|||||||
+57
-42
@@ -139,60 +139,75 @@ function followUpPanel(question) {
|
|||||||
|
|
||||||
function ratingPanel(answerRequestId) {
|
function ratingPanel(answerRequestId) {
|
||||||
const panel = node("div", "rating-panel");
|
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 actions = node("div", "rating-actions");
|
||||||
const up = node("button", "secondary rating-choice", "👍 Ja");
|
const up = node("button", "secondary rating-choice", "👍 Hilfreich");
|
||||||
const down = node("button", "secondary rating-choice", "👎 Nein");
|
const down = node("button", "secondary rating-choice", "👎 Nicht hilfreich");
|
||||||
|
const ratingStatus = node("span", "rating-status");
|
||||||
up.type = "button";
|
up.type = "button";
|
||||||
down.type = "button";
|
down.type = "button";
|
||||||
actions.append(up, down);
|
actions.append(up, down, ratingStatus);
|
||||||
panel.append(actions);
|
panel.append(actions);
|
||||||
|
|
||||||
const form = node("div", "rating-form");
|
const sendRating = async (value, selected, other) => {
|
||||||
form.hidden = true;
|
up.disabled = true;
|
||||||
const feedback = node("textarea", "rating-feedback");
|
down.disabled = true;
|
||||||
feedback.rows = 2;
|
ratingStatus.textContent = "Wird gespeichert …";
|
||||||
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 {
|
try {
|
||||||
const response = await fetch("/v1/ratings", {
|
const response = await fetch("/v1/ratings", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: apiHeaders(),
|
headers: apiHeaders(),
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({ request_id: answerRequestId, rating: value }),
|
||||||
request_id: answerRequestId,
|
|
||||||
rating: selection,
|
|
||||||
feedback: feedback.value.trim() || null,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
if (!response.ok) throw new Error(await errorDetail(response));
|
if (!response.ok) throw new Error(await errorDetail(response));
|
||||||
status.textContent = "Danke, Bewertung gespeichert.";
|
selected.classList.add("is-selected");
|
||||||
up.disabled = true;
|
other.classList.remove("is-selected");
|
||||||
down.disabled = true;
|
ratingStatus.textContent = "Bewertung gespeichert.";
|
||||||
feedback.disabled = true;
|
|
||||||
send.hidden = true;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
status.textContent = error.message || "Bewertung konnte nicht gespeichert werden.";
|
ratingStatus.textContent = error.message || "Bewertung konnte nicht gespeichert werden.";
|
||||||
send.disabled = false;
|
} 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;
|
return panel;
|
||||||
|
|||||||
+1
-1
@@ -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. 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>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
+6
-5
@@ -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; }
|
.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 { 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-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-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-status, .comment-status { color: var(--muted); font-size: 0.72rem; }
|
||||||
.rating-feedback { width: 100%; min-height: 62px; resize: vertical; padding: 9px; border: 1px solid var(--line); border-radius: 8px; color: var(--ink); background: white; }
|
.comment-form { display: grid; grid-template-columns: 1fr auto; gap: 8px; align-items: end; padding-top: 10px; border-top: 1px solid var(--line); }
|
||||||
.rating-status { grid-column: 1 / -1; color: var(--muted); font-size: 0.72rem; }
|
.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); }
|
.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; }
|
||||||
@@ -169,7 +170,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; }
|
.comment-form { grid-template-columns: 1fr; }
|
||||||
.footnote { margin-inline: 14px; }
|
.footnote { margin-inline: 14px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user