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:
Jakob Wennberg
2026-07-03 11:48:29 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent b27f6cdb04
commit 678f2ccffd
12 changed files with 17731 additions and 46 deletions
+27 -10
View File
@@ -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,
}
},
},