diff --git a/DECISIONS.md b/DECISIONS.md index 15d56ac4..b7105dbd 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -46,9 +46,12 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-07-07] Reconciled ledger-context prereqs INTO dev_docs/bank_transaction_ai_normalization.md (§14): plan is the strategic superset; ledger-context RPC gets interim normalizeCounterpartyName() now, re-keys to entity_id at Phase 2/Layer F. Closed 4 gaps: RPC in Layer F substrate list, supplier-side digest patterns, storno/correction exclusion (§13+§14), pending_operations audit+FK for agent-suggestion attribution. [2026-07-08] Ledger-context prereq trifecta folded into the P1 branch pre-merge (normalize_counterparty_key SQL mirror of normalizeCounterpartyName + supplier_patterns CTE + storno filter + evidence{seen,agree,share,last_booked} format) instead of follow-up PRs: shipping first then fixing would break the payload shape consumers had just learned. Storno filter deliberately asymmetric: account_usage excludes source_type='storno' only; counterparty CTE has NO source_type filter because correctEntry() relinks transactions.journal_entry_id to the correction (the join self-heals) and excluding 'correction' would drop exactly the human-corrected booking. Faithful-mirror discipline: bare "KORT " prefix is NOT stripped (TS doesn't either); hardening the prefix list must change the TS+SQL pair together (pg test pins this). Payload caps trimmed 20/20 -> 15/15 + supplier 10 to hold the 12 KB budget with evidence objects. [2026-07-08] Ledger-context dominant-contra VAT bug, found by the switch-on check (calling gnubok_get_agent_briefing on real prod data, not synthetic tests): counterparty patterns for foreign SaaS (Google/ngrok/Supabase) showed dominant_account 2614 (reverse-charge output VAT) instead of 5420 (software expense). Cause: the dominant_account CTE excluded only 19xx, so on a reverse-charge booking (expense + 2645 + 2614 + 1930) the three non-bank accounts tie and the account_number ASC tiebreak picks the low VAT number 2614. Fix (migration 20260708110000): also exclude 26xx (always moms in BAS, never characterizes a counterparty); 23xx/24xx/25xx/27xx stay eligible so loan/tax counterparties (e.g. ALMI) still surface their real account. supplier_patterns unaffected (aggregates supplier_invoice_items.account_number = expense only). Regression pg test asserts 5420 over 2614; verified it fails on the old function. +[2026-07-08] Built the P2 "Vad din agent vet" page (/agent-knowledge, Analys nav group, icon Brain): the human render of the same ledger-context payload the AI agent reads. RSC calling buildLedgerContext(supabase, companyId) directly (one payload, two renderers, no new API/data path), sections mirror the payload 1:1 (coverage strip, counterparty patterns with monochrome confidence bars + seen/agree evidence, supplier patterns, explicit rules distinguished as instructions, account usage, VAT profile, conventions). Chose a STANDALONE page over a 3rd tab in /settings/assistant: faithful to the plan's page spec + its sales-demo intent (Fortnox shows your data, this shows your patterns). UNGATED (shows the user their own derived booking stats, no live AI compute, doubles as an upsell) rather than CAPABILITY.ai-gated: set requiredCapability on the nav item to paywall. VAT treatment labels + BAS account names stay Swedish in both locales (regulatory domain terms, per .claude/rules/i18n.md). Verified: build compiles the route (client/server boundary resolved), lint+tsc clean on new files, i18n sv/en parity (54 keys). Data correctness already proven live on prod via the briefing + RPC. +[2026-07-08] Deep entity-resolved ledger analysis + "Vad din agent vet" graph. (1) Fixed median_booking_lag_days: was median(entry_date - transaction date) = ~0 tautology (bank flow dates the voucher to the transaction; verified 151/152 entries entry_date==txn date on prod); switched to committed_at (real posting lag, median 90 for Arcim). migration 20260708120000, applied to prod. (2) New get_ledger_deep_context RPC (migration 20260708130000): full-history, deterministic, read-side deep analysis merging counterparties by normalize_counterparty_key (delivers "Claude = 14 bookings across 12 name variants, weekly, 9710 kr, always 5420"), mining booked verifikat for spend (sum abs bank amount), recurrence cadence (median gap of distinct dates), dominant account (19xx+26xx excluded) + share, supplier entities from supplier_invoices. Chose read-side compute-on-read (no new tables/backfill job/embeddings) as the pragmatic v1 over the full persistent counterparty_entity substrate (bank_transaction_ai_normalization.md) which stays deferred: name-key merge alone delivers the wow; cross-name merges needing the bank-account hard key or fuzzy/embeddings are the deferred layer. (3) Page reworked from tables to a radial-hub SVG graph (company center, accounts inner ring, payees outer ring, hover reveals variants/spend/cadence/account+share). Both RPCs applied to prod + version-matched; light get_ledger_usage_stats stays the agent digest, deep RPC feeds the page. Then restructured the page (hero graph + Kompetens/Minne/Regler tabs) and added a read-only Kompetens (atoms) + Fakta (memory) overview mirroring /settings/assistant. [2026-07-08] Bedrock prod outage + Docker build failure both root-caused to dependabot #884 (a1fad319, 2026-07-06) bumping @anthropic-ai/bedrock-sdk 0.29.1->0.32.0. Runtime: 0.32.0 streaming returns an empty event stream ("request ended without sending any chunks", no HTTP status) - proven NOT a creds/region issue (prod diagnostic logged AKIA key + eu-west-1). Two prior sessions mis-diagnosed it as an AWS_* env collision and shipped/reverted #937 (BEDROCK_AWS_* rename) with no effect. "Works locally, fails on prod/CI" because local node_modules was stale at 0.29.1 while prod/Docker build fresh from the lockfile (0.32.0). Fix: pin back to ^0.29.1 + regenerate lockfile. FOLLOW-UP: add a dependabot ignore/exact-pin so it does not re-bump to 0.32.x and re-break both. [2026-07-08] One reconciliation PR adopts 3 prod-orphaned migrations (20260707113729 enrichment + 20260708120000/130000 ledger-stats RPCs) plus their pg-tests/fixtures onto main, instead of waiting on #927+#935 to merge: prod ledger was 3 versions ahead of the repo, leaving the default Supabase branch MIGRATIONS_FAILED and blocking every preview branch from being created. SQL committed byte-identical under the exact apply-time versions -> no-op on prod (idempotent), clean on fresh replays, and a no-op on #927/#935's next rebase. Carries #935's DB layer only (migrations + pg-tests + fixtures), not its UI/lib/i18n. Root anti-pattern: all three applied to prod via MCP apply_migration without committing the file (CLAUDE.md "never leave the remote DB ahead of the repo"). [2026-07-08] Pinned @anthropic-ai/bedrock-sdk to exact 0.29.1 (dependabot #884 auto-bumped it to 0.32.0, which broke Bedrock streaming in prod: empty stream / "request ended without sending any chunks"). Guarded three ways against accidental re-bump: exact pin in package.json, dependabot ignore, and a pinned-dep check in scripts/checks/no-new-antipatterns.mjs (check:guards). Unpin only once 0.32.x streaming is verified against Bedrock. +[2026-07-09] "Vad din agent vet" hero reworked from the flat radial-hub map into "Reconciliation Aurora" (LedgerGraph.tsx), a cinematic dark SVG panel, on the founder's explicit "maximum wow" directive (a design judge-panel of 4 divergent concepts picked it over circle-packing / flow-pulses / living-constellation). Encodings: node area = √spend, colour = cadence (weekly/monthly/irregular, the one semantic axis), shape = supplier vs counterparty, confidence = optical depth-of-field (crisp = sure, blurred = unsure). Signature on-mount act: the top ~5 name-merged payees' raw bank descriptors scatter in and collapse into one node with a counting-up ×N badge. Two deliberate calls: (a) a self-contained ALWAYS-DARK panel that bends design.md's flat/achromatic-surface rule (justified: it is a demo hero, self-contained, premium-not-neon, and DOF/glow only read on dark); (b) ZERO new deps: rejected d3-hierarchy (circle-pack) and d3-force (constellation) because a fixed 3-level tree is ~40 lines of deterministic polar trig and force physics would reshuffle the layout every load, breaking the repeatable live demo; framer-motion@12 (already installed) drives the entrance/merge, CSS keyframes drive idle pulse/breathe (single clock, no 40 JS loops), and confidence buckets into 3-4 shared SVG blur filters attached only after each node's entrance spring settles (avoids per-frame feGaussianBlur re-raster). Adversarial 3-lens review run; 3 low findings fixed (StrictMode CountUp freeze, filter-on-animating-subtree, center-out inverse permutation). [2026-07-09] Invoice online payment ships as a manual paste-link MVP (invoices.payment_link_url + email button + PDF QR/link) instead of a full Stripe Connect integration: a day of work as a demand probe vs a week for Connect (OAuth onboarding, pay page, webhook auto-booking to 1686). Same column/UI is the upgrade path: Connect would auto-fill payment_link_url later, so nothing is throwaway. Field is PSP-agnostic ("Betalningslänk", any https URL) since the effort is identical and it also covers PayPal/Zettle. Derived documents (credit note, proforma convert, recurring) deliberately do NOT copy the link: a pasted link encodes one amount for one invoice. MCP tools/list token ceiling bumped 45K->45.5K (headroom was <10 tokens; ledger entry in payload-size.bench.test.ts). [2026-07-09] Issue #916 (disconnect orphans ledger accounts): release claims by demoting cash_accounts rows to manual (bank_connection_id = null), never deleting: transactions.cash_account_id and ledger history reference the rows, and upsertFromPsd2 promotes a manual holder in place on reconnect so the bank lands back on its original BAS slot. Orphans predating the fix self-heal via a revoked-status filter in the allocator + collision guard (not data repair). When a promote collides with a duplicate row for the same connection+uid (callback mirrored onto an overflow slot pre-fix), the duplicate is deleted only if it has zero linked transactions, otherwise demoted: preserves FK links while freeing the slot. Picker-save rejections now render inline in the picker instead of routing to the sync-progress modal, whose parent-unmount-on-close made every save outcome invisible. [2026-07-09] #917 fix scoped to the current-year suggestion: "Sedan räkenskapsårets början" now resolves from the fiscal_periods row containing today, but the "Föregående räkenskapsårets start" custom option still derives from the recurring fiscal_year_start_month: the issue only covers the current-year date and a first-year company has no previous period row to resolve against. diff --git a/app/(dashboard)/agent-knowledge/page.tsx b/app/(dashboard)/agent-knowledge/page.tsx new file mode 100644 index 00000000..7cea56bc --- /dev/null +++ b/app/(dashboard)/agent-knowledge/page.tsx @@ -0,0 +1,50 @@ +import { redirect } from 'next/navigation' +import { getTranslations } from 'next-intl/server' +import { createClient } from '@/lib/supabase/server' +import { getActiveCompanyId, getCompanyDisplayName } from '@/lib/company/context' +import { buildLedgerContext } from '@/lib/agent-context/ledger-context' +import { buildDeepEntities } from '@/lib/agent-context/ledger-deep' +import { buildAgentCompetence } from '@/lib/agent-context/agent-competence' +import { PageHeader } from '@/components/ui/page-header' +import { AgentKnowledgeView } from '@/components/agent-knowledge/AgentKnowledgeView' + +// Derived per request from live bookings; never cache a stale profile. +export const dynamic = 'force-dynamic' + +/** + * "Vad din agent vet" (P2): the human-facing render of the same ledger-context + * the AI agent reads before booking. Fetches server-side via the shared + * lib/agent-context aggregation (one payload, two renderers) and shows it as a + * readable profile of how this company books. Read-only, no interactive + * controls, so a plain Server Component. + */ +export default async function AgentKnowledgePage() { + const supabase = await createClient() + const { + data: { user }, + } = await supabase.auth.getUser() + if (!user) redirect('/login') + + const companyId = await getActiveCompanyId(supabase, user.id) + if (!companyId) redirect('/onboarding') + + const [t, context, deep, competence, companyName] = await Promise.all([ + getTranslations('agentKnowledge'), + buildLedgerContext(supabase, companyId), + buildDeepEntities(supabase, companyId), + buildAgentCompetence(supabase, companyId), + getCompanyDisplayName(supabase, companyId), + ]) + + return ( +
+ + +
+ ) +} diff --git a/components/agent-knowledge/AgentCompetenceSections.tsx b/components/agent-knowledge/AgentCompetenceSections.tsx new file mode 100644 index 00000000..c6245b77 --- /dev/null +++ b/components/agent-knowledge/AgentCompetenceSections.tsx @@ -0,0 +1,128 @@ +import { getTranslations } from 'next-intl/server' +import Link from 'next/link' +import { Pin, ArrowUpRight } from 'lucide-react' +import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card' +import { Badge } from '@/components/ui/badge' +import type { AgentCompetence, AtomTier, FactKind, FactSource } from '@/lib/agent-context/agent-competence' + +/** + * Read-only views of the agent's competence (domain-knowledge atoms) and top + * learned facts, for the "Vad din agent vet" overview. Each is a standalone + * Card so it can sit in its own tab. Full editable management lives in + * /settings/assistant; each links there. + */ + +const TIER_ORDER: AtomTier[] = ['horizontal', 'vertical', 'modifier'] + +export async function CompetenceCard({ competence }: { competence: AgentCompetence }) { + const t = await getTranslations('agentKnowledge') + const { atoms } = competence + const activeAtoms = atoms.filter((a) => a.active).length + const tierLabel = (tier: AtomTier) => + tier === 'horizontal' ? t('tier_horizontal') : tier === 'vertical' ? t('tier_vertical') : t('tier_modifier') + + return ( + + + {t('comp_title')} + {t('comp_desc')} + + + {atoms.length === 0 ? ( +

{t('comp_empty')}

+ ) : ( + <> + {TIER_ORDER.map((tier) => { + const items = atoms.filter((a) => a.tier === tier) + if (items.length === 0) return null + return ( +
+

+ {tierLabel(tier)} +

+
+ {items.map((a) => ( + + {a.title} + {!a.active && tier !== 'horizontal' && ( + · {t('badge_dormant')} + )} + + ))} +
+
+ ) + })} +
+ {t('comp_count', { total: atoms.length, active: activeAtoms })} + +
+ + )} +
+
+ ) +} + +export async function FactsCard({ competence }: { competence: AgentCompetence }) { + const t = await getTranslations('agentKnowledge') + const { facts, factsActiveTotal } = competence + const kindLabel = (k: FactKind) => + k === 'fact' ? t('kind_fact') : k === 'preference' ? t('kind_preference') : k === 'pattern' ? t('kind_pattern') : t('kind_correction') + const sourceLabel = (s: FactSource) => + s === 'composer' ? t('source_composer') : s === 'user_taught' ? t('source_user_taught') : s === 'agent_learned' ? t('source_agent_learned') : t('source_derived') + + return ( + + + {t('facts_title')} + {t('facts_desc')} + + + {facts.length === 0 ? ( +

{t('facts_empty')}

+ ) : ( + <> +
    + {facts.map((f) => ( +
  • + {f.is_pinned ? ( + + ) : ( + + )} +
    +

    {f.content}

    +

    + {kindLabel(f.kind)} · {sourceLabel(f.source)} +

    +
    +
  • + ))} +
+
+ + {factsActiveTotal > facts.length ? t('facts_more', { n: factsActiveTotal - facts.length }) : ''} + + +
+ + )} +
+
+ ) +} + +function ManageLink({ href, label }: { href: string; label: string }) { + return ( + + {label} + + + ) +} diff --git a/components/agent-knowledge/AgentKnowledgeView.tsx b/components/agent-knowledge/AgentKnowledgeView.tsx new file mode 100644 index 00000000..7c3dd8d1 --- /dev/null +++ b/components/agent-knowledge/AgentKnowledgeView.tsx @@ -0,0 +1,247 @@ +import { getTranslations } from 'next-intl/server' +import { Brain } from 'lucide-react' +import { + Card, + CardHeader, + CardTitle, + CardDescription, + CardContent, +} from '@/components/ui/card' +import { Badge } from '@/components/ui/badge' +import { + Table, + TableHeader, + TableBody, + TableHead, + TableRow, + TableCell, +} from '@/components/ui/table' +import { AccountNumber } from '@/components/ui/account-number' +import { EmptyState } from '@/components/ui/empty-state' +import { formatDateLong } from '@/lib/utils' +import type { LedgerContext } from '@/lib/agent-context/ledger-context' +import type { DeepLedgerContext } from '@/lib/agent-context/ledger-deep' +import type { AgentCompetence } from '@/lib/agent-context/agent-competence' +import { LedgerGraph } from './LedgerGraph' +import { CompetenceCard, FactsCard } from './AgentCompetenceSections' +import { KnowledgeTabs } from './KnowledgeTabs' + +// Swedish VAT (moms) treatment codes stay Swedish in both locales, like BAS +// account names and momsdeklaration labels (.claude/rules/i18n.md). +const VAT_LABELS: Record = { + standard_25: 'Moms 25%', + standard_12: 'Moms 12%', + standard_6: 'Moms 6%', + reduced_12: 'Moms 12%', + reduced_6: 'Moms 6%', + reverse_charge: 'Omvänd moms', + reverse_charge_eu: 'Omvänd moms (EU)', + reverse_charge_services: 'Omvänd moms (tjänster)', + eu_reverse_charge_services: 'Omvänd moms (EU-tjänster)', + eu_goods: 'EU-varor', + export: 'Export', + exempt: 'Momsfri', + no_vat: 'Ingen moms', +} + +function vatLabel(code: string | null): string | null { + if (!code) return null + return VAT_LABELS[code] ?? code +} + +export async function AgentKnowledgeView({ + context, + deep, + competence, + companyName, +}: { + context: LedgerContext + deep: DeepLedgerContext + competence: AgentCompetence + companyName: string +}) { + const t = await getTranslations('agentKnowledge') + + const { meta, explicit_rules, vat_profile, conventions } = context + + const entities = [...deep.counterparty_entities, ...deep.supplier_entities] + + const isEmpty = meta.coverage.posted_entries_window === 0 && entities.length === 0 && explicit_rules.length === 0 + + if (isEmpty) { + // No bookings yet, but the agent still ships with competence and may + // already remember facts: show those rather than a dead end. + return ( + <> + + + + + +
+ + +
+ + ) + } + + const methodLabel = + conventions.accounting_method === 'accrual' + ? t('method_accrual') + : conventions.accounting_method === 'cash' + ? t('method_cash') + : t('method_unknown') + + const periodLabel = + vat_profile.moms_period === 'monthly' + ? t('period_monthly') + : vat_profile.moms_period === 'quarterly' + ? t('period_quarterly') + : vat_profile.moms_period === 'yearly' + ? t('period_yearly') + : (vat_profile.moms_period ?? t('unknown')) + + // "Regler & profil" tab: user-authored rules + observed VAT + conventions. + const configContent = ( + <> + {explicit_rules.length > 0 && ( + + + {t('rules_title')} + {t('rules_description')} + + + + + + {t('col_rule')} + {t('col_match')} + {t('col_account')} + {t('col_vat')} + + + + {explicit_rules.map((r, i) => ( + + +
+ {r.rule_name} + {t('src_rule')} +
+
+ {r.match} + + {r.account_number + ? + : -} + + {vatLabel(r.vat_treatment) ?? '-'} +
+ ))} +
+
+
+
+ )} + +
+ + + {t('vat_title')} + {t('vat_description')} + + + + + {vat_profile.registered ? t('yes') : t('no')} + + + + {periodLabel} + + + {vat_profile.treatments_used_12m.length === 0 ? ( + {t('vat_no_treatments')} + ) : ( +
+ {vat_profile.treatments_used_12m.map((code) => ( + {vatLabel(code)} + ))} +
+ )} +
+
+
+ + + + {t('conv_title')} + {t('conv_description')} + + + + {methodLabel} + + + {conventions.voucher_series_in_use.length === 0 ? ( + - + ) : ( +
+ {conventions.voucher_series_in_use.map((s) => ( + {s} + ))} +
+ )} +
+ + + {conventions.salary_run_active ? t('yes') : t('no')} + + + {conventions.typical_booking_lag_days !== null && ( + + {t('meta_lag_value', { days: conventions.typical_booking_lag_days })} + + )} +
+
+
+ + ) + + const tabs = [ + { value: 'competence', label: t('tab_competence'), content: }, + { value: 'memory', label: t('tab_memory'), content: }, + { value: 'config', label: t('tab_config'), content: configContent }, + ] + + return ( + <> + {/* The cinematic hero: a self-contained dark panel with its own header */} + + + {/* Supporting detail, tabbed so it doesn't stack into a long scroll */} + + +

+ {t('footer_basis', { entries: meta.coverage.posted_entries_window, date: formatDateLong(meta.computed_at) })} +

+ + ) +} + +function Row({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+ {label} +
{children}
+
+ ) +} diff --git a/components/agent-knowledge/KnowledgeTabs.tsx b/components/agent-knowledge/KnowledgeTabs.tsx new file mode 100644 index 00000000..541d4ea3 --- /dev/null +++ b/components/agent-knowledge/KnowledgeTabs.tsx @@ -0,0 +1,33 @@ +'use client' + +import type { ReactNode } from 'react' +import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs' + +/** + * Client wrapper that organizes the page's supporting detail into tabs so the + * booking map stays the hero and the rest doesn't stack into a long scroll. + * Content is server-rendered upstream and handed in as ReactNode slots. + */ +export function KnowledgeTabs({ + tabs, +}: { + tabs: { value: string; label: string; content: ReactNode }[] +}) { + if (tabs.length === 0) return null + return ( + + + {tabs.map((t) => ( + + {t.label} + + ))} + + {tabs.map((t) => ( + + {t.content} + + ))} + + ) +} diff --git a/components/agent-knowledge/LedgerGraph.tsx b/components/agent-knowledge/LedgerGraph.tsx new file mode 100644 index 00000000..b6c26809 --- /dev/null +++ b/components/agent-knowledge/LedgerGraph.tsx @@ -0,0 +1,847 @@ +'use client' + +import { useEffect, useMemo, useState } from 'react' +import { motion, AnimatePresence, useReducedMotion, animate } from 'framer-motion' +import { RotateCw } from 'lucide-react' +import { getAccountDescription } from '@/lib/bookkeeping/account-descriptions' +import { useTranslations } from 'next-intl' +import { formatCurrency } from '@/lib/utils' +import type { DeepEntity, DeepLedgerContext } from '@/lib/agent-context/ledger-deep' + +/** + * "Reconciliation Aurora" - the cinematic hero of the "Vad din agent vet" page. + * + * A self-contained dark SVG panel. The company is a luminous hub; the BAS + * accounts it books to form an inner ring of wedges (each wedge sized by the + * money that flows through it); the counterparties/suppliers live in the outer + * band, each constrained to its dominant account's angular slice so threads can + * never cross between accounts (the anti-hairball guarantee). + * + * Four orthogonal channels, so no two compete: + * - node AREA = total spend (radius = k·√kr) + * - node COLOUR = booking cadence (weekly / monthly / irregular) - the one + * semantic axis; everything else stays achromatic + * - node SHAPE = kind (supplier = filled, counterparty = open ring) + * - node FOCUS = the agent's confidence in the account mapping, rendered as + * optical depth of field (sure = crisp/forward, unsure = soft) + * + * On mount it performs the signature act: for the most-merged payees, the raw + * bank descriptors ("CLAUDE.AI", "Anthropic PBC", "claude.ai*sub") fly in as + * ghost chips and magnetically collapse into one named node with a "×N" badge - + * the agent resolving chaos into knowledge, live, in front of the customer. + * + * Deterministic (seeded jitter, fixed input order) so the demo looks identical + * on every load. Motion is CSS-driven (single-clock idle, GPU dash pulses) with + * framer only orchestrating the entrance, the merge and the hover card. Fully + * keyboard-navigable; a visually-hidden table carries the payload for readers; + * honours prefers-reduced-motion (jumps straight to the settled state). + */ + +const W = 1000 +const H = 1000 +const CX = W / 2 +const CY = H / 2 +const R_HUB = 34 +const R_ACCOUNT = 178 +const R_PAYEE_MIN = 258 +const R_PAYEE_MAX = 432 +const MAX_ACCOUNTS = 9 +const MAX_PER_ACCOUNT = 6 +const WEDGE_GAP = 0.07 // radians of padding between account wedges + +// This panel is its own dark world regardless of the app theme, so the depth of +// field, the glow and the cadence hues all read. Achromatic chrome; colour only +// ever means cadence. +const INK = '#0a0a0c' +const PAPER = '#ecebe6' +const HAIR = 'rgba(236,235,230,0.13)' +const HAIR_STRONG = 'rgba(236,235,230,0.30)' +const MUTED = 'rgba(236,235,230,0.52)' +const CAD: Record = { + weekly: '#e0895f', // terracotta - fast, recurring + monthly: '#d7a648', // ochre - monthly + irregular: '#83a98d', // sage - one-off / irregular +} +// Depth-of-field buckets: stdDeviation in viewBox units (~0.64× on screen). +const DOF = [0, 1.7, 3.4, 5.2] + +type Cadence = 'weekly' | 'monthly' | 'irregular' + +interface Payee { + id: string + entity: DeepEntity + accountNumber: string + x: number + y: number + r: number + cadence: Cadence + bucket: number // depth-of-field bucket index into DOF + thread: string // svg path from hub to node, bowed through the account anchor + labelRight: boolean + revealDelay: number + merge: boolean // show the ghost-descriptor collapse on mount + chips: string[] +} +interface Account { + number: string + name: string | null + x: number + y: number + midAngle: number + arc: string + revealDelay: number +} +interface Model { + accounts: Account[] + payees: Payee[] + truncated: boolean + totals: { tx: number; payees: number; accounts: number } +} + +function polar(cx: number, cy: number, r: number, a: number) { + return { x: cx + r * Math.cos(a), y: cy + r * Math.sin(a) } +} +function arcPath(cx: number, cy: number, r: number, a0: number, a1: number) { + const s = polar(cx, cy, r, a0) + const e = polar(cx, cy, r, a1) + const large = a1 - a0 > Math.PI ? 1 : 0 + return `M ${s.x.toFixed(2)} ${s.y.toFixed(2)} A ${r} ${r} 0 ${large} 1 ${e.x.toFixed(2)} ${e.y.toFixed(2)}` +} +function lerp(a: number, b: number, t: number) { + return a + (b - a) * t +} +// Deterministic [0,1) hash of a string (xmur3 → mulberry32), so seeded jitter is +// byte-identical on every render: the live demo never reshuffles. +function rand01(seed: string): number { + let h = 1779033703 ^ seed.length + for (let i = 0; i < seed.length; i++) { + h = Math.imul(h ^ seed.charCodeAt(i), 3432918353) + h = (h << 13) | (h >>> 19) + } + h = Math.imul(h ^ (h >>> 16), 2246822507) + h = Math.imul(h ^ (h >>> 13), 3266489909) + h ^= h >>> 16 + return (h >>> 0) / 4294967296 +} + +function cadenceOf(e: DeepEntity): Cadence { + const cd = e.cadence_days + if (cd === null || e.occurrences < 3) return 'irregular' + if (cd >= 4 && cd <= 10) return 'weekly' + if (cd > 10 && cd <= 45) return 'monthly' + return 'irregular' +} +function bucketOf(share: number | null): number { + if (share === null) return 3 + if (share >= 0.85) return 0 + if (share >= 0.7) return 1 + if (share >= 0.5) return 2 + return 3 +} +// Center-out slot order: biggest spender sits at the wedge's angular centre. +function centerOut(n: number): number[] { + const mid = (n - 1) / 2 + return Array.from({ length: n }, (_, i) => i).sort( + (a, b) => Math.abs(a - mid) - Math.abs(b - mid), + ) +} + +function buildModel(deep: DeepLedgerContext): Model { + const all: DeepEntity[] = [ + ...(deep?.counterparty_entities ?? []), + ...(deep?.supplier_entities ?? []), + ].filter((e) => e.dominant_account_number) + + const totalTx = all.reduce((s, e) => s + e.occurrences, 0) + + const byAccount = new Map() + for (const e of all) { + const acc = e.dominant_account_number as string + const arr = byAccount.get(acc) ?? [] + arr.push(e) + byAccount.set(acc, arr) + } + + // Weight a wedge by the money that flows through it (fall back to volume). + const activity = (items: DeepEntity[]) => { + const spend = items.reduce((s, i) => s + Math.max(i.total_amount, 0), 0) + return spend > 0 ? spend : items.reduce((s, i) => s + i.occurrences, 0) + } + + let groups = [...byAccount.entries()].map(([number, items]) => ({ + number, + items: items.slice().sort((a, b) => b.total_amount - a.total_amount), + weight: activity(items), + })) + // Rank by weight to pick what to show, then lay out in a fixed order (account + // number) so wedges never swap places between loads. + groups.sort((a, b) => b.weight - a.weight) + const totalAccounts = groups.length + groups = groups.slice(0, MAX_ACCOUNTS) + let truncated = totalAccounts > groups.length + for (const g of groups) { + if (g.items.length > MAX_PER_ACCOUNT) { + truncated = true + g.items = g.items.slice(0, MAX_PER_ACCOUNT) + } + } + groups.sort((a, b) => a.number.localeCompare(b.number)) + + const shownWeight = groups.reduce((s, g) => s + g.weight, 0) || 1 + const maxSpend = Math.max(...groups.flatMap((g) => g.items.map((i) => i.total_amount)), 1) + + // The most name-merged payees earn the on-mount "descriptors collapse" moment. + const mergeIds = new Set( + all + .filter((e) => e.variant_count >= 3) + .sort((a, b) => b.variant_count - a.variant_count) + .slice(0, 5) + .map((e) => `${e.dominant_account_number}:${e.key}`), + ) + + const accounts: Account[] = [] + const payees: Payee[] = [] + + const spans = 2 * Math.PI - WEDGE_GAP * groups.length + let angle = -Math.PI / 2 + WEDGE_GAP / 2 + groups.forEach((g, gi) => { + const width = spans * (g.weight / shownWeight) + const mid = angle + width / 2 + const anchor = polar(CX, CY, R_ACCOUNT, mid) + + accounts.push({ + number: g.number, + name: getAccountDescription(g.number)?.name ?? null, + x: anchor.x, + y: anchor.y, + midAngle: mid, + arc: arcPath(CX, CY, R_ACCOUNT, angle + width * 0.04, angle + width * 0.96), + revealDelay: 0.2 + gi * 0.06, + }) + + const n = g.items.length + const inner = width * 0.16 + const order = centerOut(n) // order[rank] = slot for that rank (rank 0 = biggest → centre) + g.items.forEach((e, rank) => { + const slot = order[rank] + const t = n === 1 ? 0.5 : slot / (n - 1) + const pa = angle + inner + t * (width - 2 * inner) + const spendFrac = Math.sqrt(Math.max(e.total_amount, 0) / maxSpend) + const radius = lerp(R_PAYEE_MIN, R_PAYEE_MAX, rand01(e.key)) + spendFrac * 14 + const pos = polar(CX, CY, Math.min(radius, R_PAYEE_MAX + 10), pa) + const id = `${g.number}:${e.key}` + payees.push({ + id, + entity: e, + accountNumber: g.number, + x: pos.x, + y: pos.y, + r: 7 + 19 * spendFrac, + cadence: cadenceOf(e), + bucket: bucketOf(e.dominant_account_share), + thread: `M ${CX} ${CY} Q ${anchor.x.toFixed(2)} ${anchor.y.toFixed(2)} ${pos.x.toFixed(2)} ${pos.y.toFixed(2)}`, + labelRight: Math.cos(pa) >= 0, + revealDelay: 0.55 + gi * 0.05 + rank * 0.045, + merge: mergeIds.has(id), + chips: e.variants.slice(0, 6), + }) + angle += 0 + }) + angle += width + WEDGE_GAP + }) + + return { + accounts, + payees, + truncated, + totals: { tx: totalTx, payees: payees.length, accounts: accounts.length }, + } +} + +// Weekly beats faster than monthly; irregular drifts slow. Seconds per pulse. +const PULSE_DUR: Record = { weekly: 1.15, monthly: 2.7, irregular: 4.3 } + +export function LedgerGraph({ deep, companyName }: { deep: DeepLedgerContext; companyName: string }) { + const t = useTranslations('agentKnowledge') + const reduce = useReducedMotion() ?? false + const model = useMemo(() => buildModel(deep), [deep]) + + const [hover, setHover] = useState(null) + const [runKey, setRunKey] = useState(0) + // Track which run has finished its intro rather than a bare boolean, so a + // replay (runKey++) resets to "unresolved" by derivation, without a + // setState-in-effect. Chips fly in, then collapse into their node. + const [resolvedRun, setResolvedRun] = useState(-1) + useEffect(() => { + const id = setTimeout(() => setResolvedRun(runKey), reduce ? 0 : 1300) + return () => clearTimeout(id) + }, [runKey, reduce]) + const resolved = reduce || resolvedRun === runKey + + const payeeById = useMemo(() => new Map(model.payees.map((p) => [p.id, p])), [model]) + + function payeeCaption(p: Payee): string { + const e = p.entity + const nm = getAccountDescription(e.dominant_account_number ?? '')?.name + return [ + e.name, + t('cap_bookings', { n: e.occurrences }), + e.variant_count > 1 ? t('cap_variants', { n: e.variant_count }) : null, + t(`cadence_${p.cadence}`), + formatCurrency(e.total_amount), + `${e.dominant_account_number}${nm ? ` ${nm}` : ''}${ + e.dominant_account_share !== null ? ` · ${Math.round(e.dominant_account_share * 100)}%` : '' + }`, + ] + .filter(Boolean) + .join(' · ') + } + + if (model.payees.length === 0) { + return ( +
+ {t('none_cp')} +
+ ) + } + + // Which ids are "lit" given the current hover (a payee lights its account and + // the reverse); everything else recedes into the depth of field. + const active = new Set() + if (hover) { + active.add(hover) + if (hover.startsWith('acc:')) { + const num = hover.slice(4) + model.payees.forEach((p) => p.accountNumber === num && active.add(p.id)) + } else { + const p = payeeById.get(hover) + if (p) active.add(`acc:${p.accountNumber}`) + } + } + const lit = (id: string) => !hover || active.has(id) + + const hoveredPayee = hover && !hover.startsWith('acc:') ? payeeById.get(hover) ?? null : null + + return ( +
+ + + {/* header */} +
+
+

