feat(mcp): ledger-context resource with per-company booking patterns (#928)

* feat(mcp): ledger-context resource with per-company booking patterns

Adds Accounted://ledger/context: derived account usage, counterparty
booking patterns with explicit confidence share (0.7 floor), explicit
mapping rules kept separate as authoritative, observed VAT profile, and
conventions. Backed by a SECURITY INVOKER get_ledger_usage_stats RPC so
group-bys run SQL-side, and surfaced as a top-5 digest stanza on
gnubok_get_agent_briefing so one call still bootstraps a session.

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

* feat(mcp): fold source-quality prereqs into the ledger-context RPC

Merchant-name normalization at the aggregation path (the splinter fix):
new normalize_counterparty_key() SQL function mirroring
normalizeCounterpartyName() so KORTKÖP/SWISH/date-suffixed labels merge
into one counterparty key, which also makes the categorization_templates
join exact. New supplier_patterns section (per-supplier dominant expense
account + VAT treatment from supplier invoices; credit notes and reversed
invoices excluded). account_usage excludes storno lines (they re-inflate
the account a correction moved away from); the counterparty CTE keeps
corrections because the transaction relink self-heals. Pattern confidence
is now count-grounded evidence {seen_12m, agree, share, last_booked}
instead of a bare ratio, and the digest frames it as historical frequency,
never auto-book permission.

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

* fix(agent-context): use roundOre for the share ratio (antipattern ratchet)

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

* fix(agent-context): defensive storno filter on counterparty CTE, fail-loud secondary reads

Review follow-ups: the counterparty CTE now excludes source_type='storno'
defensively (no live code path links a transaction to a storno, but legacy
rows may predate reverseEntry's unlink; a linked storno would count the
reversed category as precedent). Corrections stay included: they are the
live booking after relink. Secondary reads (rules, templates, settings)
now throw instead of silently reading as empty data: an agent must never
be told 'no rules' when the truth is 'read failed'.

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-07-08 13:48:04 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent abe9ac9d8c
commit a3c6566caf
9 changed files with 1504 additions and 1 deletions
@@ -3,13 +3,14 @@ import { dataResources, findResource, parseResourceQuery } from '../resources'
describe('mcp resource registry', () => {
it('exposes all data resources with required fields', () => {
expect(dataResources).toHaveLength(7)
expect(dataResources).toHaveLength(8)
const uris = dataResources.map((r) => r.uri).sort()
expect(uris).toEqual([
'Accounted://attention',
'Accounted://capabilities',
'Accounted://chart-of-accounts',
'Accounted://company/current',
'Accounted://ledger/context',
'Accounted://period/active',
'Accounted://recent-activity',
'Accounted://settings/vat-treatments',
@@ -6,6 +6,7 @@ import { recentActivityResource } from './recent-activity'
import { capabilitiesResource } from './capabilities'
import { vatTreatmentsResource } from './vat-treatments'
import { attentionResource } from './attention'
import { ledgerContextResource } from './ledger-context'
export const dataResources: McpResource[] = [
companyCurrentResource,
@@ -15,6 +16,7 @@ export const dataResources: McpResource[] = [
capabilitiesResource,
vatTreatmentsResource,
attentionResource,
ledgerContextResource,
]
export function findResource(uri: string): McpResource | null {
@@ -0,0 +1,21 @@
import type { McpResource } from './types'
import { buildLedgerContext } from '@/lib/agent-context/ledger-context'
/**
* How this company books things, derived from the ledger itself: account
* usage, counterparty + supplier booking patterns (with count-grounded
* evidence), user-authored rules, observed VAT profile, and conventions.
* Sibling of company-current (state now vs patterns over time); zero field
* overlap.
*
* Read-only and per-request; caching is deferred until measured slow
* (dev_docs/ledger_context_resource.md).
*/
export const ledgerContextResource: McpResource = {
uri: 'Accounted://ledger/context',
name: 'Ledger Context',
description:
'How this company books things: account usage, counterparty and supplier booking patterns with count-grounded evidence, explicit rules, observed VAT profile, conventions. Read before categorizing or creating vouchers; prefer these patterns over guesses. Explicit rules outrank observed patterns.',
mimeType: 'application/json',
read: async ({ supabase, companyId }) => buildLedgerContext(supabase, companyId),
}
+99
View File
@@ -31,6 +31,7 @@ import { generateARLedger } from '@/lib/reports/ar-ledger'
import { generateMonthlyBreakdown } from '@/lib/reports/monthly-breakdown'
import { uiWidgets, findUiWidget, WIDGET_MIME_TYPE } from './widgets'
import { dataResources, findResource, parseResourceQuery } from './resources'
import { buildLedgerContext } from '@/lib/agent-context/ledger-context'
import { prompts, findPrompt } from './prompts'
import { findSkill, loadAllSkills, toSummary, SKILL_MIME_TYPE, SKILL_URI_PREFIX, skillUri, skillSlugFromUri } from './skills'
import type { SkillTier } from './skills'
@@ -2298,6 +2299,65 @@ export const tools: McpTool[] = [
},
required: ['enabled', 'dimensions'],
},
ledger_context: {
type: 'object',
additionalProperties: false,
description: 'Digest of how this company books things: top-5 counterparty + top-3 supplier patterns. Full picture (account usage, explicit rules, VAT profile, conventions) in the Accounted://ledger/context resource. Evidence is historical frequency, NOT permission to auto-book: weigh seen count AND recency, never a ratio alone. OMITTED when not computable.',
properties: {
resource_uri: { type: 'string', description: 'URI of the full ledger-context resource.' },
window_from: { type: 'string', description: 'Start of the rolling stats window (ISO date).' },
posted_entries_window: { type: 'number', description: 'Posted journal entries in the window. Low = thin evidence: treat patterns as weak.' },
top_counterparty_patterns: {
type: 'array',
description: 'Most frequent booked bank-feed counterparties with dominant booking. evidence = seen N in 12m, M agreed, last booked; below 0.7 agreement excluded.',
items: {
type: 'object',
additionalProperties: false,
properties: {
counterparty: { type: 'string' },
dominant_category: { type: 'string' },
dominant_account_number: { type: ['string', 'null'] },
evidence: {
type: 'object',
additionalProperties: false,
properties: {
seen_12m: { type: 'number' },
agree: { type: 'number' },
last_booked: { type: 'string' },
},
required: ['seen_12m', 'agree', 'last_booked'],
},
},
required: ['counterparty', 'dominant_category', 'dominant_account_number', 'evidence'],
},
},
top_supplier_patterns: {
type: 'array',
description: 'Most invoiced suppliers (AP side) with dominant expense account and VAT treatment. Same evidence semantics.',
items: {
type: 'object',
additionalProperties: false,
properties: {
supplier: { type: 'string' },
dominant_account_number: { type: 'string' },
vat_treatment: { type: ['string', 'null'] },
evidence: {
type: 'object',
additionalProperties: false,
properties: {
seen_12m: { type: 'number' },
agree: { type: 'number' },
last_booked: { type: 'string' },
},
required: ['seen_12m', 'agree', 'last_booked'],
},
},
required: ['supplier', 'dominant_account_number', 'vat_treatment', 'evidence'],
},
},
},
required: ['resource_uri', 'window_from', 'posted_entries_window', 'top_counterparty_patterns', 'top_supplier_patterns'],
},
},
required: ['company', 'user_name', 'profile_summary', 'atoms', 'memory'],
},
@@ -2326,6 +2386,42 @@ export const tools: McpTool[] = [
}
})()
// Ledger-context digest is best-effort: a stats failure (e.g. RPC not
// yet applied on a self-hosted install) omits the block, never blocks
// the briefing.
const safeLedgerDigest = (async () => {
try {
const ctx = await buildLedgerContext(supabase, companyId)
return {
resource_uri: 'Accounted://ledger/context',
window_from: ctx.meta.window.from,
posted_entries_window: ctx.meta.coverage.posted_entries_window,
top_counterparty_patterns: ctx.counterparty_patterns.slice(0, 5).map((p) => ({
counterparty: p.counterparty,
dominant_category: p.dominant.category,
dominant_account_number: p.dominant.account_number,
evidence: {
seen_12m: p.evidence.seen_12m,
agree: p.evidence.agree,
last_booked: p.evidence.last_booked,
},
})),
top_supplier_patterns: ctx.supplier_patterns.slice(0, 3).map((s) => ({
supplier: s.supplier,
dominant_account_number: s.dominant.account_number,
vat_treatment: s.dominant.vat_treatment,
evidence: {
seen_12m: s.evidence.seen_12m,
agree: s.evidence.agree,
last_booked: s.evidence.last_booked,
},
})),
}
} catch {
return null
}
})()
const [profileRes, memoryRes, userRes, companyRes, settingsRes, dimensionsRes] = await Promise.all([
supabase
.from('agent_profiles')
@@ -2517,6 +2613,8 @@ export const tools: McpTool[] = [
}))
}
const ledgerDigest = await safeLedgerDigest
return {
company,
user_name: userName,
@@ -2530,6 +2628,7 @@ export const tools: McpTool[] = [
relevance_score: m.relevance_score,
})),
...(dimensionsBlock ? { dimensions: dimensionsBlock } : {}),
...(ledgerDigest ? { ledger_context: ledgerDigest } : {}),
}
},
},