Files
accounted/app/api/agent/feedback/route.ts
T
Jakob Wennberg 1dce9227a4 feat(assistant): make the thumbs actually report something (#1236)
* feat(assistant): make the thumbs actually report something

The thumbs up/down under an assistant answer shipped wired to nothing. They lit
up, the vote died in component state, and the code said so in a comment nobody
reading the UI could see. An affordance that looks like it reports something and
does not is worse than no affordance: it spends the user's goodwill once, and
silently.

They now post to a new /api/agent/feedback, which emits the SAME agent.feedback
event the gnubok_feedback MCP tool emits, with actorType 'user'. The product
team already queries event_log for that type, so chat votes land in the backlog
they read rather than in a second place someone has to remember to look at.
event_log takes the payload as jsonb and already treats agent.* as telemetry, so
there is no migration.

The conversation id is caller-supplied, so it gets the same ownership check
/api/agent/invoke got: without it a member could file feedback against a
colleague's thread and the backlog would carry conversations the reporter never
saw. Mutation-checked, three tests fail when the guard is removed.

The pressed state is set only after the server accepts the vote, so the button
never claims a report that never arrived, and a vote does not toggle off: it is
append-only telemetry, and offering an undo we cannot honour would be a control
that lies. Changing your mind sends the other sentiment instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(assistant): drop the unused free-text field from the feedback route

compliance-swarm flagged the `comment` field as an undocumented PII exposure:
free text embedded verbatim into the agent.feedback payload and written to
event_log under telemetry retention, with no data-classification decision, next
to a PostHog policy that treats the same class of data differently.

The finding is right, and the field was worse than it looked: no caller ever
sent one. The UI posts sentiment and a turn index. So this is dead API surface
whose only effect was to accept whatever a user might type, in an accounting
product, into a 180-day log: client names, personnummer, case details.

Removed rather than documented. A comment box is a reasonable thing to want,
but it needs its own classification and redaction decision made with the UI in
front of it, not inherited from an unused parameter.

The test now asserts the property instead of the field's absence: a caller that
posts a comment anyway must not get it stored anywhere in the payload.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 15:46:08 +02:00

109 lines
4.0 KiB
TypeScript

import { NextResponse } from 'next/server'
import { z } from 'zod'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody } from '@/lib/api/validate'
import { ensureInitialized } from '@/lib/init'
import { eventBus } from '@/lib/events/bus'
// The event bus is a module-level singleton and extension handlers are wired
// by this call. Without it at module level the emit below goes nowhere and the
// vote is silently dropped, which is exactly the failure this route exists to
// fix.
ensureInitialized()
// POST /api/agent/feedback
//
// A thumbs-up or thumbs-down on one assistant answer.
//
// The buttons have existed since the message-actions pass but were wired to
// nothing: they animated, and the vote died in component state. An affordance
// that looks like it reports something and does not is worse than no
// affordance, because it spends the user's goodwill once and silently.
//
// This emits the SAME `agent.feedback` event the gnubok_feedback MCP tool
// emits, with actorType 'user'. The product team already queries event_log for
// that type, so votes from the chat land in the existing triage flow rather
// than in a second place someone has to remember to look at. That is also why
// there is no migration here: event_log takes the payload as jsonb and already
// treats agent.* as telemetry with the longer retention.
const BodySchema = z.object({
conversation_id: z.string().uuid(),
sentiment: z.enum(['positive', 'negative']),
// Which answer, so a vote can be traced to the turn it was about.
message_index: z.number().int().min(0).optional(),
})
export const POST = withRouteContext(
'agent.feedback.create',
async (request, ctx) => {
const { supabase, companyId, user, log } = ctx
const validation = await validateBody(request, BodySchema, {
log,
operation: 'agent.feedback.create',
})
if (!validation.success) return validation.response
const body = validation.data
// The conversation id comes from the client, so it is checked the same way
// /api/agent/invoke checks it: without this, any member could attach
// feedback to another member's thread, and the triage backlog would carry
// rows whose conversation the reporter never saw.
const { data: conversation } = await supabase
.from('agent_conversations')
.select('id, user_id, company_id, intent_id')
.eq('id', body.conversation_id)
.maybeSingle()
if (
!conversation ||
conversation.user_id !== user.id ||
conversation.company_id !== companyId
) {
return NextResponse.json(
{
error: {
code: 'CONVERSATION_NOT_FOUND',
message: 'Konversationen hittades inte.',
message_en: 'Conversation not found.',
},
},
{ status: 404 },
)
}
await eventBus.emit({
type: 'agent.feedback',
payload: {
// Deliberately NOT free text. A comment box here would put whatever
// the user typed, in an accounting product, verbatim into event_log
// under telemetry retention: names, personnummer, client details. The
// vote carries only what it needs to be actionable, which is the intent
// and which answer it was about. If a comment field is ever wanted, it
// needs its own data-classification and redaction decision first, made
// with the UI in front of it rather than as an unused parameter.
context: [
`Chattbetyg på svar i ${conversation.intent_id}.`,
body.message_index === undefined ? null : `(svar #${body.message_index})`,
]
.filter(Boolean)
.join(' '),
sentiment: body.sentiment,
suggestion: null,
toolName: null,
skillSlug: null,
sessionId: conversation.id,
actorType: 'user',
actorId: user.id,
actorLabel: null,
userId: user.id,
companyId,
},
})
return NextResponse.json({ data: { recorded: true } })
},
{ requireWrite: true },
)