+ {t('graph_title')} +

+

+ {t('graph_description')} +

+
+ {!reduce && ( + + )} +
+ + {/* stage */} +
+ + + {DOF.map((sd, i) => ( + + + + ))} + + + + + + + + {/* account wedge arcs (the spine) */} + {model.accounts.map((a) => ( + + + + + ))} + + {/* threads: base vein + travelling cadence pulse */} + {model.payees.map((p) => ( + + + + + ))} + + {/* payee nodes, blurry buckets first so the confident ones sit on top */} + {[...model.payees] + .sort((a, b) => b.bucket - a.bucket) + .map((p) => ( + + ))} + + {/* company hub */} + + + + + {initialsOf(companyName)} + + + + + + {/* tally: the "understood" count-up */} +
+ + + {' '} + {t('graph_tally', { + payees: model.totals.payees, + accounts: model.totals.accounts, + })} +
+ + {/* hover detail card, anchored to the node */} + + {hoveredPayee && ( + + )} + +
+ + {/* legend */} +
+ + {t('cadence_weekly')} + + + {t('cadence_monthly')} + + + {t('cadence_irregular')} + + {t('legend_size')} + {t('legend_focus')} + {model.truncated && {t('graph_truncated')}} +
+ + {/* screen-reader alternative: the full payload as a plain list */} +
    + {model.payees.map((p) => ( +
  • {payeeCaption(p)}
  • + ))} +
