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:
co-authored by
Claude Fable 5
parent
abe9ac9d8c
commit
a3c6566caf
@@ -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),
|
||||
}
|
||||
@@ -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 } : {}),
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user