feat(agent): log answer comments

This commit is contained in:
2026-09-17 00:02:31 +02:00
parent b42c326d7f
commit af8bfcaa6a
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 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)
+40
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+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 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():
+20
View File
@@ -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
View File
@@ -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
View File
@@ -59,7 +59,7 @@
<div class="privacy-note"> <div class="privacy-note">
<strong>Datenschutzgrenze</strong> <strong>Datenschutzgrenze</strong>
<span>Keine Namen, Personalnummern oder Lohndaten eingeben. 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
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; } .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; }
} }