Answer quality review
When a user dislikes an answer (PUT /messages/{message_id}/feedback with
"rating": "dislike"), the answer opens for review. These three routes are the admin side
of that flow. The partner admin app renders the screens; this page is the API contract.
| Method | Path | Purpose |
|---|---|---|
| GET | /quality/flagged | List the answers under review, newest complaint first. |
| GET | /quality/flagged/{message_id} | One flagged answer, with its root causes and review history. |
| POST | /quality/flagged/{message_id}/review | Set the review status and add a note. |
All three require the configured API key and a verified admin principal: a verified JWT,
or the claims the trusted edge mints. operator and user get 403. With verified
authentication off (require_auth), the routes return 503: a caller-asserted
X-User-Role grants nothing, because these views show every user's questions and notes.
This is the same gate as MCP and prompt management. Show the Quality menu to admins only.
Browser clients go through a same-origin BFF and never hold the shared API key.
Each read writes an ai_engine_audit_log row (action = read, entityType = message)
naming the admin, because these views show users' questions. Each review writes a review
row naming the admin, together with the new status, the status it replaced, the note and
the issue link. The status change and that row are saved together or not at all.
Review status
open ──▶ reviewing ──▶ resolved
│
└──▶ needs_prompt_change ──▶ resolved
A dislike sets open if the answer has no status yet, and reopens it as open after an
admin resolved it. It never resets a review in progress (reviewing,
needs_prompt_change), and a later like never changes the status. An admin may set any of
the four statuses.
needs_prompt_change requires an issue_url, because prompts change only by pull request
and deploy. There is no prompt editor; show the help text "Perubahan prompt melalui tim AI
(issue → rilis)".
GET /quality/flagged
| Query parameter | Type | Meaning |
|---|---|---|
status | open | reviewing | needs_prompt_change | resolved | Review status. |
reason | wrong_answer | wrong_citation | incomplete | outdated | offensive_or_sara | other | Feedback reason. |
rating | like | dislike | Latest rating. A user may change a dislike to a like after the review opened. |
from | ISO 8601 date-time | Feedback at or after this time (inclusive). A time with no offset is read as UTC. |
to | ISO 8601 date-time | Feedback before this time (exclusive). |
tool | string | Matches the tool that built the grounding, or any tool the agent called. |
fallback_reason | string | The recorded fallback, for example over_refusal, coverage_incomplete or no_answer. |
cursor | string | The previous page's next_cursor. A cursor the API did not issue returns 422. |
limit | 1–100, default 25 | Page size. |
Filters combine with AND. A tool or fallback_reason containing NUL (U+0000) returns
422. Paging is by keyset on (feedback_at, message_id), so rows that
arrive while a user pages are not repeated.
{
"items": [
{
"message_id": "00000000-0000-0000-0000-000000000102",
"conversation_id": "00000000-0000-0000-0000-000000000101",
"question_preview": "Apa isi Qanun Aceh Nomor 7 Tahun 2024 tentang zakat?",
"rating": "dislike",
"reason": "wrong_citation",
"review_status": "open",
"grounding_rate": 0.78,
"fallback_reason": null,
"tool_executed": "vector_search",
"feedback_at": "2026-11-03T08:00:00+00:00",
"created_at": "2026-11-03T07:59:12+00:00"
}
],
"next_cursor": "<opaque cursor>"
}
question_preview is the first 200 characters of the user question the answer replied to.
grounding_rate is the claim grounding rate (claims verified out of claims drafted, 0–1),
and is null when nothing was checkable or the answer predates the rate. Show — for
null, not 0%. tool_executed is null on answers stored before this field was recorded.
GET /quality/flagged/{message_id}
{
"message_id": "00000000-0000-0000-0000-000000000102",
"conversation_id": "00000000-0000-0000-0000-000000000101",
"question": "Apa isi Qanun Aceh Nomor 7 Tahun 2024 tentang zakat?",
"answer": "Qanun tersebut mengatur ... [1]",
"citations": [{"document_id": "…", "chunk_id": "…", "document_title": "…"}],
"grounding_rate": 0.78,
"claims_supported": 7,
"claims_total": 9,
"feedback": {
"rating": "dislike",
"reason": "wrong_citation",
"note": "Pasal yang dikutip salah",
"at": "2026-11-03T08:00:00+00:00"
},
"review_status": "reviewing",
"review_history": [
{
"actor": "11111111-1111-4111-8111-111111111111",
"action": "review",
"status": "reviewing",
"rating": null,
"reason": null,
"note": "Cek ulang pasal",
"issue_url": null,
"at": "2026-11-03T09:10:00+00:00"
}
],
"root_causes": [
{
"code": "mis_citation",
"label_id": "Sitasi tidak valid",
"label_en": "Claim cited a passage it was not shown",
"evidence": {"fabricated_label": 1}
}
],
"trace_available": true,
"created_at": "2026-11-03T07:59:12+00:00"
}
answer and citations are what the user was shown, after the Government Policy Guard.
review_history lists the answer's feedback and review audit events, oldest first.
Root causes
Root causes are read from what the answer recorded: its message metadata and its
per-answer trace (ADR-0046). Each one is a check on a stored count, code or flag, not a model
judgment, and evidence holds only those counts and codes, never answer or claim text. They
point a reviewer at a stage; they do not decide whether the answer was wrong.
code | Fires when | evidence |
|---|---|---|
retrieval_miss | The fallback was no_answer (below the no-answer floor), or the trace shows retrieval ran and returned no candidates. | fallback_reason, no_answer, or retrieval_legs + candidates |
over_refusal | The fallback was over_refusal or coverage_incomplete. | fallback_reason |
mis_citation | A claim cited a passage label it was not shown (/query/v2: an invalid_citation verdict). | fabricated_label or invalid_citation count |
unsupported_claims | The support check rejected a claim (/query/v2: an unsupported verdict). | unsupported or v2_unsupported count |
policy_guard_block | The Policy Guard blocked the answer. | action, and categories when recorded |
no_issue_found | None of the above. A human decides. | {} |
Several causes can fire together; they are listed in the order of the table. When the
trace row is missing, or was written in a format this version cannot read,
trace_available is false and root causes come from the message metadata alone.
An unknown, malformed or never-flagged message_id returns 404.
POST /quality/flagged/{message_id}/review
{
"status": "needs_prompt_change",
"note": "Instruksi sitasi perlu diperjelas",
"issue_url": "https://github.com/insourcedata/superfk-aceh/issues/1234"
}
| Field | Rule |
|---|---|
status | Required; one of the four review statuses. |
note | Optional; at most 1000 characters; no NUL (U+0000). |
issue_url | Optional http(s) URL with no NUL (U+0000); required when status is needs_prompt_change. |
Unknown fields, an invalid status, a long note, a non-http(s) link, a NUL in the note or
link, or a missing link for needs_prompt_change return 422, and nothing changes. The response is the updated
detail, the same shape as GET /quality/flagged/{message_id}.
Errors
| Status | Meaning |
|---|---|
401 | Missing or invalid API key, or a required verified principal is absent. |
403 | The verified principal is not an administrator. |
404 | The answer is unknown, malformed or not under review. |
422 | An invalid filter, cursor or review body. |
503 | Verified authentication is off, so the review surface is unavailable. |
Not in this version
- A summary dashboard endpoint (counts by status, reason, root cause). The list's filters cover the review flow; it will be added if the partner asks.
- An AI-written root-cause summary. It would need a model judge and a Bahasa spot-check (ADR-0037).