feat(agent): one-tap "clear the proposals I didn't approve" (BoXon feedback) (#1798)
When the assistant stages several verifikat and the user approves only some,
the rest lingered as pending_operations in Granskning until the 30-day expiry —
manual per-item cleanup. Now:
- lib/agent/pending/reject-conversation-pending.ts rejects a conversation's
still-pending proposals in one update (guarded on status='pending' so it never
stamps over a committed verifikat; company-scoped; keyed on the conversation).
- POST /api/agent/conversations/[id]/reject-pending — the chat's "Rensa förslag
som inte godkänts" button (appears when the thread has staged proposals; drops
the cards from view).
- Auto-clear on archive: archiving a thread ("I'm done") clears its leftover
proposals in the PATCH, best-effort.
Kept durable-by-default (proposals still come back on resume) — the button is
explicit user intent, not an auto-reject on every panel close, so resume still
works. 10 tests (helper + endpoint 401/404/404/happy); lint + guards + scoped
typecheck clean. UI button awaits founder visual sign-off.
Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Jakob Wennberg
Claude Opus 4.8
parent
13b69a2056
commit
1f7acbf144
@@ -1156,3 +1156,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-08-21] Confidence honesty fix, driven by a real backtest (scripts/backtest-categorize.ts, read-only: runs the real cascade on already-booked prod transactions and scores the model's pick vs the human's actual account). Backtest exposed the selector reporting 0.95 on pure category guesses → "säker" was a lie (high-conf picks only 52% accurate). Fix: confidence is now driven by DETERMINISTIC BACKING (the confidence of a candidate that independently points at the chosen account), not the model's verbalized confidence (which the backtest showed is ~always "high"). A backed pick takes the candidate confidence, reduced only when the model is unsure (BACKED_MODEL_FACTOR); an UNBACKED pick (category guess no candidate agreed with) is capped at 0.7 — below the säker band (0.8) — so a guess is never "säker". Re-backtest: säker accuracy 52% → 73%, and far fewer picks claim säker (only template-backed ones). Still not auto-book-grade (~73%, want ~95%); auto-book stays off until isotonic calibration on real approvals. Backtest caveats: exact-account match is strict (penalizes reasonable-but-different picks + companies' idiosyncratic charts), sample is established users (cold-start majority has no ground truth yet), backtest ran samples=1 (no self-consistency). Some confident-wrong cases are POISONED templates (a past mis-booking → wrong candidate the model correctly follows), a data-quality issue not fixable in the confidence math.
|
||||
[2026-08-21] Behandlingshistorik ships as a report over existing stores (journal_entries.committed_at + audit_log + rattelse log + import tables) rather than on processing_history: that table only carries Document/BankTransaction/System events in prod, while audit_log is complete, immutable and already the archive's revision/behandlingshistorik.json. Event labels stay Swedish in both locales (räkenskapsinformation, archived 7 years, same rule as SIE/grundbok); only the view chrome is translated. Bokföringsposter come from journal_entries (not audit COMMIT rows) so entries predating the audit log or from the July SIE-import window are never missing.
|
||||
[2026-08-21] Peppol access is granted per company by the operators, never self-served (peppol_access table, locked by default): every transmission is billed per document by Qvalia and every receiving identifier consumes a contracted tenant slot, so the company asks from Settings > Fakturering (request row + e-mail to support) and we enable it with scripts/peppol/access.ts, setting max_sends (null = no cap) and separately receive_enabled; the send route refuses PEPPOL_ACCESS_REQUIRED / PEPPOL_SEND_LIMIT_REACHED before touching the invoice, and registration refuses PEPPOL_RECEIVING_NOT_ENABLED. Founder call 2026-08-21 after the first open-for-all hour in prod.
|
||||
[2026-08-22] BoXon feedback fix: leftover assistant proposals no longer need manual cleanup in Granskning. lib/agent/pending/reject-conversation-pending.ts rejects a conversation's still-pending pending_operations in one update (guarded on status='pending' so it never stamps 'rejected' over a committing/committed verifikat; company-scoped; filtered by agent_metadata->>conversation_id). Surfaced two ways: POST /api/agent/conversations/[id]/reject-pending (the chat's "Rensa förslag som inte godkänts" button) + auto-clear when the conversation is ARCHIVED (best-effort in the PATCH; archive = "I'm done with this thread"). Kept durable-by-default (proposals still resume) — the button is explicit user intent, not an auto-reject on every panel close, so resume still works. Chat button drops all staged cards from view on click (committed ones are already booked; the card was only a confirmation). UI PR: needs founder visual sign-off on the button.
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { createMockRequest, createMockRouteParams, parseJsonResponse, createQueuedMockSupabase } from '@/tests/helpers'
|
||||
|
||||
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
|
||||
|
||||
const requireAuthMock = vi.fn()
|
||||
vi.mock('@/lib/auth/require-auth', () => ({ requireAuth: (...a: unknown[]) => requireAuthMock(...a) }))
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
vi.mock('@/lib/auth/require-write', () => ({ requireWritePermission: vi.fn().mockResolvedValue({ ok: true }) }))
|
||||
const rejectPending = vi.fn()
|
||||
vi.mock('@/lib/agent/pending/reject-conversation-pending', () => ({
|
||||
rejectPendingForConversation: (...a: unknown[]) => rejectPending(...a),
|
||||
}))
|
||||
|
||||
import { POST } from '../reject-pending/route'
|
||||
|
||||
const params = () => createMockRouteParams({ id: 'conv-1' })
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase: mockSupabase, error: null })
|
||||
rejectPending.mockResolvedValue(3)
|
||||
})
|
||||
|
||||
describe('POST /api/agent/conversations/[id]/reject-pending', () => {
|
||||
it('401 when unauthenticated', async () => {
|
||||
requireAuthMock.mockResolvedValue({ user: null, supabase: mockSupabase, error: NextResponse.json({ error: 'x' }, { status: 401 }) })
|
||||
const res = await POST(createMockRequest('/x', { method: 'POST' }), params())
|
||||
expect((await parseJsonResponse(res)).status).toBe(401)
|
||||
})
|
||||
|
||||
it('404 when the conversation is not the caller’s', async () => {
|
||||
enqueue({ data: null }) // conversation ownership lookup misses
|
||||
const res = await POST(createMockRequest('/x', { method: 'POST' }), params())
|
||||
expect(res.status).toBe(404)
|
||||
expect(rejectPending).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('404 when the caller is no longer a member of the company', async () => {
|
||||
enqueue({ data: { id: 'conv-1', company_id: 'company-1', user_id: 'user-1' } }) // owns it
|
||||
enqueue({ data: null }) // membership lookup misses
|
||||
const res = await POST(createMockRequest('/x', { method: 'POST' }), params())
|
||||
expect(res.status).toBe(404)
|
||||
expect(rejectPending).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('clears the conversation’s pending proposals and returns the count', async () => {
|
||||
enqueue({ data: { id: 'conv-1', company_id: 'company-1', user_id: 'user-1' } })
|
||||
enqueue({ data: { role: 'owner' } })
|
||||
const res = await POST(createMockRequest('/x', { method: 'POST' }), params())
|
||||
const { status, body } = await parseJsonResponse<{ data: { cleared: number } }>(res)
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.cleared).toBe(3)
|
||||
expect(rejectPending).toHaveBeenCalledWith(mockSupabase, 'company-1', 'conv-1')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,50 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { rejectPendingForConversation } from '@/lib/agent/pending/reject-conversation-pending'
|
||||
|
||||
// POST /api/agent/conversations/[id]/reject-pending
|
||||
//
|
||||
// Rejects every still-pending proposal the assistant staged in this
|
||||
// conversation — the chat's "clear the proposals I didn't approve" action, so
|
||||
// users don't have to reject leftovers one by one in Granskning.
|
||||
|
||||
const notFound = () =>
|
||||
NextResponse.json(
|
||||
{
|
||||
error: {
|
||||
code: 'CONVERSATION_NOT_FOUND',
|
||||
message: 'Konversationen hittades inte.',
|
||||
message_en: 'Conversation not found.',
|
||||
},
|
||||
},
|
||||
{ status: 404 },
|
||||
)
|
||||
|
||||
export const POST = withRouteContext(
|
||||
'agent.conversations.reject_pending',
|
||||
async (_request, ctx, { params }: { params: Promise<{ id: string }> }) => {
|
||||
const { id } = await params
|
||||
const { supabase, user } = ctx
|
||||
|
||||
// Conversations are user-scoped: prove ownership before touching its rows.
|
||||
const { data: conv } = await supabase
|
||||
.from('agent_conversations')
|
||||
.select('id, company_id, user_id')
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.maybeSingle()
|
||||
if (!conv) return notFound()
|
||||
|
||||
// Defense in depth alongside RLS: still a member of the conversation's company.
|
||||
const { data: membership } = await supabase
|
||||
.from('company_members')
|
||||
.select('role')
|
||||
.eq('company_id', conv.company_id)
|
||||
.eq('user_id', user.id)
|
||||
.maybeSingle()
|
||||
if (!membership) return notFound()
|
||||
|
||||
const cleared = await rejectPendingForConversation(supabase, conv.company_id as string, id)
|
||||
return NextResponse.json({ data: { cleared } })
|
||||
},
|
||||
)
|
||||
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { rejectPendingForConversation } from '@/lib/agent/pending/reject-conversation-pending'
|
||||
|
||||
// GET /api/agent/conversations/[id]
|
||||
//
|
||||
@@ -147,6 +148,18 @@ export const PATCH = withRouteContext(
|
||||
.select('id, intent_id, context_ref, title, pinned, archived, last_message_at, created_at')
|
||||
.single()
|
||||
if (error) throw error
|
||||
|
||||
// Archiving a thread means "I'm done with it": clear any proposals the
|
||||
// assistant staged here that the user never acted on, so they don't linger
|
||||
// in Granskning. Best-effort — a failure here must not fail the archive.
|
||||
if (body.archived === true) {
|
||||
try {
|
||||
await rejectPendingForConversation(supabase, existing.company_id as string, id)
|
||||
} catch {
|
||||
// ignored
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
},
|
||||
)
|
||||
|
||||
@@ -206,6 +206,26 @@ export default function AgentChat({
|
||||
onStatus,
|
||||
}: AgentChatProps) {
|
||||
const [conversationId, setConversationId] = useState<string | null>(initialConversationId ?? null)
|
||||
// "Clear the proposals I didn't approve" — rejects the conversation's
|
||||
// still-pending staged operations in one call so they don't linger in
|
||||
// Granskning, then drops the cards from view.
|
||||
const [clearingProposals, setClearingProposals] = useState(false)
|
||||
async function handleClearProposals() {
|
||||
const convId = conversationId
|
||||
if (!convId || clearingProposals) return
|
||||
setClearingProposals(true)
|
||||
try {
|
||||
await fetch(`/api/agent/conversations/${convId}/reject-pending`, { method: 'POST' })
|
||||
// Committed proposals are already booked (their card was only a
|
||||
// confirmation); pending ones the server just rejected. Either way, drop
|
||||
// the cards so the conversation reads as resolved.
|
||||
setMessages((prev) => prev.map((m) => (m.staged ? { ...m, staged: undefined } : m)))
|
||||
} catch {
|
||||
// best-effort: leave the cards if the request failed
|
||||
} finally {
|
||||
setClearingProposals(false)
|
||||
}
|
||||
}
|
||||
// Track whether the first-turn callback has fired so the bootstrap
|
||||
// starters get exactly one notification even if a turn fires before
|
||||
// the conversation_id event (defensive: order shouldn't matter).
|
||||
@@ -867,6 +887,22 @@ export default function AgentChat({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* One-tap cleanup: when the assistant staged several proposals and the
|
||||
user only approved some, clear the rest here instead of rejecting each
|
||||
one in Granskning. */}
|
||||
{conversationId && messages.some((m) => m.staged && m.staged.length > 0) && (
|
||||
<div className="flex justify-end border-t border-border px-5 py-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleClearProposals()}
|
||||
disabled={clearingProposals}
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50"
|
||||
>
|
||||
{clearingProposals ? 'Rensar…' : 'Rensa förslag som inte godkänts'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Paywall: /api/agent/invoke 403s without the ai capability. Replace
|
||||
the composer with an upsell so an already-open conversation (or a
|
||||
deep link to /chat/*) never offers an input that can't send. */}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { rejectPendingForConversation } from '../reject-conversation-pending'
|
||||
|
||||
function makeSupabase(rows: unknown[]) {
|
||||
const calls: { table?: string; update?: Record<string, unknown>; filters: [string, unknown][] } = {
|
||||
filters: [],
|
||||
}
|
||||
const chain = {
|
||||
from(table: string) {
|
||||
calls.table = table
|
||||
return chain
|
||||
},
|
||||
update(payload: Record<string, unknown>) {
|
||||
calls.update = payload
|
||||
return chain
|
||||
},
|
||||
eq(col: string, val: unknown) {
|
||||
calls.filters.push([col, val])
|
||||
return chain
|
||||
},
|
||||
select: async () => ({ data: rows }),
|
||||
}
|
||||
return { supabase: chain as unknown as SupabaseClient, calls }
|
||||
}
|
||||
|
||||
describe('rejectPendingForConversation', () => {
|
||||
it('rejects only pending rows of the conversation and returns the count', async () => {
|
||||
const { supabase, calls } = makeSupabase([{ id: 'a' }, { id: 'b' }])
|
||||
const n = await rejectPendingForConversation(supabase, 'company-1', 'conv-1')
|
||||
expect(n).toBe(2)
|
||||
expect(calls.table).toBe('pending_operations')
|
||||
expect(calls.update).toMatchObject({ status: 'rejected', rejection_reason: 'Ej godkänd i assistenten' })
|
||||
expect(calls.update?.resolved_at).toEqual(expect.any(String))
|
||||
// Guarded to this company, only pending rows, only this conversation.
|
||||
expect(calls.filters).toEqual(
|
||||
expect.arrayContaining([
|
||||
['company_id', 'company-1'],
|
||||
['status', 'pending'],
|
||||
['agent_metadata->>conversation_id', 'conv-1'],
|
||||
]),
|
||||
)
|
||||
})
|
||||
|
||||
it('returns 0 when nothing was pending', async () => {
|
||||
const { supabase } = makeSupabase([])
|
||||
expect(await rejectPendingForConversation(supabase, 'c1', 'conv-1')).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
|
||||
/**
|
||||
* Reject every still-pending proposal the assistant staged in one conversation.
|
||||
*
|
||||
* When the assistant proposes several verifikat and the user approves only some,
|
||||
* the rest linger as pending_operations in Granskning until the 30-day expiry —
|
||||
* the "manually clean up afterward" friction users reported. This clears the
|
||||
* leftovers in one shot: the chat's "clear the rest" button and the auto-clean
|
||||
* on conversation archive both call it.
|
||||
*
|
||||
* Only `status = 'pending'` rows are touched (never a committing/committed one,
|
||||
* which would stamp 'rejected' over a posted verifikat — the same guard the
|
||||
* single/bulk reject routes use). Company-scoped. Returns how many were cleared.
|
||||
*/
|
||||
export async function rejectPendingForConversation(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
conversationId: string,
|
||||
): Promise<number> {
|
||||
const { data } = await supabase
|
||||
.from('pending_operations')
|
||||
.update({
|
||||
status: 'rejected',
|
||||
resolved_at: new Date().toISOString(),
|
||||
rejection_reason: 'Ej godkänd i assistenten',
|
||||
})
|
||||
.eq('company_id', companyId)
|
||||
.eq('status', 'pending')
|
||||
.eq('agent_metadata->>conversation_id', conversationId)
|
||||
.select('id')
|
||||
return data?.length ?? 0
|
||||
}
|
||||
Reference in New Issue
Block a user