feat(mcp): surface documents that are attached to nothing on the attention resource (#1979)

A document_attachments row is reachable from eight places. A row referenced by
none of them is stored, retained for seven years under BFL, and connected to no
bookkeeping at all. Nothing surfaced those, so they accumulated: 4 497 across
210 companies, 481 of them in the preceding week.

The naive predicate is a trap. Without a mime filter the same query returns
15 806 rows, and 11 309 of those are archived PSD2 bank-API responses that are
unlinked by design. Putting them on an orientation surface would hand an agent
eleven thousand items of work it must not do, which is worse than showing
nothing.

So the rule is an allow-list of the mime types an underlag can actually be.
Measured on production: application/json was 11 309 of 11 309 PSD2 archive, and
pdf/png/jpeg/heic were 0 of 4 495. The split is clean, and an allow-list keeps
the next machine-payload format out by default rather than after someone
notices it leaking.

Two passes, mirroring fetchPurchasesWithoutUnderlag: the indexed column filter
first, then eight reference lookups that run only when candidates exist, so a
company with none costs exactly one query. The scan cap is set by URL length
rather than table size, because every candidate id is echoed back through those
eight .in() lookups.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-08-27 18:00:27 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Opus 5
parent 304baf1089
commit 5fe3ae71a1
5 changed files with 358 additions and 2 deletions
@@ -22,7 +22,7 @@ const ctx = (supabase: ReturnType<typeof createQueuedMockSupabase>['supabase'])
})
/**
* Enqueues 14 baseline empty results in the order the resource consumes them.
* Enqueues 15 baseline empty results in the order the resource consumes them.
* Tests can override individual slots before invoking by enqueueing in advance.
*/
function enqueueEmpty(enqueue: (r: { data?: unknown; error?: unknown; count?: number | null }) => void) {
@@ -54,6 +54,10 @@ function enqueueEmpty(enqueue: (r: { data?: unknown; error?: unknown; count?: nu
enqueue({ data: null })
// 14. companySettingsRow
enqueue({ data: null })
// 15. unlinked-document candidates. fetchUnlinkedDocuments returns early on
// an empty candidate set, so it consumes exactly this one slot; with
// candidates it consumes eight more, one per referencing table.
enqueue({ data: [] })
}
describe('Accounted://attention', () => {
@@ -188,6 +192,47 @@ describe('Accounted://attention', () => {
expect(result.summary.critical).toBe(1)
})
it('surfaces documents that nothing references, pointing at the link tool', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
// Slots 1-14 empty, then this test's own candidate set in slot 15 and the
// eight reference lookups behind it.
for (let i = 0; i < 14; i += 1) enqueue({ data: i === 12 || i === 13 ? null : [], count: 0 })
enqueue({
data: [
{ id: 'doc-new', file_name: 'kvitto.pdf', mime_type: 'application/pdf', file_size_bytes: 900, upload_source: 'api', created_at: '2026-08-20T00:00:00.000Z' },
{ id: 'doc-old', file_name: 'faktura.pdf', mime_type: 'application/pdf', file_size_bytes: 900, upload_source: 'file_upload', created_at: '2026-08-01T00:00:00.000Z' },
],
})
for (let i = 0; i < 8; i += 1) enqueue({ data: [] })
const result = (await attentionResource.read(ctx(supabase))) as AttentionResponse
const cat = result.categories.find((c) => c.key === 'unlinked_documents')
expect(cat).toBeDefined()
expect(cat?.count).toBe(2)
expect(cat?.severity).toBe('warning')
expect(cat?.next?.tool).toBe('gnubok_link_document_to_voucher')
// Oldest first, matching every other category's "start with the stalest" hint.
expect(cat?.next?.args).toEqual({ document_id: 'doc-old' })
})
it('omits the unlinked-documents category when every candidate is claimed', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
for (let i = 0; i < 14; i += 1) enqueue({ data: i === 12 || i === 13 ? null : [], count: 0 })
enqueue({
data: [
{ id: 'doc-1', file_name: 'kvitto.pdf', mime_type: 'application/pdf', file_size_bytes: 900, upload_source: 'api', created_at: '2026-08-20T00:00:00.000Z' },
],
})
// The first referencing table claims it; the remaining seven return empty.
enqueue({ data: [{ document_id: 'doc-1' }] })
for (let i = 0; i < 7; i += 1) enqueue({ data: [] })
const result = (await attentionResource.read(ctx(supabase))) as AttentionResponse
expect(result.categories.find((c) => c.key === 'unlinked_documents')).toBeUndefined()
})
it('flags voucher gaps as critical and includes next tool args', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
const seriesRows = [{ voucher_series: 'A', fiscal_period_id: 'fp-1' }]
@@ -206,6 +251,7 @@ describe('Accounted://attention', () => {
enqueue({ data: [] }) // bankConnRows
enqueue({ data: null }) // activePeriodRow
enqueue({ data: null }) // companySettingsRow
enqueue({ data: [] }) // unlinked-document candidates
// Loop body for series 'A':
enqueue({ data: [{ gap_start: 5, gap_end: 7 }] }) // detect_voucher_gaps RPC
enqueue({ data: [] }) // voucher_gap_explanations follow-up
@@ -1,5 +1,9 @@
import type { McpResource } from './types'
import { ACTION_NEEDED_THRESHOLD_DAYS } from '@/lib/deadlines/status-engine'
import {
fetchUnlinkedDocuments,
UNLINKED_DOCUMENT_SCAN_CAP,
} from '@/lib/documents/unlinked-documents'
import { countReconciliationDue } from '@/lib/worklist/categories'
type Severity = 'critical' | 'warning' | 'info'
@@ -29,7 +33,7 @@ export const attentionResource: McpResource = {
uri: 'Accounted://attention',
name: 'What Needs Attention',
description:
'One-shot summary of outstanding work for the active company: unbooked transactions, overdue invoices, pending approvals, voucher gaps, upcoming deadlines, bank consent expiry, and period-lock alerts. Each category includes a count, up to 5 sample rows, and a suggested next tool call. Use this at session start to orient before chaining read tools.',
'One-shot summary of outstanding work for the active company: unbooked transactions, overdue invoices, pending approvals, documents linked to no verifikat, voucher gaps, upcoming deadlines, bank consent expiry, and period-lock alerts. Each category includes a count, up to 5 sample rows, and a suggested next tool call. Use this at session start to orient before chaining read tools.',
mimeType: 'application/json',
read: async ({ supabase, companyId }) => {
const now = new Date()
@@ -52,6 +56,7 @@ export const attentionResource: McpResource = {
bankConnRows,
activePeriodRow,
companySettingsRow,
unlinkedDocuments,
] = await Promise.all([
supabase
.from('transactions')
@@ -143,6 +148,7 @@ export const attentionResource: McpResource = {
.select('bookkeeping_locked_through, auto_lock_period_days')
.eq('company_id', companyId)
.maybeSingle(),
fetchUnlinkedDocuments(supabase, companyId),
])
const categories: AttentionCategory[] = []
@@ -242,6 +248,37 @@ export const attentionResource: McpResource = {
})
}
// ── Documents attached to nothing ──────────────────────────────
//
// Underlag-shaped files only: the same query without a mime allow-list
// returns 11 309 archived PSD2 bank-API responses on production, which are
// unlinked by design and must never be presented as work. See
// lib/documents/unlinked-documents.ts.
if (unlinkedDocuments.count > 0) {
const oldest = unlinkedDocuments.documents[unlinkedDocuments.documents.length - 1]
categories.push({
key: 'unlinked_documents',
label_sv: 'Dokument utan koppling till verifikat eller transaktion',
severity: 'warning',
count: unlinkedDocuments.count,
samples: unlinkedDocuments.documents.slice(0, SAMPLE_LIMIT),
next: {
// Two legitimate destinations, and the tool pointer can only name
// one. A document that arrived after the fact is linked to the
// posted verifikat; one whose affärshändelse was never booked
// belongs to a new verifikat as its underlag (BFL 5 kap. 6 §), which
// is what gnubok_link_document_to_voucher's own description says to
// prefer. The prose carries the choice, the pointer carries the
// common case, and journal_entry_id is the agent's to resolve.
description: unlinkedDocuments.capped
? `Koppla dokumentet till rätt verifikat, eller bokför affärshändelsen med dokumentet som underlag om den inte är bokförd än. Minst ${unlinkedDocuments.count} dokument saknar koppling (avsökningen stannade vid ${UNLINKED_DOCUMENT_SCAN_CAP} kandidater).`
: 'Koppla dokumentet till rätt verifikat, eller bokför affärshändelsen med dokumentet som underlag om den inte är bokförd än.',
tool: 'gnubok_link_document_to_voucher',
args: oldest ? { document_id: oldest.id } : undefined,
},
})
}
// ── Voucher gaps without explanations ──────────────────────────
const seriesRows = (voucherSeriesRows.data ?? []) as Array<{ voucher_series: string; fiscal_period_id: string }>
const allGaps: Array<{ series: string; gap_start: number; gap_end: number; fiscal_period_id: string }> = []