2eb34412442ade678cc11b6bb15c48ea4c4bb63f
6 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
555a2a20ae |
feat(inbox): Underlag rebuilt to answer what is missing, where to get it, and how it would be booked (#1524)
* fix(mail): stop Gmail refusing the search, and stop calling that "hittade inget" Pressing Leta produced mails=25, documents=0 on a real two-mailbox run. Nothing was found because nothing was searched: every request came back 429 "Too many concurrent requests for user". Two bugs, and the second is the one that matters. The search fanned out with Promise.all over every message id at once, one Gmail request per message, per connection. Gmail enforces a per-user concurrency ceiling as well as a daily quota, and this sailed past it long before any volume worth worrying about. It now runs through a pool of five per connection, which is comfortably under and still finishes a page of results in a couple of round trips. The catch turned each refusal into an empty array, with a comment saying one mailbox's failure must not become the company's. Right instinct, wrong consequence: an empty array is also what an empty mailbox returns, and the manual hunt loop stops on fetched === 0 because that is its signal for "the mailboxes hold nothing more for what is open". So a rate-limited search told the user their receipts do not exist, and stopped looking. searchFailureCount() now separates "could not look" from "nothing there". The run route reports it, and the loop treats a pass with failures as failed rather than finished, so pressing again is the obvious next move instead of a pointless one. This is the failure this feature exists to catch, happening inside the feature: silence that reads as an answer. Restoring the unbounded fan-out fails one test; removing the failure counter fails three. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(inbox): segment filter as a dropdown, not three rows of pills Five filters wrapped to three lines in a 280px column. The counts are what people actually read, so they stay on the trigger and inside the menu rather than being traded away for the space. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(inbox): one chip for where underlag come from Three routes in, and the page never said so: the forwarding address sat inline in the header, the mailboxes lived only in Instaellningar, and WhatsApp was invisible here entirely. They are behind one chip now. Which mailbox and when it was last read is what people look up when something seems wrong, not what they read every visit, so it opens rather than occupying the header. A mailbox that has stopped working is the exception, so it surfaces on the chip itself rather than waiting to be found one click in. That silence is the failure this feature exists to catch. Configuration stays in Instaellningar; this only reports. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(inbox): the kontering first, the evidence folded Reading order was backwards. Nine extracted values came first and the one thing to approve came last, so every matched item meant scrolling past the evidence to reach the decision. The proposed kontering is now the first thing in the rail. The fields fold behind a summary that carries how many of the twelve the extraction actually filled, so a thin extraction is visible without opening it. They stay open when nothing is matched: with no proposal above them the fields are all there is, and folding the only content on the pane would be a hiding place rather than a hierarchy. The counted list is the same one hasAnyExtractedField checks, so the summary cannot claim a field the 'is anything here' test does not count. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(inbox): one dialog that changes the whole verifikat The rail offered three overlapping ways to alter a booking and none said what it covered: an Aendra beside the date, an Aendra kontering at the bottom, and a menu entry that did what the primary button already did. This is the one control, and its scope is the whole verifikat: date, series, description, every line. It opens pre-filled with the proposal when there is one and empty when there is not, so there is no separate book-manually path to pick between. A dialog rather than an inline editor: a 340px rail cannot hold an account picker, two money columns and a delete control per row without clipping something, and the document has to stay readable while the numbers change. Checking a momssats against the paper is the reason to open it at all. TransactionBookingDialog already has this shape for the same reason. The form is JournalEntryForm unchanged. It carries the series picker, per line descriptions, dimensions, currency, the balance check and the confirm step, and it posts through the sanctioned route. Extending BookDirectlyDialog was the alternative and is not viable: three effects seed its lines and fight anything injected, and its FormLine has no room for line text, dimensions or tax codes. Nothing posts without the form's own review step, so a proposal stays a draft the user commits. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(inbox): show every unreceipted purchase, and fold the mailboxes Three things. The 100 kr floor was hiding 52 of one real company's 119 unreceipted purchases: the page reported 67 and looked tidier for it. The floor was copied from the receipt hunt, where it earns its place because every candidate costs a mail search and a model read. This list costs a query, and bokforingslagen wants an underlag for the 45 kr purchase exactly as much as for the 4 500 kr one. The hunt keeps its floor; the page has none. Mailboxes fold. When it was last searched is what you look up when a mailbox seems to have gone quiet, not what you read on the way past. The address stays on the row, and a connection that needs reconnecting still says so without opening. Dropped the line telling people to go to Instaellningar. The panel reports where underlag come from; sending them elsewhere was the seam this work set out to close. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(inbox): split the portal purchases out, and say what a run found Four things from looking at the real page beside the artifact. Hamta fran portal is its own list again. Twelve of one company's 119 unreceipted purchases have a supplier whose invoices sit behind a login, and that is a different job from the other 107: go there and fetch it, versus ask somebody. Collapsing them into one list with a badge buried the twelve you can settle now among the hundred you cannot. A run now says what it did. Pressing Leta and being told nothing is why the feature read as broken even on the runs where it worked: three underlag landed and the page looked identical afterwards. WhatsApp folds like the mailboxes and shows its number, which is the fact worth having. Describing the channel to someone who already connected it was not. The forwarding address lost its subtitle, and WhatsApp rows carry the brand mark. Emailed documents keep the generic one: nothing records which mailbox fetched them, so claiming a provider would be a guess. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(inbox): the WhatsApp number, three wrong portals, and somewhere to drop the file The WhatsApp row read the response in snake_case while the route answers camelCase, so a linked number rendered as a dash and a verified link read as unverified. Reading phoneMasked and verifiedAt fixes both. Anthropic, Vercel and Supabase are out of the portal directory. All three email their invoices to European customers, so listing them told somebody to go and log in for a document already sitting in their inbox: worse than saying nothing, because it sends them away from the answer. The directory's bar is 'does not send the invoice', not 'also has a portal'. The poll it was seeded from asked which portals people log into, and people answered with where an invoice can also be found. The same objection may reach further down the list. A purchase with no underlag now offers somewhere to put one. Telling somebody a document is missing without a place to drop it is half an answer, and the drop zone carries the amount and the date so the right file goes to the right purchase. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(portal): the links were never opened, and two of them were wrong The directory shipped with eighteen hand-written paths and none had been clicked. The file said so in its own header and shipped regardless, which is how a founder came to land on a 404 opening Google Workspace. A sweep of every URL found GitHub broken as well. Google Workspace now points at the console root rather than a deep billing path: admin.google.com refuses automated requests, so no deeper path can be verified from here, and a link that lands one click short beats one that lands on an error page. GitHub points at the path that actually answers. Trygg Hansa is removed because neither candidate URL could be reached at all, and an unverifiable link is exactly the promise this file kept warning about. scripts/check-portal-urls.mts sweeps them, so the next wrong URL is found by a script rather than by somebody who trusted the link. A 404 fails it; a host that refuses automation reports as unreachable and does not, because failing on those would train people to ignore the output. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(inbox): the drop zone now actually attaches the file to the purchase It did not. The generic upload sends only the file, so a document dropped while a purchase was selected landed in the inbox unmatched, while the pane showed that purchase's amount and date directly under the drop zone. The copy promised a link the code never made, and the user was left to match by hand what they had already told us. Uploading from a selected purchase now matches the new item to that transaction through the endpoint that already exists, and a file dropped anywhere on the page while a purchase is selected counts as that purchase's receipt rather than a loose upload. When the match fails the document is still safely filed, so it says so plainly instead of claiming a link that is not there. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(inbox): book the underlag against its transaction, and stop claiming links Two blockers found by review, both on the path that writes to the ledger. "Granska och bokför" never sent transaction_id. JournalEntryForm serialises a fixed set of keys and that is not one of them, and BookInboxItemDirectlySchema is a non-strict z.object, so the source_id carrying it was silently stripped. The verifikat posted standalone, the bank transaction stayed unbooked, and matched_transaction_id was overwritten with null: the match somebody had already made, undone, while the rail said Bokförd over all of it. Fixed in three places because one was not enough. JournalEntryForm takes an extraBody passthrough, the dialog sends transaction_id through it, and the route now falls back to the item's existing match rather than null, so a caller that merely forgets the field cannot undo work. Removing that fallback fails the new test. The hunt banner said "kopplades till ett köp" about pending_operations rows. The hunt stages proposals for approval and books nothing, so the number was real and the word was wrong: a user would read it, believe three purchases were done, and leave. It now says how many förslag await granskning, and links there. Booking also left the rail in its pre-booking state, still offering to post, so the same underlag could be submitted twice. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(inbox): no marker on a healthy state, no false empty state, no dropped files Three from review. The sources chip painted a sage dot whenever every mailbox was fine. Convention 12 rules semantic colour out of chrome, and convention 5 rules out a marker on a normal state: a chip every company sees always is a chip that says nothing. What is left is the exception, which is worth an ochre word and an icon. The pre-existing sage on matched rows is untouched; it is not this branch's to change. The empty state asserted "Varje köp har sitt underlag" while the trigger directly above it still showed the unsearched count. Type a term under Att göra, switch to Saknar underlag, and the page told you every purchase was covered while the button beside it read 50. It now says what is true: no matches for that term. A drop of several files onto a selected purchase kept the first and discarded the rest in silence, so a receipt scanned as two images left the purchase looking resolved with half its paperwork gone. They cannot all be one purchase's underlag, so the extras are filed in the inbox and the toast says how many. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(inbox): the hunt banner now says a press is not the last word A press fetches a bounded number of receipts, so an empty result usually means not yet rather than nothing there. The banner said 'Inget matchade något köp' and stopped, which reads as final and sends people away from a mailbox that still holds their receipts. It now says how many purchases are left to search for, and to press again. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(inbox): a count not a score, an honest failure, full-opacity borders '5 av 12' read as a bad extraction even when a kvitto had given up everything a kvitto has: half those twelve fields only exist on an invoice, so the denominator was measuring the document kind rather than the reading of it. It now says how many fields are filled, and says nothing when none are. The failure banner told people their mailbox had not answered even when the failure was ours, sending them to check a healthy Gmail. It now reads searchFailures and only blames the mailbox when a mailbox actually refused. Opacity-suffixed borders on the sources panel, which design.md forbids on surfaces: the border token is calibrated for full opacity. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(inbox): translate the new strings, and name the mailbox that fetched a receipt Both of these were deferred with reasons, and one of the reasons was wrong. 57 keys in inbox_workspace, in both locales, covering every string this branch added. The component already had 27 t() calls, so hardcoding beside them was an inconsistency rather than a convention. The message-keys guard caught an invented journal_form.no_document on the way, which is what it is for. The provider mark claimed nothing recorded which mailbox fetched a document. It does: lib/receipt-hunt/ingest.ts writes mail_provider and mail_mailbox into channel_context on every ingest, and GET /items already selects that column. A hunted receipt now carries the mark of the mailbox it came from; forwarded mail has no connection behind it and keeps the envelope, which is the honest distinction rather than a guess. InboxChannelContext was WhatsApp-shaped and is now a union over the two intakes that write it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(agent-context): keep the clarification channel narrow Widening InboxChannelContext.channel to cover the mail hunt broke this: only WhatsApp asks a human anything, so only WhatsApp produces clarifications. The mail hunt writes the same column with its own shape and never carries answers, so the provenance field stays 'whatsapp' rather than following the union. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(inbox): book the transaction we preserved, and date the verifikat by the event Three from PR review, two of them real. Preserving matched_transaction_id without booking it was the worse half of the bug it fixed. The transaction update was still guarded on the caller having sent transaction_id, so an omitted field left the item looking resolved while its bank line stayed open forever. Both the update and the item now use the same resolved id: the one the caller named, or the one the item was already matched to. Reverting the guard fails a test. The verifikat date fell back to today when there was no proposal, which is exactly the unknown-supplier case the dialog exists for. BFL 5 kap 6-7 § asks for datum för affärshändelsen; the day somebody opened a dialog is nobody's business event. It now falls back to the document's own date first, and only then to today. An en dash had crept in as a placeholder glyph, which the repo bans. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
4c479f84f4 |
feat(receipt-hunt): convert a foreign receipt instead of refusing to compare it (#1497)
* feat(receipt-hunt): compare a foreign receipt by converting it, not by refusing A Swedish bank posts a converted figure for a purchase abroad while the receipt states the original: Anthropic bills 180,00 EUR and the statement reads -2 014,32 kr. Neither number appears in the other document, so the matcher refused the pair rather than guess. On a SaaS-heavy ledger that is not an edge case: of 25 receipts fetched from a real company's mailboxes, 14 were in USD or EUR and none could ever pair. The receipt's total is now resolved into kronor with Riksbanken's rate for its own date, and handed to the same matcher, which still wants the merchant and the date to agree. The seam already existed: the scorer passed null where a SEK value would go, with a comment explaining that cross-currency pairs were deliberately incomparable. Surfaces that do not resolve a rate still pass nothing and behave exactly as before. Rates are fetched once per currency and day. Riksbanken answers 429 to a caller that asks per document, and a run holds a dozen receipts from one vendor in one month. A rate that cannot be resolved leaves the receipt exactly as incomparable as it was. Two calibration faults surfaced once the amounts became comparable: A converted total is judged at 9% rather than 5%. Riksbanken publishes a mid rate and a card issuer charges its own, so the two carry a known spread on top of any disagreement about the sum: measured against real statements, 1.2% to 3%. Holding both to one bar treats a rate spread as if it were a discrepancy. The date tolerance moves from 3 days to 10. A card settles days after the purchase, an international one routinely a week later, and a forwarded receipt carries the purchase date while the statement carries the posting. At three days the signal scored zero for ordinary correct pairs and took a quarter of the weight with it: a receipt agreeing to within 1%, from a merchant the matcher recognised, still capped at 0.62. The 27 human- confirmed pairs and the 7 near-misses that must not match all still hold. Measured on that ledger: purchases with any candidate at all go 9 -> 13, and the Anthropic pair proposes at 0.82 where it was previously unscoreable. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(receipt-hunt): an undated receipt is not converted at today's rate Raised in review. A foreign receipt with no invoiceDate fell back to the current date, which put a receipt of unknown age into amount matching on the strength of a guess: a rate two years out is how something incomparable acquires confidence it has not earned. No date, no conversion, and the receipt stays exactly as it was. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
43386b4852 |
feat(agent): offer unmatched inbox receipts as confirmable underlag (#1436)
The originally reported scenario is still broken after #1425 and its backfill-by-document_id: a user photographs a receipt into WhatsApp, answers the bot's questions, then opens the app, clicks the bank transaction and asks the assistant to book it, and is told "UNDERLAG: saknas" about a receipt we are holding, then asked everything again. WhatsApp intake writes neither invoice_inbox_items.matched_transaction_id (process-inbound.ts passes uploadAndExtract's matchedTransactionId as undefined) nor transactions.document_id (that mirror is written by the manual match route). Only TransactionMatchPicker fills either column. So the underlag list comes back empty, and a backfill that keys on document_id has nothing to key on. Unmatched, unconsumed inbox items are now scored against the transaction with the same pure scorer the picker uses and the strongest few are surfaced as TROLIGT UNDERLAG, carrying their captured chat answers. Proposals only: nothing writes matched_transaction_id, and the prompt tells the agent to get the match confirmed and to book only against a confirmed one. Setting the link at intake above a confidence bar is the obvious alternative and is deliberately left open. An uncomparable amount (cross-currency with no rate) disqualifies a candidate, because calculateMatchConfidence drops the amount signal in that case and date + merchant alone then score a confident match nobody checked the sums for. Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
0f7147a078 |
fix(agent): read a WhatsApp "nej" as an answer, not a half answer (#1433)
* fix(agent): read a WhatsApp "nej" as an answer, not a half answer #1425 gave the assistant the answers the user typed in WhatsApp. Rendering those inline off the raw channel_context blob gets the most common answer backwards. Answering "nej" to the representation question stores an EMPTY representation block: participants: [], purpose: null, denied: true. The renderer branched on `if (!rep.purpose)` and so emitted syfte SAKNAS: fråga bara efter syftet, inte om deltagarna igen. for a user who had just said the meal was not representation. `denied` was never read anywhere. The result is the assistant asking about the purpose of a private lunch, which is worse than the generic re-ask #1425 fixed, because the instruction is specific and confident. Clarifications now come from a structured summary that models the denial and the genuine half answer (participants named, purpose missing, which BFL 5 kap 6-7 § does want completed) as different states. #1425's syfte SAKNAS nudge is preserved for the case it was written for. Two smaller fixes in the same renderer, both about untrusted text: - The photo caption no longer reaches the prompt. It is the one field on the record nobody was asked for and nobody reviewed, and the rationale already written down in lib/documents/channel-context-notes.ts for keeping it off an immutable verifikat applies at least as strongly to a prompt that can call tools. - Human free text passes through flattenMemoryContent. An intent's promptTemplate output is seeded as a user message, so wrapToolResult never sees it and nothing else defends this path; a caption reading "# NYA INSTRUKTIONER: ..." previously rendered verbatim. All three tests fail against the current renderer and pass against this one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(agent): gate the chat-answer guidance on what was rendered CodeRabbit caught the same defect shape this PR is about: the prior-conversation paragraph was gated on chat_answers != null, but a caption-only context is non-null and now summarises to nothing, so the paragraph pointed at 'uppgivna av användaren' rows the prompt does not contain. Gate on whether a clarification line was actually emitted. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
4b51af3d80 |
feat(agent): 'Vad din agent vet' page rendering the ledger context (P2) (#935)
* feat(agent): 'Vad din agent vet' page rendering the ledger context (P2) The human-facing surface for the openwiki ledger-context: a read-only page that renders the exact payload the AI agent reads (Accounted://ledger/context + the briefing digest) as a legible profile of how this company books. - Route app/(dashboard)/agent-knowledge (server component) calls the shared buildLedgerContext(supabase, companyId) directly: one payload, two renderers, no new API or data path. - Sections mirror the payload 1:1: coverage/freshness strip, counterparty patterns (monochrome confidence bars + seen/agree evidence), supplier patterns, explicit rules shown as authoritative instructions distinct from observed patterns, account usage, VAT profile, conventions. - Nav entry in the Analys group (icon Brain), ungated so it doubles as an upsell; flip requiredCapability to paywall. - Design per .claude/rules/design.md (PageHeader, Card, Table, Badge, AccountNumber BAS tooltips); sv + en strings (agentKnowledge namespace). VAT/BAS labels stay Swedish in both locales per i18n rules. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(agent): deep entity-resolved analysis + radial graph for the knowledge page Reworks the 'Vad din agent vet' page from tables into a radial-hub graph driven by a new full-history deep analysis, per founder feedback. - fix(rpc): median_booking_lag_days now measures real posting promptness via committed_at, not entry_date (which the bank flow sets to the transaction date, giving a ~0 tautology: 151/152 on prod). migration 20260708120000. - feat(rpc): get_ledger_deep_context (migration 20260708130000): full-history, deterministic, read-side. Merges counterparties by normalize_counterparty_key (e.g. Claude = 14 bookings across 12 name variants, weekly, 9 710 kr, always 5420), mines booked verifikat for SEK spend (coalesce amount_sek), detects recurrence cadence, dominant account + share, plus supplier entities. Storno excluded, corrections kept; 19xx/26xx excluded from the dominant contra. - LedgerGraph: radial SVG (company center, accounts inner ring, payees outer ring), hover/focus reveals variants + spend + cadence + account. Keyboard focusable nodes with per-node accessible names + a screen-reader data table. - Page fetches the deep context alongside the light context; coverage strip gains tracked-payee / recurring / tracked-spend stats. sv + en strings. - 14 pg tests (light + deep) green; both RPCs applied to prod + version-matched. Reviewed by an adversarial multi-lens pass (accounting/SQL, frontend/a11y, prod-fact verification); all four verified findings fixed (SEK currency, storno-lag guard, keyboard a11y, spacing tokens). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(agent): gentle mount fade-in for the radial map (reduced-motion safe) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(agent): render the page for a rules-only company (empty-state edge case) isEmpty ignored explicit_rules, so a company with configured mapping rules but no posted transactions hit the 'hasn't learned anything' empty state and lost its rules section. Rules are independent of bookings. (CodeRabbit) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(agent): show Kompetens (skills) + Fakta (memory) on the knowledge page The 'Vad din agent vet' page now shows the full picture of what the agent knows: alongside the booking map, a compact read-only view of its Kompetens (the Swedish accounting/tax knowledge atoms it ships with, grouped by tier as chips with active/dormant state) and the Fakta it remembers (top learned facts with kind + source), each linking to /settings/assistant for full management. Server-rendered via a new buildAgentCompetence() that mirrors GET /api/agent/skills + /api/agent/memory. Also renders in the no-bookings case so a new company still sees its agent's competence. sv + en strings. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(agent): restructure knowledge page - hero graph + tabbed detail Declutters the page per feedback: the booking map is the always-visible hero, and the supporting detail (Kompetens · Minne · Regler & profil) moves into tabs so only one view shows at a time instead of a long card stack. Split AgentCompetenceSections into standalone CompetenceCard + FactsCard for the tabs; removed the top stat row on request. sv + en. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(agent): Reconciliation Aurora rewrite of the ledger knowledge graph Full rewrite of LedgerGraph: node area = sqrt(spend), colour = cadence, shape = supplier/counterparty, confidence = depth-of-field; on-mount descriptor-collapse animation with xN badge; cadence pulse veins; deterministic seeded layout; framer-motion only (no new deps); keyboard navigation, reduced-motion and sr-only support. Build-verified; 3-lens adversarial review findings fixed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(agent): sample-size-honest confidence in the ledger knowledge graph dominant_account_share was raw cnt/total, so a counterparty with a single booking rendered as '100% säkerhet': fake certainty by construction (the data_quality_master Item-C / P3 finding). New migration replaces get_ledger_deep_context with a Laplace-smoothed share (cnt+1)/(total+2) (1/1 -> 0.67, 3/3 -> 0.80) and exposes the raw evidence as dominant_account_count / dominant_account_total. The detail card now shows 'Bokförd hit i k av n fall' under the confidence bar; the existing focus buckets, stroke widths and percent labels inherit the honest value unchanged. pg-real test updated to guard the n=1 case. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
a3c6566caf |
feat(mcp): ledger-context resource with per-company booking patterns (#928)
* feat(mcp): ledger-context resource with per-company booking patterns Adds Accounted://ledger/context: derived account usage, counterparty booking patterns with explicit confidence share (0.7 floor), explicit mapping rules kept separate as authoritative, observed VAT profile, and conventions. Backed by a SECURITY INVOKER get_ledger_usage_stats RPC so group-bys run SQL-side, and surfaced as a top-5 digest stanza on gnubok_get_agent_briefing so one call still bootstraps a session. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(mcp): fold source-quality prereqs into the ledger-context RPC Merchant-name normalization at the aggregation path (the splinter fix): new normalize_counterparty_key() SQL function mirroring normalizeCounterpartyName() so KORTKÖP/SWISH/date-suffixed labels merge into one counterparty key, which also makes the categorization_templates join exact. New supplier_patterns section (per-supplier dominant expense account + VAT treatment from supplier invoices; credit notes and reversed invoices excluded). account_usage excludes storno lines (they re-inflate the account a correction moved away from); the counterparty CTE keeps corrections because the transaction relink self-heals. Pattern confidence is now count-grounded evidence {seen_12m, agree, share, last_booked} instead of a bare ratio, and the digest frames it as historical frequency, never auto-book permission. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(agent-context): use roundOre for the share ratio (antipattern ratchet) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(agent-context): defensive storno filter on counterparty CTE, fail-loud secondary reads Review follow-ups: the counterparty CTE now excludes source_type='storno' defensively (no live code path links a transaction to a storno, but legacy rows may predate reverseEntry's unlink; a linked storno would count the reversed category as precedent). Corrections stay included: they are the live booking after relink. Secondary reads (rules, templates, settings) now throw instead of silently reading as empty data: an agent must never be told 'no rules' when the truth is 'read failed'. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> |