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:
co-authored by
Jakob Wennberg
Claude Opus 5
parent
304baf1089
commit
5fe3ae71a1
@@ -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 }> = []
|
||||
|
||||
Reference in New Issue
Block a user