diff --git a/DECISIONS.md b/DECISIONS.md index 664b9fcb..29cdf05d 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1156,3 +1156,4 @@ One line per decision: `[YYYY-MM-DD] : `. 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. diff --git a/app/api/agent/conversations/[id]/__tests__/reject-pending.test.ts b/app/api/agent/conversations/[id]/__tests__/reject-pending.test.ts new file mode 100644 index 00000000..e03309ad --- /dev/null +++ b/app/api/agent/conversations/[id]/__tests__/reject-pending.test.ts @@ -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') + }) +}) diff --git a/app/api/agent/conversations/[id]/reject-pending/route.ts b/app/api/agent/conversations/[id]/reject-pending/route.ts new file mode 100644 index 00000000..7e7ada6e --- /dev/null +++ b/app/api/agent/conversations/[id]/reject-pending/route.ts @@ -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 } }) + }, +) diff --git a/app/api/agent/conversations/[id]/route.ts b/app/api/agent/conversations/[id]/route.ts index 0314361e..759f8a9f 100644 --- a/app/api/agent/conversations/[id]/route.ts +++ b/app/api/agent/conversations/[id]/route.ts @@ -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 }) }, ) diff --git a/components/agent/AgentChat.tsx b/components/agent/AgentChat.tsx index 0602257b..ba39d095 100644 --- a/components/agent/AgentChat.tsx +++ b/components/agent/AgentChat.tsx @@ -206,6 +206,26 @@ export default function AgentChat({ onStatus, }: AgentChatProps) { const [conversationId, setConversationId] = useState(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({ )} + {/* 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) && ( +
+ +
+ )} + {/* 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. */} diff --git a/lib/agent/pending/__tests__/reject-conversation-pending.test.ts b/lib/agent/pending/__tests__/reject-conversation-pending.test.ts new file mode 100644 index 00000000..c1b2acfe --- /dev/null +++ b/lib/agent/pending/__tests__/reject-conversation-pending.test.ts @@ -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; filters: [string, unknown][] } = { + filters: [], + } + const chain = { + from(table: string) { + calls.table = table + return chain + }, + update(payload: Record) { + 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) + }) +}) diff --git a/lib/agent/pending/reject-conversation-pending.ts b/lib/agent/pending/reject-conversation-pending.ts new file mode 100644 index 00000000..eec5508f --- /dev/null +++ b/lib/agent/pending/reject-conversation-pending.ts @@ -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 { + 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 +}