+
+ ) +} + +function accountCaption(a: Account): string { + return `${a.number}${a.name ? ` · ${a.name}` : ''}` +} + +// Enlarged invisible hit target + keyboard focus for an account arc. +function AccountHit({ + account, + caption, + onHover, + lit, +}: { + account: Account + caption: string + onHover: (id: string | null) => void + lit: boolean +}) { + const id = `acc:${account.number}` + const inside = polar(CX, CY, R_ACCOUNT - 20, account.midAngle) + return ( + onHover(id)} + onMouseLeave={() => onHover(null)} + onFocus={() => onHover(id)} + onBlur={() => onHover(null)} + className="cursor-pointer outline-none [&:focus-visible>circle]:opacity-100" + > + {caption} + + + + {account.number} + + + ) +} + +function PayeeGlyph({ + p, + reduce, + resolved, + lit, + focused, + caption, + onHover, +}: { + p: Payee + reduce: boolean + resolved: boolean + lit: boolean + focused: boolean + caption: string + onHover: (id: string | null) => void +}) { + const colour = CAD[p.cadence] + const showChips = !reduce && p.merge && !resolved + // The depth-of-field blur only attaches once the entrance spring settles, so + // feGaussianBlur never re-rasterizes per frame while the node is moving (and + // the blur "racking in" as the node comes to rest is the intended focus pull). + const [settled, setSettled] = useState(reduce) + // Only the biggest spenders keep a resting label; the rest reveal on focus. + const bigLabel = p.r >= 14 + const label = p.entity.name.length > 16 ? p.entity.name.slice(0, 15) + '…' : p.entity.name + const lx = p.labelRight ? p.r + 8 : -(p.r + 8) + + return ( + // Positioning lives on a plain (SVG transform attribute) so it can never + // be clobbered by framer's CSS transform on the scaling child below. Hover + // "racks focus" onto a node by dropping it to the crisp filter bucket. + onHover(p.id)} + onMouseLeave={() => onHover(null)} + onFocus={() => onHover(p.id)} + onBlur={() => onHover(null)} + className="cursor-pointer outline-none" + > + {caption} + {/* always-full-size transparent hit target */} + + + {/* opacity layer: entrance fade + hover dimming. Animates opacity only, so + framer never sets a CSS transform that would fight the attribute. */} + + {/* the money shot: raw bank descriptors that collapse inward. Kept OUT + of the scaling group so they show at full size during the scatter. */} + + {showChips && + p.chips.map((chip, i) => { + const ang = rand01(p.id + chip) * Math.PI * 2 + const rad = 34 + rand01(chip + String(i)) * 52 + const ox = Math.cos(ang) * rad + const oy = Math.sin(ang) * rad + return ( + + {chip.length > 18 ? chip.slice(0, 17) + '…' : chip} + + ) + })} + + + {/* scale layer: the node grows in around its centre (fill-box) */} + setSettled(true)} + transition={ + reduce + ? { duration: 0 } + : { type: 'spring', stiffness: 150, damping: 17, delay: p.merge ? 1.32 : p.revealDelay } + } + style={{ transformBox: 'fill-box', transformOrigin: 'center' }} + > + {/* variant echo ring: faint concentric hint that this node is a merge */} + {p.entity.variant_count > 2 && ( + + )} + {focused && } + + {/* the glyph: supplier = filled, counterparty = open */} + + + {/* "×N" merge badge */} + {p.entity.variant_count > 1 && (resolved || reduce) && ( + + + + {p.merge ? ( + + ) : ( + `×${p.entity.variant_count}` + )} + + + )} + + + {bigLabel && ( + + {label} + + )} + + + ) +} + +function DetailCard({ p, t }: { p: Payee; t: ReturnType }) { + const e = p.entity + const nm = getAccountDescription(e.dominant_account_number ?? '')?.name + const share = e.dominant_account_share !== null ? Math.round(e.dominant_account_share * 100) : null + // Anchor to the node: viewBox coords → % of the square stage. Flip sides so it + // never spills off the edge. + const left = (p.x / W) * 100 + const top = (p.y / H) * 100 + const right = p.x < CX + return ( + +
+ + {e.name} +
+
+
+ {t('cap_bookings', { n: e.occurrences })} + {t(`cadence_${p.cadence}`)} +
+ {e.variant_count > 1 && ( +
+ {t('card_variants')} + ×{e.variant_count} +
+ )} +
+ {t('legend_size')} + {formatCurrency(e.total_amount)} +
+
+
+ + {e.dominant_account_number} + {nm ? ` ${nm}` : ''} + + {share !== null && {t('card_confidence', { share })}} +
+ {share !== null && ( +
+
+
+ )} + {e.dominant_account_count != null && e.dominant_account_total != null && ( +
+ {t('card_evidence', { k: e.dominant_account_count, n: e.dominant_account_total })} +
+ )} +
+ {e.variants.length > 1 && ( +
+ {e.variants.slice(0, 4).join(' · ')} + {e.variant_count > 4 ? ' …' : ''} +
+ )} +
+ + ) +} + +function CountUp({ + target, + run, + reduce, + prefix = '', + duration = 1, +}: { + target: number + run: number + reduce: boolean + prefix?: string + duration?: number +}) { + const [v, setV] = useState(0) + // Re-run whenever `run` bumps (mount / replay / resolve). No ref-guard: it + // would early-return on React StrictMode's second effect setup in dev and + // freeze the number at 0 (the cleanup already stops any prior animation). + useEffect(() => { + if (reduce) return + const controls = animate(0, target, { + duration, + ease: 'easeOut', + onUpdate: (x) => setV(Math.round(x)), + }) + return () => controls.stop() + }, [target, run, reduce, duration]) + const shown = reduce ? target : v + return <>{prefix}{shown.toLocaleString('sv-SE')} +} + +function Dot({ fill }: { fill: string }) { + return +} + +function initialsOf(name: string): string { + return ( + name + .split(/\s+/) + .filter(Boolean) + .slice(0, 2) + .map((w) => w[0]?.toUpperCase() ?? '') + .join('') || '•' + ) +} + +// One clock for all continuous life: GPU-friendly CSS keyframes, frozen for +// prefers-reduced-motion users. +const keyframes = ` +@keyframes aurora-pulse { to { stroke-dashoffset: -1; } } +@keyframes aurora-breathe { 0%,100% { transform: scale(1); } 50% { transform: scale(1.03); } } +.aurora-pulse { animation-name: aurora-pulse; animation-timing-function: linear; animation-iteration-count: infinite; } +.aurora-breathe { animation: aurora-breathe 5.5s ease-in-out infinite; } +@media (prefers-reduced-motion: reduce) { + .aurora-pulse, .aurora-breathe { animation: none !important; } +} +` diff --git a/components/dashboard/DashboardNav.tsx b/components/dashboard/DashboardNav.tsx index b16a4eb3..af3a11ef 100644 --- a/components/dashboard/DashboardNav.tsx +++ b/components/dashboard/DashboardNav.tsx @@ -40,6 +40,7 @@ import { CalendarClock, CalendarRange, FileCheck, + Brain, FileSpreadsheet, ScrollText, } from 'lucide-react' @@ -98,6 +99,7 @@ type NavLabelKey = | 'dashboard' | 'home' | 'assistant' + | 'agent_knowledge' | 'kpi' | 'invoice_inbox' | 'invoices' @@ -189,6 +191,9 @@ const navItems: NavItem[] = [ // Rapporter surface (nav_ia_redesign §F) is built. { href: '/kpi', labelKey: 'kpi', icon: TrendingUp, group: 'analys' }, { href: '/reports', labelKey: 'reports', icon: BarChart3, group: 'analys' }, + // "Vad din agent vet": read-only profile of how this company books, the + // human render of the agent's ledger-context (dev_docs/ledger_context_resource.md). + { href: '/agent-knowledge', labelKey: 'agent_knowledge', icon: Brain, group: 'analys' }, // Data: master-data registers + data plumbing. Anställda is a register // (you edit an employee rarely, you run payroll monthly), so it lives here // while the Löner flow stays in Arbeta. diff --git a/lib/agent-context/agent-competence.ts b/lib/agent-context/agent-competence.ts new file mode 100644 index 00000000..2d7efd5b --- /dev/null +++ b/lib/agent-context/agent-competence.ts @@ -0,0 +1,99 @@ +import type { SupabaseClient } from '@supabase/supabase-js' + +// Server-side read of the agent's competence (the domain-knowledge "atoms" it +// ships with) and its top learned facts (memory), for the read-only overview +// on the "Vad din agent vet" page. Mirrors GET /api/agent/skills and +// GET /api/agent/memory so the two surfaces stay consistent; the full editable +// management lives in /settings/assistant. + +export type AtomTier = 'horizontal' | 'vertical' | 'modifier' + +export interface AgentAtom { + id: string + tier: AtomTier + title: string + description: string + /** horizontal atoms apply to every company; vertical/modifier only when the + * composer selected them into this company's profile. */ + active: boolean +} + +export type FactKind = 'fact' | 'preference' | 'pattern' | 'correction' +export type FactSource = 'composer' | 'user_taught' | 'agent_learned' | 'derived' + +export interface AgentFact { + id: string + kind: FactKind + content: string + source: FactSource + is_pinned: boolean +} + +export interface AgentCompetence { + atoms: AgentAtom[] + facts: AgentFact[] + /** Total active facts (before the overview cap), so the count is honest. */ + factsActiveTotal: number +} + +const FACTS_LIMIT = 12 + +export async function buildAgentCompetence( + supabase: SupabaseClient, + companyId: string, +): Promise { + const [atomsRes, profileRes, factsRes, factsCountRes] = await Promise.all([ + // The atom registry is global product content, not tenant data. + supabase + .from('agent_atom_registry') + .select('id, tier, title, description') + .eq('is_active', true) + .eq('mcp_exposed', true) + .is('parent_atom_id', null) + .order('tier', { ascending: true }) + .order('title', { ascending: true }), + supabase + .from('agent_profiles') + .select('vertical_atoms, modifier_atoms') + .eq('company_id', companyId) + .maybeSingle(), + supabase + .from('agent_memory') + .select('id, kind, content, source, is_pinned') + .eq('company_id', companyId) + .eq('is_active', true) + .order('is_pinned', { ascending: false }) + .order('relevance_score', { ascending: false }) + .order('last_accessed_at', { ascending: false, nullsFirst: false }) + .order('created_at', { ascending: false }) + .limit(FACTS_LIMIT), + supabase + .from('agent_memory') + .select('id', { count: 'exact', head: true }) + .eq('company_id', companyId) + .eq('is_active', true), + ]) + + if (atomsRes.error) throw new Error(`agent skills failed: ${atomsRes.error.message}`) + if (factsRes.error) throw new Error(`agent memory failed: ${factsRes.error.message}`) + + const verticalActive = new Set((profileRes.data?.vertical_atoms as string[] | null) ?? []) + const modifierActive = new Set((profileRes.data?.modifier_atoms as string[] | null) ?? []) + + const atoms: AgentAtom[] = (atomsRes.data ?? []).map((a) => { + const tier = a.tier as AtomTier + const active = + tier === 'horizontal' ? true : tier === 'vertical' ? verticalActive.has(a.id) : modifierActive.has(a.id) + return { id: a.id, tier, title: a.title, description: a.description, active } + }) + + const facts: AgentFact[] = (factsRes.data ?? []).map((f) => ({ + id: f.id, + kind: f.kind as FactKind, + content: f.content, + source: f.source as FactSource, + is_pinned: f.is_pinned, + })) + + return { atoms, facts, factsActiveTotal: factsCountRes.count ?? facts.length } +} diff --git a/lib/agent-context/ledger-deep.ts b/lib/agent-context/ledger-deep.ts new file mode 100644 index 00000000..72cf8690 --- /dev/null +++ b/lib/agent-context/ledger-deep.ts @@ -0,0 +1,70 @@ +import type { SupabaseClient } from '@supabase/supabase-js' + +// Deep, entity-resolved analysis behind the "Vad din agent vet" page. Merges +// counterparties across name variants, mines the booked verifikat for real +// spend, and detects recurrence. Backed by the get_ledger_deep_context RPC +// (full history, deterministic). See migration 20260708130000. + +export interface DeepEntity { + /** Display label (the modal raw variant). */ + name: string + /** Identity key: normalized counterparty key, or supplier id. */ + key: string + /** Up to 8 distinct raw labels that merged into this entity. */ + variants: string[] + /** True count of distinct raw labels merged. */ + variant_count: number + /** Number of bookings. */ + occurrences: number + /** Total paid, gross SEK (abs bank amount / invoice total). */ + total_amount: number + first_seen: string + last_seen: string + /** Median gap between distinct booking dates, days. null if < 2 dates. */ + cadence_days: number | null + dominant_account_number: string | null + /** + * Laplace-smoothed consistency (cnt+1)/(total+2), 0..1: sample-size-honest, + * so a single booking reads 0.67, never 1.0 (migration 20260710090000). + */ + dominant_account_share: number | null + /** Raw evidence: bookings on the dominant account. */ + dominant_account_count: number | null + /** Raw evidence: all counted contra lines for the entity. */ + dominant_account_total: number | null + dominant_vat?: string | null + kind: 'counterparty' | 'supplier' +} + +export interface DeepLedgerContext { + counterparty_entities: DeepEntity[] + supplier_entities: DeepEntity[] +} + +interface DeepRow { + counterparty_entities: Omit[] + supplier_entities: Omit[] +} + +/** + * Full-history deep analysis. `fromDate = null` means all history; pass a date + * to bound the lookback on large tenants (the cache layer is deferred). + */ +export async function buildDeepEntities( + supabase: SupabaseClient, + companyId: string, + fromDate: string | null = null, +): Promise { + const { data, error } = await supabase.rpc('get_ledger_deep_context', { + p_company_id: companyId, + p_from_date: fromDate, + }) + if (error) { + throw new Error(`ledger deep context failed: ${error.message}`) + } + const row = (data ?? { counterparty_entities: [], supplier_entities: [] }) as DeepRow + return { + counterparty_entities: (row.counterparty_entities ?? []).map((e) => ({ ...e, kind: 'counterparty' as const })), + supplier_entities: (row.supplier_entities ?? []).map((e) => ({ ...e, kind: 'supplier' as const })), + } +} diff --git a/messages/en.json b/messages/en.json index c0b43843..2f2a2b4f 100644 --- a/messages/en.json +++ b/messages/en.json @@ -67,6 +67,7 @@ "dashboard": "Overview", "home": "Home", "assistant": "Assistant", + "agent_knowledge": "What your agent knows", "bureau": "Bureau", "kpi": "KPIs", "invoice_inbox": "Documents", @@ -128,6 +129,116 @@ "ext_tic": "Company profile", "ext_invoice_inbox": "Document inbox" }, + "agentKnowledge": { + "title": "What your agent knows", + "description": "How your company books, derived from your own accounting. This is the context your assistant reads before categorizing or creating vouchers.", + "meta_window": "Period", + "meta_posted_entries": "Posted vouchers", + "meta_lag": "Typical booking lag", + "meta_lag_value": "{days} days", + "meta_computed": "Updated", + "empty_title": "Your assistant hasn't learned anything yet", + "empty_description": "As you book transactions, the assistant learns how your company codes each counterparty, supplier and account.", + "empty_action": "Book transactions", + "graph_title": "Booking map", + "graph_description": "How the assistant sees your counterparties and suppliers flow into the chart of accounts. Colour shows rhythm, size the amount, and sharpness how sure the booking is. Hover a node for details.", + "graph_hint": "Hover a node to see how it's booked.", + "graph_account_caption": "{count} counterparties", + "graph_payee_caption": "seen {seen}× · {share}% agree", + "graph_aria": "Radial map: {payees} counterparties linked to {accounts} accounts.", + "graph_truncated": "showing the most frequent", + "legend_account": "Account", + "legend_counterparty": "Counterparty", + "legend_supplier": "Supplier", + "legend_edge": "Line strength = confidence", + "meta_entities": "Tracked payees", + "meta_recurring": "Recurring", + "meta_tracked_spend": "Tracked amount", + "cap_bookings": "{n} bookings", + "cap_variants": "{n} variants", + "cadence_weekly": "~weekly", + "cadence_monthly": "~monthly", + "cadence_quarterly": "~quarterly", + "cadence_irregular": "irregular", + "graph_replay": "Replay", + "graph_tally": "transactions · {payees} counterparties · {accounts} accounts, understood", + "legend_size": "Size = amount", + "legend_focus": "Sharpness = confidence", + "card_variants": "Name variants", + "card_confidence": "{share}% confidence", + "card_evidence": "Booked here in {k} of {n} cases", + "comp_title": "Competence", + "comp_desc": "Knowledge areas of Swedish bookkeeping and tax the assistant builds on. Core competence applies to everyone; industry and company adaptation is chosen for your company.", + "comp_empty": "No knowledge areas yet.", + "comp_count": "{total} knowledge areas · {active} active", + "comp_manage": "Read more", + "tier_horizontal": "Core competence", + "tier_vertical": "Tailored to your industry", + "tier_modifier": "Your company situation", + "badge_dormant": "Dormant", + "facts_title": "Facts the assistant remembers", + "facts_desc": "Notes the assistant uses to give you the right advice. Pinned facts are always included.", + "facts_empty": "No memories yet.", + "facts_more": "+{n} more", + "facts_manage": "Manage memory", + "facts_pinned": "Pinned", + "kind_fact": "Fact", + "kind_preference": "Preference", + "kind_pattern": "Pattern", + "kind_correction": "Correction", + "source_composer": "Loaded at startup", + "source_user_taught": "You taught me", + "source_agent_learned": "I noted", + "source_derived": "Derived", + "tab_competence": "Competence", + "tab_memory": "Memory", + "tab_config": "Rules & profile", + "footer_basis": "Based on {entries} posted vouchers · updated {date}", + "cp_title": "Booking patterns by counterparty", + "cp_description": "The most frequent counterparties from the bank feed and how they are usually booked. The evidence is historical frequency, not a guarantee: the assistant suggests, you approve.", + "sup_title": "Patterns by supplier", + "sup_description": "Derived from supplier invoices: the dominant expense account and VAT treatment per supplier.", + "rules_title": "Your rules", + "rules_description": "Rules you set yourself. They are instructions and take precedence over observed patterns.", + "acc_title": "Account usage", + "acc_description": "Accounts actually in use, the 20 most frequent over the last 12 months.", + "vat_title": "VAT profile", + "vat_description": "Observed VAT handling.", + "conv_title": "Conventions", + "conv_description": "How your company is set up.", + "col_counterparty": "Counterparty", + "col_supplier": "Supplier", + "col_account": "Account", + "col_vat": "VAT", + "col_evidence": "Confidence", + "col_last": "Last", + "col_rule": "Rule", + "col_match": "Match", + "col_postings": "Postings", + "col_last_used": "Last used", + "ev_counts": "{agree} of {seen} times", + "src_observed": "Observed", + "src_template": "Learned", + "src_rule": "Rule", + "none_cp": "No counterparty patterns yet.", + "vat_registered_label": "VAT registered", + "vat_period_label": "Reporting period", + "vat_treatments_label": "VAT types used", + "vat_no_treatments": "None yet", + "conv_method_label": "Accounting method", + "conv_series_label": "Voucher series", + "conv_salary_label": "Payroll run active", + "conv_lag_label": "Typical booking lag", + "method_accrual": "Accrual (invoice) method", + "method_cash": "Cash method", + "method_unknown": "Not set", + "period_monthly": "Monthly", + "period_quarterly": "Quarterly", + "period_yearly": "Yearly", + "unknown": "Not set", + "yes": "Yes", + "no": "No" + }, "auth": { "login_title": "Sign in", "login_subtitle": "Sign in to manage your finances", diff --git a/messages/sv.json b/messages/sv.json index 16ecb173..681c9ea7 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -67,6 +67,7 @@ "dashboard": "Översikt", "home": "Hem", "assistant": "Assistent", + "agent_knowledge": "Vad din agent vet", "bureau": "Byrå", "kpi": "Nyckeltal", "invoice_inbox": "Underlag", @@ -128,6 +129,116 @@ "ext_tic": "Företagsprofil", "ext_invoice_inbox": "Dokumentinkorg" }, + "agentKnowledge": { + "title": "Vad din agent vet", + "description": "Så här bokför ditt företag, härlett ur din egen bokföring. Det här är sammanhanget din assistent läser innan den kategoriserar eller skapar verifikationer.", + "meta_window": "Period", + "meta_posted_entries": "Bokförda verifikationer", + "meta_lag": "Typisk bokföringstid", + "meta_lag_value": "{days} dagar", + "meta_computed": "Uppdaterad", + "empty_title": "Din assistent har inte lärt sig något än", + "empty_description": "När du bokför transaktioner lär sig assistenten hur ditt företag konterar varje motpart, leverantör och konto.", + "empty_action": "Bokför transaktioner", + "graph_title": "Konteringskarta", + "graph_description": "Så här ser assistenten dina motparter och leverantörer flöda in i kontoplanen. Färgen visar rytmen, storleken beloppet och skärpan hur säker konteringen är. Peka på en nod för detaljer.", + "graph_hint": "Peka på en nod för att se hur den bokförs.", + "graph_account_caption": "{count} motparter", + "graph_payee_caption": "sedd {seen} ggr · {share}% lika", + "graph_aria": "Radiell karta: {payees} motparter kopplade till {accounts} konton.", + "graph_truncated": "visar de vanligaste", + "legend_account": "Konto", + "legend_counterparty": "Motpart", + "legend_supplier": "Leverantör", + "legend_edge": "Linjestyrka = träffsäkerhet", + "meta_entities": "Spårade motparter", + "meta_recurring": "Återkommande", + "meta_tracked_spend": "Spårat belopp", + "cap_bookings": "{n} bokningar", + "cap_variants": "{n} varianter", + "cadence_weekly": "~veckovis", + "cadence_monthly": "~månadsvis", + "cadence_quarterly": "~kvartalsvis", + "cadence_irregular": "oregelbundet", + "graph_replay": "Spela upp igen", + "graph_tally": "transaktioner · {payees} motparter · {accounts} konton, förstådda", + "legend_size": "Storlek = belopp", + "legend_focus": "Skärpa = säkerhet", + "card_variants": "Namnvarianter", + "card_confidence": "{share}% säkerhet", + "card_evidence": "Bokförd hit i {k} av {n} fall", + "comp_title": "Kompetens", + "comp_desc": "Kunskapsområden om svensk bokföring och skatt som assistenten bygger på. Kärnkompetensen gäller alla; bransch- och bolagsanpassningen väljs utifrån ditt företag.", + "comp_empty": "Inga kunskapsområden ännu.", + "comp_count": "{total} kunskapsområden · {active} aktiva", + "comp_manage": "Läs mer", + "tier_horizontal": "Kärnkompetens", + "tier_vertical": "Anpassat för din bransch", + "tier_modifier": "Din bolagssituation", + "badge_dormant": "Vilande", + "facts_title": "Fakta assistenten minns", + "facts_desc": "Anteckningar assistenten använder för att ge dig rätt råd. Fästa fakta skickas alltid med.", + "facts_empty": "Inga minnen ännu.", + "facts_more": "+{n} till", + "facts_manage": "Hantera minne", + "facts_pinned": "Fäst", + "kind_fact": "Fakta", + "kind_preference": "Preferens", + "kind_pattern": "Mönster", + "kind_correction": "Korrigering", + "source_composer": "Inläst vid uppstart", + "source_user_taught": "Du lärde mig", + "source_agent_learned": "Jag noterade", + "source_derived": "Härlett", + "tab_competence": "Kompetens", + "tab_memory": "Minne", + "tab_config": "Regler & profil", + "footer_basis": "Baserat på {entries} bokförda verifikat · uppdaterad {date}", + "cp_title": "Konteringsmönster per motpart", + "cp_description": "De vanligaste motparterna från bankflödet och hur de brukar bokföras. Underlaget är historisk frekvens, inte en garanti: assistenten föreslår, du godkänner.", + "sup_title": "Mönster per leverantör", + "sup_description": "Härlett ur leverantörsfakturor: dominerande kostnadskonto och momstyp per leverantör.", + "rules_title": "Dina regler", + "rules_description": "Regler du själv satt. De är instruktioner och går före observerade mönster.", + "acc_title": "Kontoanvändning", + "acc_description": "Konton som faktiskt används, de 20 vanligaste de senaste 12 månaderna.", + "vat_title": "Momsprofil", + "vat_description": "Observerad momshantering.", + "conv_title": "Konventioner", + "conv_description": "Hur ditt företag är inställt.", + "col_counterparty": "Motpart", + "col_supplier": "Leverantör", + "col_account": "Konto", + "col_vat": "Moms", + "col_evidence": "Träffsäkerhet", + "col_last": "Senast", + "col_rule": "Regel", + "col_match": "Matchning", + "col_postings": "Bokningar", + "col_last_used": "Senast använt", + "ev_counts": "{agree} av {seen} ggr", + "src_observed": "Observerat", + "src_template": "Inlärt", + "src_rule": "Regel", + "none_cp": "Inga motpartsmönster än.", + "vat_registered_label": "Momsregistrerad", + "vat_period_label": "Redovisningsperiod", + "vat_treatments_label": "Momstyper som använts", + "vat_no_treatments": "Inga än", + "conv_method_label": "Bokföringsmetod", + "conv_series_label": "Verifikationsserier", + "conv_salary_label": "Lönekörning aktiv", + "conv_lag_label": "Typisk bokföringstid", + "method_accrual": "Fakturametod", + "method_cash": "Kontantmetod", + "method_unknown": "Ej angiven", + "period_monthly": "Månadsvis", + "period_quarterly": "Kvartalsvis", + "period_yearly": "Årsvis", + "unknown": "Ej angiven", + "yes": "Ja", + "no": "Nej" + }, "auth": { "login_title": "Logga in", "login_subtitle": "Logga in för att hantera din ekonomi", diff --git a/supabase/migrations/20260712130000_ledger_deep_context_laplace_share.sql b/supabase/migrations/20260712130000_ledger_deep_context_laplace_share.sql new file mode 100644 index 00000000..ac472ad0 --- /dev/null +++ b/supabase/migrations/20260712130000_ledger_deep_context_laplace_share.sql @@ -0,0 +1,195 @@ +-- Honest confidence for get_ledger_deep_context (data_quality_master.md Item C). +-- +-- The raw dominant_account_share (cnt/total) reads "100% säkerhet" for an +-- entity with a single booking: fake certainty by construction (n=1 => 1.0). +-- This replace keeps the field name but makes it the Laplace-smoothed share +-- (cnt + 1) / (total + 2), the rule-of-succession estimate, so sample size +-- shrinks the displayed confidence: 1/1 -> 0.67, 3/3 -> 0.80, 12/13 -> 0.87. +-- The raw evidence is now exposed alongside it as dominant_account_count (cnt +-- on the dominant account) and dominant_account_total (all counted contra +-- lines), so the UI can say "bokförd hit i 12 av 13 fall". +-- +-- Everything else is byte-identical to 20260708130000_ledger_deep_context.sql +-- (see that file's header for the full semantics: variant merging, cadence, +-- storno/19xx/26xx exclusions, SECURITY INVOKER + RLS scoping). +-- +-- pg-test: tests/pg/ledger-deep-context-rpc.pg.test.ts + +CREATE OR REPLACE FUNCTION public.get_ledger_deep_context( + p_company_id uuid, + p_from_date date DEFAULT NULL +) +RETURNS jsonb +LANGUAGE sql +STABLE +SECURITY INVOKER +SET search_path TO 'public' +AS $$ + SELECT jsonb_build_object( + 'counterparty_entities', + ( + WITH booked AS ( + SELECT + public.normalize_counterparty_key(t.merchant_name) AS k, + t.merchant_name, + t.journal_entry_id, + t.date, + -- SEK: amount_sek carries the converted value on foreign-currency + -- rows and is null on SEK rows, so coalesce keeps everything in SEK + -- (mirrors the supplier side's coalesce(total_sek, total)). + abs(coalesce(t.amount_sek, t.amount)) AS amount + FROM public.transactions t + JOIN public.journal_entries je ON je.id = t.journal_entry_id + WHERE t.company_id = p_company_id + AND t.journal_entry_id IS NOT NULL + AND je.status = 'posted' + AND je.source_type <> 'storno' + AND t.merchant_name IS NOT NULL + AND trim(t.merchant_name) <> '' + AND (p_from_date IS NULL OR t.date >= p_from_date) + ), + keyed AS (SELECT * FROM booked WHERE k <> ''), + -- Median gap between distinct booking dates -> recurrence cadence. + distinct_dates AS (SELECT DISTINCT k, date FROM keyed), + gaps AS ( + SELECT k, (date - lag(date) OVER (PARTITION BY k ORDER BY date)) AS gap + FROM distinct_dates + ), + recur AS ( + SELECT k, round(percentile_cont(0.5) WITHIN GROUP (ORDER BY gap))::int AS cadence_days + FROM gaps WHERE gap IS NOT NULL GROUP BY k + ), + -- Dominant contra account + its share, over the entity's verifikat lines. + acct_counts AS ( + SELECT b.k, l.account_number, count(*)::bigint AS cnt + FROM keyed b + JOIN public.journal_entry_lines l ON l.journal_entry_id = b.journal_entry_id + WHERE l.account_number NOT LIKE '19%' + AND l.account_number NOT LIKE '26%' + GROUP BY b.k, l.account_number + ), + acct_totals AS (SELECT k, sum(cnt) AS total FROM acct_counts GROUP BY k), + dominant_account AS ( + SELECT DISTINCT ON (ac.k) ac.k, ac.account_number, ac.cnt, at.total + FROM acct_counts ac JOIN acct_totals at ON at.k = ac.k + ORDER BY ac.k, ac.cnt DESC, ac.account_number + ), + agg AS ( + SELECT + k, + mode() WITHIN GROUP (ORDER BY merchant_name) AS display_name, + count(*)::bigint AS occurrences, + count(DISTINCT merchant_name)::int AS variant_count, + (array_agg(DISTINCT merchant_name))[1:8] AS variants, + sum(amount) AS total_amount, + min(date) AS first_seen, + max(date) AS last_seen + FROM keyed GROUP BY k + ) + SELECT coalesce( + jsonb_agg( + jsonb_build_object( + 'name', a.display_name, + 'key', a.k, + 'variants', to_jsonb(a.variants), + 'variant_count', a.variant_count, + 'occurrences', a.occurrences, + 'total_amount', round(a.total_amount)::bigint, + 'first_seen', a.first_seen, + 'last_seen', a.last_seen, + 'cadence_days', r.cadence_days, + 'dominant_account_number', da.account_number, + -- Laplace-smoothed: n=1 no longer reads as 100%. + 'dominant_account_share', + CASE WHEN da.total > 0 THEN round((da.cnt + 1)::numeric / (da.total + 2), 2) ELSE NULL END, + 'dominant_account_count', da.cnt, + 'dominant_account_total', da.total + ) + ORDER BY a.occurrences DESC, a.total_amount DESC, a.display_name + ), + '[]'::jsonb + ) + FROM (SELECT * FROM agg ORDER BY occurrences DESC, total_amount DESC, display_name LIMIT 40) a + LEFT JOIN recur r ON r.k = a.k + LEFT JOIN dominant_account da ON da.k = a.k + ), + 'supplier_entities', + ( + WITH sinv AS ( + SELECT si.id, si.supplier_id, s.name AS supplier_name, + si.invoice_date, si.vat_treatment, + coalesce(si.total_sek, si.total, 0) AS amount + FROM public.supplier_invoices si + JOIN public.suppliers s ON s.id = si.supplier_id + WHERE si.company_id = p_company_id + AND si.status NOT IN ('reversed', 'credited') + AND si.is_credit_note = false + AND (p_from_date IS NULL OR si.invoice_date >= p_from_date) + ), + distinct_dates AS (SELECT DISTINCT supplier_id, invoice_date FROM sinv), + gaps AS ( + SELECT supplier_id, + (invoice_date - lag(invoice_date) OVER (PARTITION BY supplier_id ORDER BY invoice_date)) AS gap + FROM distinct_dates + ), + recur AS ( + SELECT supplier_id, round(percentile_cont(0.5) WITHIN GROUP (ORDER BY gap))::int AS cadence_days + FROM gaps WHERE gap IS NOT NULL GROUP BY supplier_id + ), + acct_counts AS ( + SELECT v.supplier_id, i.account_number, count(DISTINCT v.id)::bigint AS cnt + FROM sinv v JOIN public.supplier_invoice_items i ON i.supplier_invoice_id = v.id + GROUP BY v.supplier_id, i.account_number + ), + acct_totals AS (SELECT supplier_id, sum(cnt) AS total FROM acct_counts GROUP BY supplier_id), + dominant_account AS ( + SELECT DISTINCT ON (ac.supplier_id) ac.supplier_id, ac.account_number, ac.cnt, at.total + FROM acct_counts ac JOIN acct_totals at ON at.supplier_id = ac.supplier_id + ORDER BY ac.supplier_id, ac.cnt DESC, ac.account_number + ), + agg AS ( + SELECT + supplier_id, + max(supplier_name) AS supplier_name, + count(*)::bigint AS occurrences, + sum(amount) AS total_amount, + min(invoice_date) AS first_seen, + max(invoice_date) AS last_seen, + mode() WITHIN GROUP (ORDER BY vat_treatment) AS dominant_vat + FROM sinv GROUP BY supplier_id + ) + SELECT coalesce( + jsonb_agg( + jsonb_build_object( + 'name', a.supplier_name, + 'key', a.supplier_id::text, + 'variants', to_jsonb(ARRAY[a.supplier_name]), + 'variant_count', 1, + 'occurrences', a.occurrences, + 'total_amount', round(a.total_amount)::bigint, + 'first_seen', a.first_seen, + 'last_seen', a.last_seen, + 'cadence_days', r.cadence_days, + 'dominant_account_number', da.account_number, + -- Laplace-smoothed: n=1 no longer reads as 100%. + 'dominant_account_share', + CASE WHEN da.total > 0 THEN round((da.cnt + 1)::numeric / (da.total + 2), 2) ELSE NULL END, + 'dominant_account_count', da.cnt, + 'dominant_account_total', da.total, + 'dominant_vat', a.dominant_vat + ) + ORDER BY a.occurrences DESC, a.total_amount DESC, a.supplier_name + ), + '[]'::jsonb + ) + FROM (SELECT * FROM agg ORDER BY occurrences DESC, total_amount DESC, supplier_name LIMIT 20) a + LEFT JOIN recur r ON r.supplier_id = a.supplier_id + LEFT JOIN dominant_account da ON da.supplier_id = a.supplier_id + ) + ); +$$; + +REVOKE ALL ON FUNCTION public.get_ledger_deep_context(uuid, date) FROM PUBLIC, anon; +GRANT EXECUTE ON FUNCTION public.get_ledger_deep_context(uuid, date) TO authenticated, service_role; + +NOTIFY pgrst, 'reload schema'; diff --git a/tests/pg/ledger-deep-context-rpc.pg.test.ts b/tests/pg/ledger-deep-context-rpc.pg.test.ts index 9967b27c..f6851b48 100644 --- a/tests/pg/ledger-deep-context-rpc.pg.test.ts +++ b/tests/pg/ledger-deep-context-rpc.pg.test.ts @@ -69,6 +69,8 @@ type DeepEntity = { cadence_days: number | null dominant_account_number: string | null dominant_account_share: number | null + dominant_account_count: number | null + dominant_account_total: number | null dominant_vat?: string | null } type Deep = { counterparty_entities: DeepEntity[]; supplier_entities: DeepEntity[] } @@ -129,7 +131,10 @@ describe('get_ledger_deep_context', () => { expect(klarna!.variant_count).toBe(3) expect(klarna!.variants.length).toBeGreaterThanOrEqual(3) expect(klarna!.dominant_account_number).toBe('5420') - expect(klarna!.dominant_account_share).toBe(1) + // Laplace-smoothed (3+1)/(3+2): consistent history, but n=3 is not certainty. + expect(klarna!.dominant_account_share).toBe(0.8) + expect(klarna!.dominant_account_count).toBe(3) + expect(klarna!.dominant_account_total).toBe(3) expect(klarna!.total_amount).toBe(300) expect(klarna!.first_seen).toBe('2026-04-01') expect(klarna!.last_seen).toBe('2026-06-01') @@ -145,6 +150,11 @@ describe('get_ledger_deep_context', () => { expect(sl!.variant_count).toBe(1) expect(sl!.cadence_days).toBeNull() expect(sl!.dominant_account_number).toBe('5810') + // The P3 bug this guards: a single booking must NOT read as 100%. + // Laplace (1+1)/(1+2) = 0.67, with the raw 1-of-1 evidence exposed. + expect(sl!.dominant_account_share).toBe(0.67) + expect(sl!.dominant_account_count).toBe(1) + expect(sl!.dominant_account_total).toBe(1) }) it('aggregates supplier entities with spend and dominant account', async () => { @@ -154,6 +164,10 @@ describe('get_ledger_deep_context', () => { expect(telia!.occurrences).toBe(2) expect(telia!.total_amount).toBe(1000) expect(telia!.dominant_account_number).toBe('6212') + // Laplace-smoothed (2+1)/(2+2). + expect(telia!.dominant_account_share).toBe(0.75) + expect(telia!.dominant_account_count).toBe(2) + expect(telia!.dominant_account_total).toBe(2) expect(telia!.dominant_vat).toBe('standard_25') expect(telia!.cadence_days).toBeGreaterThanOrEqual(30) })