feat(mcp): P2 hygiene — honest category suggestions, skill-reference lint, cadence copy (#882)
* feat(mcp): counterparty-tied category suggestions + no_signal (P2-1)
suggest_categories padded every transaction with a company-wide
category-frequency fallback at <=0.5 confidence — an identical four-way
spread on 20+/24 items that agents correctly reported as pure noise
(agent.feedback). Real signal came from memory atoms and query_journal.
- History is now counterparty-keyed: buildMerchantHistory groups past
categorized transactions by normalized merchant; the engine only
surfaces history for THIS transaction's merchant, with provenance
('Bokförd N gånger tidigare för denna motpart') and occurrence-scaled
confidence (0.56 at 1x, capped 0.85). No global padding — an empty
list is the honest answer.
- The MCP tool returns no_signal_transaction_ids for transactions where
NO source matched, steering agents to investigate (query_journal)
instead of pattern-matching on unrelated rows.
- Both callers (REST suggest-categories route + MCP tool) share the new
helpers, so web UI and agents improve together.
Part of dev_docs/mcp_optimization_plan.md (P2-1).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(skills): dangling-reference validation in skills:check + fix 10 dangling links (P2-2)
skills:generate/check now fail when an atom SKILL.md links a
references/*.md that does not exist on disk — a dangling pointer ships
a 404 to every agent that follows it (the weekly-booking-check
incident, agent.feedback).
The validator immediately caught 10 live dangling links in 4 atoms,
three distinct flavors:
- filename typo: swedish-asset-accounting/references/depreciaton.md
renamed to depreciation.md (the link was right, the file misspelled)
- link mismatch: swedish-e-invoicing linked market-providers-pricing.md;
the file is market-provider-pricing.md (link fixed)
- unauthored plans: single-shareholder-ab-fmb TODOs and reklambyra's
'planerad utbyggnad' section used resolvable references/ paths for
files that were never written — rephrased as plans without paths
Seed migration regenerated (4 atoms bumped, renamed reference child).
Part of dev_docs/mcp_optimization_plan.md (P2-2).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(events): align agent-feedback review cadence copy (P2-4)
gnubok_feedback replies 'we aggregate signal weekly'; the event-log
handler comment said quarterly. One of them was lying — weekly wins
(the mcp_optimization_plan triage is the living example).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -166,11 +166,11 @@ Konton som **inte** används för denna vertikal trots vanliga missförstånd: 1
|
||||
|
||||
## Overflow-filer (planerad utbyggnad)
|
||||
|
||||
Token-budget för denna SKILL.md är hållen under 15k. Föreslagna overflow-filer för djupare detaljer:
|
||||
Token-budget för denna SKILL.md är hållen under 15k. Föreslagna overflow-ämnen för djupare detaljer (inga av dessa referensfiler är skapade ännu):
|
||||
|
||||
- **references/principal-vs-agent-mediainkop.md** — Beslutsmatris med IFRS 15 B37-indikatorer, K3 23.2-tillämpning, exempelposteringar för principal/agent/utlägg, mall för principal-/agent-analys per kund.
|
||||
- **references/a-sink-influencer-utlandsbetalningar.md** — A-SINK vs SINK-beslut, AGI-rapportering, skatteavtal (OECD art. 17), formulärflöden (SKV 520, SINK-ansökan), KU70 till utländska mottagare.
|
||||
- **references/sponsring-motprestationsbedomning.md** — Mall för sponsoravtal med marknadsvärdering per komponent, checklista för dokumentation, fullständig praxisgenomgång (RÅ 1976 ref. 127 I/II, RÅ 2000 ref. 31 I/II, HFD 2014 ref. 62, HFD 2018 ref. 55, KR Göteborg 2024-12), SOU 2026:5-bevakning.
|
||||
- **references/url-upphovsratt-byraavtal.md** — Branschmallar för upphovsrätts-överlåtelse i byrå-kundavtal, NJA 2010 s. 559-tolkning, DSM-direktivets §§ i URL (15 a–15 c, 29, 29 d–e, 48 b–d, 52 i–u), AD 2002 nr 87.
|
||||
- **references/ai-content-och-ai-act-byraer.md** — EU AI Act-tidslinje (art. 4, art. 50), TDM-opt-out under 15 a § URL, AI-klausulbibliotek för byråavtal, bokföring av AI-prenumerationer och API-konsumtion (5420 vs 6540), C-250/25-bevakning.
|
||||
- **references/pagaende-arbeten-konsultrorelse.md** — K2 6.13–6.24 och K3 23.18–23.37 jämförelse, IL 17 kap 23–32 §§, RÅ 2006 ref. 28 frikoppling, Srf U 14 + U 15, BFNAR 2025:1-kvittningsförbud, exempelflöden för retainer, kampanj fast pris och blandade uppdrag.
|
||||
- **principal-vs-agent-mediainkop** — Beslutsmatris med IFRS 15 B37-indikatorer, K3 23.2-tillämpning, exempelposteringar för principal/agent/utlägg, mall för principal-/agent-analys per kund.
|
||||
- **a-sink-influencer-utlandsbetalningar** — A-SINK vs SINK-beslut, AGI-rapportering, skatteavtal (OECD art. 17), formulärflöden (SKV 520, SINK-ansökan), KU70 till utländska mottagare.
|
||||
- **sponsring-motprestationsbedomning** — Mall för sponsoravtal med marknadsvärdering per komponent, checklista för dokumentation, fullständig praxisgenomgång (RÅ 1976 ref. 127 I/II, RÅ 2000 ref. 31 I/II, HFD 2014 ref. 62, HFD 2018 ref. 55, KR Göteborg 2024-12), SOU 2026:5-bevakning.
|
||||
- **url-upphovsratt-byraavtal** — Branschmallar för upphovsrätts-överlåtelse i byrå-kundavtal, NJA 2010 s. 559-tolkning, DSM-direktivets §§ i URL (15 a–15 c, 29, 29 d–e, 48 b–d, 52 i–u), AD 2002 nr 87.
|
||||
- **ai-content-och-ai-act-byraer** — EU AI Act-tidslinje (art. 4, art. 50), TDM-opt-out under 15 a § URL, AI-klausulbibliotek för byråavtal, bokföring av AI-prenumerationer och API-konsumtion (5420 vs 6540), C-250/25-bevakning.
|
||||
- **pagaende-arbeten-konsultrorelse** — K2 6.13–6.24 och K3 23.18–23.37 jämförelse, IL 17 kap 23–32 §§, RÅ 2006 ref. 28 frikoppling, Srf U 14 + U 15, BFNAR 2025:1-kvittningsförbud, exempelflöden för retainer, kampanj fast pris och blandade uppdrag.
|
||||
|
||||
@@ -74,5 +74,5 @@ This modifier composes with:
|
||||
|
||||
## References
|
||||
|
||||
- See `references/k10-walkthrough.md` for a step-by-step K10 example (TODO — user to author).
|
||||
- See `references/loneunderlag-2026.md` for the 2026 reformed parameters (TODO — user to author).
|
||||
- Planned reference (not yet authored): a step-by-step K10 walkthrough example.
|
||||
- Planned reference (not yet authored): the 2026 reformed löneunderlag parameters.
|
||||
|
||||
@@ -20,7 +20,7 @@ Use the table below to decide which reference file(s) to read. Multiple files of
|
||||
| UBL 2.1 invoice structure, EN 16931 BT-* business terms, mandatory header (CustomizationID, ProfileID, InvoiceTypeCode), UNCL5305 VAT category codes (S/Z/E/AE/K/G/O), calculation rules (BR-CO-13/15/17, BR-S-08 etc.), document type identifiers, BIS suite (Billing, Self-Billing, Catalogue, Despatch Advice, Invoice Response, MLR, MLS), Peppol BIS 4.0 / PINT convergence | `references/peppol-bis-billing.md` |
|
||||
| Peppol 4-corner architecture, AS4 v2.0 transport, SMP/SML lookup with NAPTR/SHA-256 algorithm, SBDH v1.2 with C1 country code, PKI G3 certificates, becoming a certified Access Point or Service Provider, OpenPeppol membership tiers and pricing, Peppol Testbed conformance | `references/peppol-network.md` |
|
||||
| Sweden-specific CIUS rules (SE-R-005 "Godkänd för F-skatt", SE-R-006 VAT rate restriction, SE-R-008/009 Bankgiro, SE-R-010 Plusgiro, SE-R-011 PaymentMeansCode 30, SE-R-013 Luhn orgnr), Swedish VAT (25/12/6/0%) encoding, OCR reference (BT-83), Bankgiro/Plusgiro PaymentMeans encoding, ROT/RUT and grön teknik handling, BAS-kontoplan postings for AR/AP, faktureringsmetoden vs kontantmetoden, multi-currency with TaxCurrencyCode, BT-10 BuyerReference per-buyer formats, Peppol identifier schemes (0007/0088/0192/0184/0037/0208/0204), F-skatt registration | `references/swedish-cius-and-specifics.md` |
|
||||
| Choosing between Pagero/InExchange/Crediflow/Visma Autoinvoice/Maventa/Qvalia/Tietoevry/Basware/OpusCapita/Hogia/Ropo Capital/Storecove, market shares, pricing benchmarks (per-document, monthly minimums), DIGG Peppol traffic statistics, how Fortnox/Bokio/SpeedLedger/Björn Lundén white-label their Peppol layer, API capabilities of major providers | `references/market-providers-pricing.md` |
|
||||
| Choosing between Pagero/InExchange/Crediflow/Visma Autoinvoice/Maventa/Qvalia/Tietoevry/Basware/OpusCapita/Hogia/Ropo Capital/Storecove, market shares, pricing benchmarks (per-document, monthly minimums), DIGG Peppol traffic statistics, how Fortnox/Bokio/SpeedLedger/Björn Lundén white-label their Peppol layer, API capabilities of major providers | `references/market-provider-pricing.md` |
|
||||
| Consumer e-faktura: Bankgirot e-faktura privat, EFA / e-giro format, Anslutningsärende/Anmälningsärende, bank participants, Kivra digital mailbox (volumes, pricing, ownership, Tink/Swish integration), Min Myndighetspost, distinction between consumer rails and Peppol | `references/consumer-and-b2c.md` |
|
||||
| Comparing Sweden to Belgium (2026 decentralised Peppol mandate), France (PA/PPF 2026-2027), Germany (XRechnung phased 2025-2028), Italy (SDI clearance), Poland (KSeF Feb/Apr 2026), Romania (e-Factura), Norway (proposed 2028), Spain, ViDA cross-border 1 July 2030 mandate, ViDA 2035 alignment deadline for legacy CTC regimes, predicting Sweden's likely model | `references/european-mandates.md` |
|
||||
| Implementing e-invoicing in software: open-source libraries (Oxalis-NG, Oxalis-AS4, Helger phase4 / phoss-smp / peppol-commons / phive / ph-ubl), test environments, common rejection patterns (BR-CO-15 rounding, BT-10 missing, encoding bugs), build-vs-buy economics, when to use Storecove vs own AP, validation stack in CI, the recommended Accounted phased plan, strategic positioning vs Crediflow/InExchange-dependent incumbents | `references/implementation-guide.md` |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getSuggestedCategories, getSuggestedTemplates, type SuggestedCategory, type SuggestedTemplate } from '@/lib/transactions/category-suggestions'
|
||||
import { getSuggestedCategories, getSuggestedTemplates, buildMerchantHistory, merchantHistoryFor, type SuggestedCategory, type SuggestedTemplate } from '@/lib/transactions/category-suggestions'
|
||||
import { findCounterpartyTemplatesBatch, formatCounterpartyName, toCounterpartyTemplateId } from '@/lib/bookkeeping/counterparty-templates'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import type { Transaction, EntityType } from '@/types'
|
||||
@@ -48,22 +48,19 @@ export async function POST(request: Request) {
|
||||
.eq('is_active', true)
|
||||
.order('priority', { ascending: false })
|
||||
|
||||
// Build category history from user's past categorized transactions
|
||||
// Counterparty-keyed history from past categorized transactions — the
|
||||
// suggestion engine only surfaces history tied to the SAME merchant
|
||||
// (global frequency padding produced identical low-confidence spreads).
|
||||
const { data: historicalTxns } = await supabase
|
||||
.from('transactions')
|
||||
.select('category')
|
||||
.select('category, merchant_name')
|
||||
.eq('company_id', companyId)
|
||||
.not('is_business', 'is', null)
|
||||
.neq('category', 'uncategorized')
|
||||
.neq('category', 'private')
|
||||
.limit(200)
|
||||
|
||||
const categoryHistory: Record<string, number> = {}
|
||||
if (historicalTxns) {
|
||||
for (const tx of historicalTxns) {
|
||||
categoryHistory[tx.category] = (categoryHistory[tx.category] || 0) + 1
|
||||
}
|
||||
}
|
||||
const merchantHistory = buildMerchantHistory(historicalTxns ?? [])
|
||||
|
||||
// Fetch entity type for template matching
|
||||
const { data: settings } = await supabase
|
||||
@@ -84,7 +81,7 @@ export async function POST(request: Request) {
|
||||
suggestions[tx.id] = getSuggestedCategories(
|
||||
tx as Transaction,
|
||||
mappingRules || [],
|
||||
categoryHistory
|
||||
merchantHistoryFor(merchantHistory, (tx as Transaction).merchant_name)
|
||||
)
|
||||
template_suggestions[tx.id] = await getSuggestedTemplates(tx as Transaction, entityType, mappingRules || undefined)
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@ import { validateYearEndReadiness, previewYearEndClosing } from '@/lib/core/book
|
||||
import { generateSIEExport } from '@/lib/reports/sie-export'
|
||||
import { generateFullArchive, estimateArchiveSize } from '@/lib/reports/full-archive-export'
|
||||
import { bookkeepingErrorResponse } from '@/lib/bookkeeping/errors'
|
||||
import { getSuggestedCategories } from '@/lib/transactions/category-suggestions'
|
||||
import { getSuggestedCategories, buildMerchantHistory, merchantHistoryFor } from '@/lib/transactions/category-suggestions'
|
||||
import { detectBookingDuplicate } from '@/lib/transactions/booking-duplicate-detection'
|
||||
import { findDuplicatePaymentCandidatesForInvoice } from '@/lib/invoices/duplicate-payment-candidates'
|
||||
import { renderToBuffer } from '@react-pdf/renderer'
|
||||
@@ -4435,7 +4435,7 @@ export const tools: McpTool[] = [
|
||||
{
|
||||
name: 'gnubok_suggest_categories',
|
||||
title: 'Suggest Transaction Categories',
|
||||
description: 'Suggest categories for uncategorized transactions using mapping rules, pattern matching, history, and counterparty templates. Up to 20 transactions per call.',
|
||||
description: 'Suggest categories for uncategorized transactions using mapping rules, patterns, counterparty history and templates. Up to 20 per call. no_signal_transaction_ids = nothing matched; investigate via gnubok_query_journal instead of guessing.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
@@ -4454,8 +4454,13 @@ export const tools: McpTool[] = [
|
||||
properties: {
|
||||
suggestions: { type: 'object' },
|
||||
counterparty_matches: { type: 'object' },
|
||||
no_signal_transaction_ids: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description: 'Transactions where no source (rule, pattern, counterparty history, template) matched. An honest empty — do not infer categories from the other rows; investigate the counterparty (e.g. gnubok_query_journal) instead.',
|
||||
},
|
||||
},
|
||||
required: ['suggestions', 'counterparty_matches'],
|
||||
required: ['suggestions', 'counterparty_matches', 'no_signal_transaction_ids'],
|
||||
},
|
||||
annotations: {
|
||||
readOnlyHint: true,
|
||||
@@ -4487,19 +4492,19 @@ export const tools: McpTool[] = [
|
||||
.order('priority', { ascending: false })
|
||||
|
||||
// Build category history from past categorizations
|
||||
// Counterparty-keyed history: the engine only surfaces history tied to
|
||||
// the SAME merchant — global frequency padding produced the identical
|
||||
// ~0.5 four-way spread agents reported as pure noise (P2-1).
|
||||
const { data: historicalTxns } = await supabase
|
||||
.from('transactions')
|
||||
.select('category')
|
||||
.select('category, merchant_name')
|
||||
.eq('company_id', companyId)
|
||||
.not('is_business', 'is', null)
|
||||
.neq('category', 'uncategorized')
|
||||
.neq('category', 'private')
|
||||
.limit(200)
|
||||
|
||||
const categoryHistory: Record<string, number> = {}
|
||||
for (const tx of historicalTxns || []) {
|
||||
if (tx.category) categoryHistory[tx.category] = (categoryHistory[tx.category] || 0) + 1
|
||||
}
|
||||
const merchantHistory = buildMerchantHistory(historicalTxns ?? [])
|
||||
|
||||
// Batch counterparty template matching
|
||||
const counterpartyMatches = await findCounterpartyTemplatesBatch(
|
||||
@@ -4512,7 +4517,8 @@ export const tools: McpTool[] = [
|
||||
|
||||
for (const tx of transactions) {
|
||||
suggestions[tx.id] = getSuggestedCategories(
|
||||
tx as Transaction, mappingRules ?? [], categoryHistory
|
||||
tx as Transaction, mappingRules ?? [],
|
||||
merchantHistoryFor(merchantHistory, (tx as Transaction).merchant_name)
|
||||
)
|
||||
|
||||
const cpMatch = counterpartyMatches.get(tx.id)
|
||||
@@ -4529,7 +4535,18 @@ export const tools: McpTool[] = [
|
||||
}
|
||||
}
|
||||
|
||||
return { suggestions, counterparty_matches: counterpartyResult }
|
||||
// Honest absence beats fabricated confidence: mark transactions where
|
||||
// NO source produced a suggestion so agents investigate instead of
|
||||
// pattern-matching on unrelated rows (P2-1).
|
||||
const noSignal = transactions
|
||||
.filter((tx) => (suggestions[tx.id]?.length ?? 0) === 0 && !counterpartyResult[tx.id])
|
||||
.map((tx) => tx.id)
|
||||
|
||||
return {
|
||||
suggestions,
|
||||
counterparty_matches: counterpartyResult,
|
||||
no_signal_transaction_ids: noSignal,
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
@@ -53,7 +53,8 @@ const PERSISTED_EVENT_TYPES: CoreEventType[] = [
|
||||
// whether a loaded atom helps or hurts.
|
||||
'mcp.skill_loaded',
|
||||
// Agent self-reported feedback — surfaces "this tool was missing", "this
|
||||
// description was wrong", etc. Quarterly review → roadmap.
|
||||
// description was wrong", etc. Reviewed weekly (matches the gnubok_feedback
|
||||
// reply copy); triage → dev_docs/mcp_optimization_plan.md.
|
||||
'agent.feedback',
|
||||
// Bank connection consent lifecycle — required audit trail per ASVS V16
|
||||
// and GDPR Art.30 (records of processing) for PSD2 consent decisions.
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
buildMerchantHistory,
|
||||
getSuggestedCategories,
|
||||
merchantHistoryFor,
|
||||
} from '../category-suggestions'
|
||||
import type { Transaction } from '@/types'
|
||||
|
||||
/**
|
||||
* P2-1 (mcp_optimization_plan): suggestions must carry signal tied to THIS
|
||||
* transaction. The old company-wide frequency fallback emitted an identical
|
||||
* ~0.5 four-way spread on every transaction — noise agents correctly
|
||||
* distrusted. History is now counterparty-keyed with provenance; when no
|
||||
* source matches, the honest answer is an empty list.
|
||||
*/
|
||||
|
||||
const tx = (overrides: Partial<Transaction> = {}): Transaction =>
|
||||
({
|
||||
id: 'tx-1',
|
||||
company_id: 'company-1',
|
||||
date: '2026-06-01',
|
||||
description: 'KORTKÖP POLARN O PYRET',
|
||||
amount: -500,
|
||||
currency: 'SEK',
|
||||
merchant_name: 'Polarn O. Pyret',
|
||||
...overrides,
|
||||
}) as Transaction
|
||||
|
||||
describe('buildMerchantHistory / merchantHistoryFor', () => {
|
||||
const rows = [
|
||||
{ merchant_name: 'Polarn O. Pyret', category: 'expense_office' },
|
||||
{ merchant_name: 'polarn o. pyret', category: 'expense_office' },
|
||||
{ merchant_name: 'Polarn O. Pyret', category: 'expense_consumables' },
|
||||
{ merchant_name: 'DNB Bank', category: 'expense_bank_fees' },
|
||||
{ merchant_name: null, category: 'expense_other' },
|
||||
{ merchant_name: 'Ghost AB', category: null },
|
||||
]
|
||||
|
||||
it('groups case-insensitively by merchant and ignores null merchants/categories', () => {
|
||||
const map = buildMerchantHistory(rows)
|
||||
expect(merchantHistoryFor(map, 'POLARN O. PYRET')).toEqual({
|
||||
expense_office: 2,
|
||||
expense_consumables: 1,
|
||||
})
|
||||
expect(merchantHistoryFor(map, 'DNB Bank')).toEqual({ expense_bank_fees: 1 })
|
||||
expect(merchantHistoryFor(map, 'Unknown Vendor')).toEqual({})
|
||||
expect(merchantHistoryFor(map, null)).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('getSuggestedCategories — counterparty history', () => {
|
||||
it('returns an empty list (not a fabricated spread) when nothing matches', () => {
|
||||
const result = getSuggestedCategories(
|
||||
tx({ merchant_name: 'Helt Okänd Motpart', description: 'XYZ 123' }),
|
||||
[],
|
||||
{},
|
||||
)
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('surfaces merchant history with provenance and occurrence-scaled confidence', () => {
|
||||
const result = getSuggestedCategories(tx({ description: 'XYZ 123' }), [], {
|
||||
expense_office: 3,
|
||||
expense_consumables: 1,
|
||||
})
|
||||
expect(result.length).toBe(2)
|
||||
expect(result[0]).toMatchObject({
|
||||
category: 'expense_office',
|
||||
source: 'history',
|
||||
confidence: Math.min(0.85, 0.5 + 3 * 0.06),
|
||||
})
|
||||
expect(result[0].match_reason).toMatch(/3 gånger tidigare för denna motpart/)
|
||||
expect(result[1].category).toBe('expense_consumables')
|
||||
expect(result[1].match_reason).toMatch(/1 gång tidigare/)
|
||||
})
|
||||
|
||||
it('caps history confidence at 0.85', () => {
|
||||
const result = getSuggestedCategories(tx({ description: 'XYZ 123' }), [], {
|
||||
expense_office: 50,
|
||||
})
|
||||
expect(result[0].confidence).toBe(0.85)
|
||||
})
|
||||
|
||||
it('filters history to the transaction direction', () => {
|
||||
const result = getSuggestedCategories(
|
||||
tx({ amount: 1000, description: 'XYZ 123' }), // income direction
|
||||
[],
|
||||
{ expense_office: 5 },
|
||||
)
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -34,13 +34,51 @@ const CATEGORY_LABELS: Record<string, string> = {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get suggested categories for a transaction
|
||||
* Combines mapping rules, pattern matching, and user history
|
||||
* Counterparty-keyed history: normalized merchant name -> category counts.
|
||||
* Built once per request from the caller's recent categorized transactions.
|
||||
*/
|
||||
export type MerchantHistoryMap = Map<string, Record<string, number>>
|
||||
|
||||
function normalizeMerchantKey(name: string | null | undefined): string {
|
||||
return (name ?? '').toLowerCase().trim()
|
||||
}
|
||||
|
||||
export function buildMerchantHistory(
|
||||
rows: Array<{ merchant_name: string | null; category: string | null }>,
|
||||
): MerchantHistoryMap {
|
||||
const map: MerchantHistoryMap = new Map()
|
||||
for (const row of rows) {
|
||||
const key = normalizeMerchantKey(row.merchant_name)
|
||||
if (!key || !row.category) continue
|
||||
const bucket = map.get(key) ?? {}
|
||||
bucket[row.category] = (bucket[row.category] || 0) + 1
|
||||
map.set(key, bucket)
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
export function merchantHistoryFor(
|
||||
map: MerchantHistoryMap,
|
||||
merchantName: string | null | undefined,
|
||||
): Record<string, number> {
|
||||
const key = normalizeMerchantKey(merchantName)
|
||||
return key ? (map.get(key) ?? {}) : {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get suggested categories for a transaction.
|
||||
* Combines mapping rules, pattern matching, and counterparty history.
|
||||
*
|
||||
* merchantHistory is the category history FOR THIS TRANSACTION'S counterparty
|
||||
* (see buildMerchantHistory/merchantHistoryFor) — never a company-wide
|
||||
* frequency map. Global padding produced identical ~0.5 four-way spreads on
|
||||
* every transaction, which agents correctly read as no signal
|
||||
* (mcp_optimization_plan P2-1); an empty result is the honest answer.
|
||||
*/
|
||||
export function getSuggestedCategories(
|
||||
transaction: Transaction,
|
||||
mappingRules: MappingRule[],
|
||||
categoryHistory: Record<string, number>
|
||||
merchantHistory: Record<string, number>
|
||||
): SuggestedCategory[] {
|
||||
const suggestions: SuggestedCategory[] = []
|
||||
const seen = new Set<string>()
|
||||
@@ -104,8 +142,9 @@ export function getSuggestedCategories(
|
||||
})
|
||||
}
|
||||
|
||||
// 3. User history (most commonly used categories)
|
||||
const historyEntries = Object.entries(categoryHistory)
|
||||
// 3. Counterparty history — categories this merchant was booked as before.
|
||||
// Confidence scales with occurrences and the reason carries provenance.
|
||||
const historyEntries = Object.entries(merchantHistory)
|
||||
.sort(([, a], [, b]) => b - a)
|
||||
.filter(([cat]) => !seen.has(cat))
|
||||
|
||||
@@ -120,8 +159,11 @@ export function getSuggestedCategories(
|
||||
category: cat as TransactionCategory,
|
||||
label: CATEGORY_LABELS[cat] || cat,
|
||||
account: getExpenseAccountForCategory(cat as TransactionCategory),
|
||||
confidence: Math.min(0.5, count / 20),
|
||||
// 1 previous booking -> 0.56, capped at 0.85 (history informs, a human
|
||||
// or counterparty template confirms).
|
||||
confidence: Math.min(0.85, 0.5 + count * 0.06),
|
||||
source: 'history',
|
||||
match_reason: `Bokförd ${count} gång${count === 1 ? '' : 'er'} tidigare för denna motpart`,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -24,14 +24,14 @@
|
||||
"version": 3
|
||||
},
|
||||
"horizontal/swedish-asset-accounting": {
|
||||
"hash": "f7c79332c0fbc24f391aac3aeb1a01327bb803d11864dd68e52e37a6df2e8588",
|
||||
"version": 2
|
||||
"hash": "8f2e88b7d1b1d1f309c8533e8d05b8d0504a1c21d52a4d3257127eb25a52767f",
|
||||
"version": 3
|
||||
},
|
||||
"horizontal/swedish-asset-accounting/accounts-and-registry": {
|
||||
"hash": "b33d044b36d290aebb3677ae82020e0890aff3664070f2d1ec510f38b63793f4",
|
||||
"version": 1
|
||||
},
|
||||
"horizontal/swedish-asset-accounting/depreciaton": {
|
||||
"horizontal/swedish-asset-accounting/depreciation": {
|
||||
"hash": "f764c19f11c8613ba5014ca2767ddb1ae2926928e04c3abb43444ba27c286aec",
|
||||
"version": 1
|
||||
},
|
||||
@@ -40,8 +40,8 @@
|
||||
"version": 1
|
||||
},
|
||||
"horizontal/swedish-e-invoicing": {
|
||||
"hash": "bbcb94de81be7663384085104811a801ed6a12aa37cb2a34eefc6d0ba41267e6",
|
||||
"version": 4
|
||||
"hash": "0483ca6d461699a9651ab6e5b7484dfcc2442f4746f3a0fada4dd97269942496",
|
||||
"version": 5
|
||||
},
|
||||
"horizontal/swedish-e-invoicing/consumer-and-b2c": {
|
||||
"hash": "886cf35d3663b88d3b5bc8ca1766e14f073eb3de1f9e75aa55479582e638f32c",
|
||||
@@ -328,8 +328,8 @@
|
||||
"version": 1
|
||||
},
|
||||
"modifier/single-shareholder-ab-fmb": {
|
||||
"hash": "d79be2b50e33774453b8d6baaec59b79fd4bb7771550cec00f7edd7afedfb30a",
|
||||
"version": 2
|
||||
"hash": "f2398ee0bbf5e76e796a986f89b2f56e518bcc8830997fec197fac563e20960b",
|
||||
"version": 3
|
||||
},
|
||||
"vertical/bygg-hantverk": {
|
||||
"hash": "9670e3515b607176efbdab05be8fc0d76f531ddb1fee8b2d70cbf78802ef571a",
|
||||
@@ -424,8 +424,8 @@
|
||||
"version": 1
|
||||
},
|
||||
"vertical/reklambyra-marknadsforing": {
|
||||
"hash": "6d67fd8ab8c1032168d072025241a0157d68f13f87b92be5e3a84e554265afee",
|
||||
"version": 1
|
||||
"hash": "4eb3a4770caa13d16d693e4ec92aa63c5097e223913a7adc08528051e41b72ee",
|
||||
"version": 2
|
||||
},
|
||||
"vertical/software-saas-ai": {
|
||||
"hash": "63fd0c3f1ddfde9978cd072ee5c76f89900acc5807d5085fa626e611b28a81cd",
|
||||
|
||||
@@ -160,6 +160,30 @@ async function main() {
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// Dangling references/ links (mcp_optimization_plan P2-2): an atom body
|
||||
// pointing at a references/ file that does not exist ships a 404 to every
|
||||
// agent that follows it. Fails BOTH modes so a dangling pointer can never
|
||||
// reach the registry. Top-level atoms only — reference children live inside
|
||||
// references/ themselves, so relative links would double-resolve.
|
||||
const refLinkRe = /(?:\]\(|\b)\.?\/?(references\/[A-Za-z0-9._/-]+\.md)/g
|
||||
const danglingRefs: string[] = []
|
||||
for (const atom of atoms) {
|
||||
if (atom.parent_atom_id) continue
|
||||
const baseDir = dirname(join(ROOT, atom.body_path))
|
||||
const seen = new Set<string>()
|
||||
for (const m of atom.body.matchAll(refLinkRe)) {
|
||||
const rel = m[1]
|
||||
if (seen.has(rel)) continue
|
||||
seen.add(rel)
|
||||
if (!existsSync(join(baseDir, rel))) danglingRefs.push(`${atom.id}: ${rel}`)
|
||||
}
|
||||
}
|
||||
if (danglingRefs.length > 0) {
|
||||
console.error('✗ dangling references/ link(s) in atom bodies — create the file or remove the pointer:')
|
||||
for (const d of danglingRefs) console.error(` ${d}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const manifest = loadManifest()
|
||||
const onDisk = new Map(atoms.map((a) => [a.id, sha256(a.body)]))
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user