Agent v1 bundle: TIC v2 onboarding, in-app assistant gating, sidebar nav, MCP fixes (#584)

* fix(sie-import): accept tab as field separator (Bollbok exports)

The SIE 4 spec allows either space or tab between fields, but
splitSIELine() only treated space (0x20) as a separator. Bollbok
exports tab-separated lines for every record except #RAR, which
silently swallowed all #IB / #UB / #KONTO / #KTYP / #VER / #TRANS
records — imports appeared empty even though the file was well-formed.

Also adds a parser-side diagnostic that emits a warning when raw #IB
or #VER lines are present in the input but parsing produced none. The
previous silent failure is how this bug stayed hidden; the warning
gives the import preview something visible to surface next time.

Verified against two real reproducer files (Sean / Erik Hellqvist):
  erik h 2025.SE (UTF-8): 166 accounts, 66 IB, 4 UB, 11 RES, 95 vouchers, 198 TRANS.
  erik h 2026.SE (CP437): 166 accounts, 66 IB, 4 UB, 0 vouchers.
Both now parse with zero warnings/errors.

Tests:
  + 8 Bollbok-shape tab-separated fixtures (2025 + 2026 quoting variants).
  + 4 silent-failure diagnostic-warning tests.
  All 74 sie-parser tests pass; 155/155 in lib/import; 64/64 downstream callers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(sie-import): address PR #513 review — strip #KTYP quotes, suppress redundant aggregate warning

Two non-blocking P2 findings from Greptile review on PR #513:

1. #KTYP handler stored fields[2] directly, so Bollbok 2026 exports
   (#KTYP\t1510\t"T") stored '"T"' with literal quotes instead of 'T'.
   Latent defect — accountType is unused downstream today, but my tab-
   separator fix made the quoted-value path reachable. Now routes through
   parseStringField so both Bollbok 2025 (unquoted T) and 2026 (quoted "T")
   land as 'T'.

2. The aggregate "kontrollera fältavskiljare och teckenkodning" warning
   fired alongside per-record 'error'-severity issues for malformed #IB /
   #VER records, producing a misleading hint when the parser had already
   pinpointed the structural problem. Now suppressed when an error-severity
   issue with the same tag already exists.

Test coverage:
  + accountType asserted to be 'T' (not '"T"') in both 2025 + 2026 shapes.
  + VER aggregate-warning test now uses #VER lines without { } blocks
    (silent loss, no per-record error) — the canonical case the diagnostic
    is designed for.
  + New suppression test: bare #VER produces per-record errors AND the
    aggregate warning is absent.

75/75 sie-parser tests pass; 156/156 in lib/import.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* wip: agent chat + composer + memory + document extraction

In-progress work on this branch beyond the SIE-import fixes:
- Specialized accountant agent (composer + intents + chat loop)
- Persistent agent_conversations/messages, agent_profiles, agent_memory
- /chat surface + /onboarding/agent + /settings/agent-memory
- document-extraction extension with status hooks
- MCP server staging refactor + new skills (atoms, bank reconciliation,
  customer onboarding, kreditfaktura)
- pending_operations rejection feedback (category + reason) + realtime
- TIC company profile cached snapshot on companies
- 17 migrations (all additive — see prior conversation analysis)

Parked while branch waits for review/merge. Migrations are already
applied to prod.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(tic): migrate company-data client from api-core v1 to Lens v2

Swaps the seven TIC company-data endpoints we call from the api-core
paths (`/datasets/companies/{companyId}/...`, `/search/companies`) to
the Lens equivalents (`/companies/{id}/...`, `/search-public/companies`).
Hard cutover; proxy pattern preserved.

Schema shifts handled inside the extension so consumers (TicWorkspace,
Step2CompanyDetails) don't need changes:

- `/companies/{id}/bank-accounts` now returns Bankgirot only — map to
  the existing `{ type, accountNumber, bic }` shape, drop terminated.
- `/companies/{id}/industries` returns a discriminated array — filter
  to `companyIndustryCodeType === 'sni2007'` to preserve v1 behavior.
- `/companies/{id}/phone-numbers` renamed the field to
  `phoneNumberFormatted` (fall back to `e164PhoneNumber`).
- `/companies/{id}/documents` replaces `/financial-report-summaries`;
  filter `type === 'annualReport'` and read nested
  `financialReportMetadata` to rebuild the legacy summary shape.
- `isCeased` is now a top-level boolean; `activityStatus` is an enum.
  Translate enum -> 'ceased' for the workspace's existing check.

BankID identity flow (id.tic.io) is untouched — separate TIC product.

Note: deploy gated on the TIC proxy being flipped to lens-api.tic.io
with an `x-api-key` Lens key.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(tic): expose v2 onboarding & workspace data

Adds six new Lens (v2) fetchers on top of the migration that already
landed in this branch, surfacing the data through /lookup and /profile.

New fetchers in lib/tic-client.ts:
- getFiscalYears          /companies/{id}/fiscal-years
- getAccountingPeriods    /companies/{id}/accounting-periods
- getPayrolls             /companies/{id}/payrolls
- getSignatory            /companies/{id}/signatory
- getRepresentatives      /companies/{id}/representatives
- getCompanyStatus        /companies/{id}/status

/lookup gains a fiscalYear field (current fiscal-year configuration)
so onboarding Step 2 can skip manual MM-DD entry. CompanyLookupResult
extended with optional fiscalYear; consumers without it keep working.

/profile gains five new sections on TICCompanyProfile:
- fiscalYear + fiscalYearHistory   current + deduped period list
- signatory                        firmateckning descriptions
- board + representatives          board-composition summary + active
                                   officers (positionEnd in future)
- payrolls                         payroll2 array newest-first, with
                                   deviation vs annual-report
- statuses                         current+historical status entries
                                   with red/yellow/green/neutral color

TicWorkspace renders the new data as four cards (Status, Fiscal year +
Signatory, Board + Representatives, Payroll history) plus a Badge
mapping for the traffic-light status color.

Tests: 52 -> 60 passing. Added unit tests for the new fetchers' v2
paths, fiscal-year auto-fill in /lookup, and full v2 profile coverage.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(onboarding,agent): lean on TIC v2 to skip Steps 1 & 3 and sharpen Opus

Three small wins that unlock more of the v2 cutover. No new endpoints — the
data was already in the snapshot, just not flowing where it should.

Step 1 (entity_type) — deep-link path only:
- /lookup now returns `legalEntityType` and `registrationDate` (added to
  CompanyLookupResult).
- /onboarding/page.tsx does a server-side /lookup prefetch when
  ?org_number= is present (BankID picker path), maps "AB"/"EF" to the
  EntityType enum, and seeds Step 1's radio. Falls through silently for
  unsupported codes (HB, KB, …) and on TIC errors.
- WelcomeOnboarding hydrates ticLookup state from the server prefetch so
  Step 2's debounced client fetch and Step 3's first-year inference both
  have data on first render — no flash.

Step 3 (is_first_fiscal_year) — every path:
- deriveFirstYearDefaults() parses ticLookup.registrationDate and returns
  { isFirstFiscalYear, firstYearStart } when registered <12 months ago.
  Step 3's initialData picks it up; the user only confirms the end date.
- Settings value wins when present so existing users with a saved choice
  don't get overridden.

Composer prompt:
- redactTic allowlist was the bottleneck — it stripped beneficialOwners,
  signatory, board, representatives, payrolls, statuses, fiscalYear
  before Opus ever saw the JSON. Existing filterRedundantQuestions
  ownership logic was effectively dead because the data path was severed.
  Expanded allowlist to include those v2 sections; kept bankAccounts/
  email/phone/fiscalYearHistory/financialReports out (token cost > signal).
- SYSTEM_PROMPT now documents each v2 section and the rules Opus should
  apply: payroll signal switches from "registration.payroll" to "actual
  payrolls[] filings" (kills the false-positive swedish-payroll selection
  for newly registered employers); beneficialOwners[] becomes the
  authoritative ownership source (single owner → FMB modifier; multiple →
  multi-owner); statuses[] isCeased/red triggers an uncertainty_note.

Tests: 4112 unchanged. Build: green. No schema or migration changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(agent): onboarding polish + composer signal fixes from first-run feedback

UX:
- AgentOnboarding: drop the 10s "Hoppa över — fortsätt med standardval"
  escape hatch. The fallback path runs automatically on timeout; the
  manual skip just teased users into a degraded build.
- ReviewCard step 2 title: "Stämma av detaljerna" → "Stäm av detaljerna"
  (imperative form matches the rest of the steps).
- Drop em-dashes from user-visible Swedish strings in AgentOnboarding +
  ReviewCard (fallback labels, subtitles, placeholder, error message,
  final CTA). Em-dashes survive in code comments only.
- "Fråga min revisor" → "Fråga min assistent" everywhere it surfaced:
  AgentTrigger, AgentSparkleButton, ReviewCard preview, ReviewCard
  fallback comment, general.help intent buttonLabel + prompt text.
- AgentTrigger / AgentSparkleButton / EmptyState.AgentHelpLink /
  TransactionInboxCard ask-button all gated on identity.isVerified.
  Pre-onboarding users no longer see the floating FAB or per-page
  Sparkle buttons. AgentSheetProvider.identity gained an isVerified
  field; (dashboard)/layout.tsx selects agent_profiles.verified_at and
  passes it through.

TIC verksamhetsbeskrivning:
- tic/index.ts /profile: /companies/{id}/purposes returns every
  historical verksamhetsföremål filing. Picking [0] was returning the
  oldest "äga och förvalta" holding-company boilerplate for companies
  whose later filings narrowed the purpose ("tillhandahålla
  företagskrediter och finansiella teknologilösningar"). Sort the
  array by lastUpdatedAtUtc desc and take the most recent non-empty
  purpose.

Composer banking signal:
- loadBankingSummary now reads journal_entry_id alongside
  description/amount/date and returns per-counterparty `direction`
  ('in' | 'out' | 'mixed') and `has_unbooked` (any row not yet booked).
  Aggregate `unbooked_count` accompanies the rollup.
- buildUserPrompt emits each counterparty as
  `Name: 12 345 kr (ut, OBOKFÖRD)` so Opus can tell income from cost
  on sight and tell which counterparties are still open questions.
- SYSTEM_PROMPT now explicitly forbids verification questions about
  counterparties whose direction is unambiguous AND status is 'bokförd'.
  Should kill the regressions from the first agent build:
  * "Konsult, J 98 565 kr — intäkt eller kostnad?" when the amount is
    clearly negative.
  * "ALMI AB 493 000 kr — lån eller bidrag?" when the transaction is
    already categorized.

Tests: 4112 unchanged. Build: green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(agent,ui): representation needs deltagare+syfte, drop duplicate doc icon

Representation booking:
- transaction-categorization prompt now requires the agent to capture
  participants (name + company) AND purpose before staging a
  representation categorization. SKV's representationsregler + ML 8 kap
  require the verifikation to document who attended and what the
  meeting was about; without that the avdrag is denied and the post
  should be booked as non-deductible / personalkostnad.
- The agent confirms back in plain text (audit trail in the chat),
  writes the deltagare + syfte to gnubok_remember_fact (long-term),
  THEN stages. Saknas deltagare/syfte: explicitly tell the user the
  avdrag won't go through and offer the non-deductible alternative.
- Known gap (followup, not this commit): the staged op's journal entry
  description doesn't yet carry the deltagare text. Until we add a
  `notes` field to gnubok_categorize_transaction, the audit trail
  lives in chat + agent_memory only.

TransactionInboxCard duplicate attachment indicator:
- Drop the FileCheck2 "open document" button from the trailing slot.
  TransactionAttachmentIndicator (Paperclip) next to the description
  already opens the underlag on click. Two icons doing the same thing
  was noise. Cleaned up the unused state (isOpeningDoc, hasAttachment,
  handleOpenAttachment) and dropped now-unused imports (FileCheck2,
  useToast).

Tests: 4112. Build: green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(agent,nav): notes on verifikation + redesigned sidebar

Audit-trail notes for representation:
- gnubok_categorize_transaction gains an optional `notes` string.
  Threaded through stagePendingOperation → commitCategorizeTransaction →
  createTransactionJournalEntry, which now appends notes to the entry's
  description (capped at 500 chars). The verifikation an external auditor
  reads now carries deltagare + syfte directly — not just chat history /
  agent_memory.
- transaction-categorization prompt updated: representation flow now
  REQUIRES the agent to pass deltagare+syfte via the notes parameter.
  Without it the booking is non-deductible / personalkostnad per SKV.

DashboardNav redesign:
- Top section: flat, no header — Hem (/chat), Underlag (was
  Dokumentinkorg), Transaktioner, Granskning. Always visible; the inline
  badge on /pending shows the count when there are pending ops.
- Mid section: four collapsible dropdowns (Försäljning, Inköp,
  Redovisning, Personal). Each auto-expands when the active route lives
  inside it. KPI moved from main to Redovisning. Extension nav items
  (TIC workspace, etc.) fold into Redovisning.
- Bottom-left: new account popover (DropdownMenu, opens upward) holding
  CompanySwitcher, Inställningar, Hjälp, Support, Logga ut. Replaces
  the old top company-switcher card + the bottom Support/Logout block.
- Mobile drawer mirrors the new structure: top items as flat list,
  same four dropdown groups, separate "Tillägg" section when
  extensions exist, "Mitt konto" section at the bottom.
- i18n: invoice_inbox label renamed "Dokumentinkorg" → "Underlag"
  ("Documents" in en). New keys: mitt_konto, group_extensions.

Tests: 4112. Build: green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(nav): unhide Leverantörer under Inköp

The /suppliers entry existed in navItems but was marked hidden — leftover
from when the supplier list lived elsewhere in the IA. Removing the
hidden flag puts Leverantörer in the Inköp dropdown alongside
Leverantörsfakturor.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(nav): CompanySwitcher back to top-left, user account moves bottom-left

The previous pass collapsed both concepts into the bottom popover. They
mean different things: the company is the org context everything below
operates against (top-of-sidebar, scannable); the user is the
account-holder (bottom-of-sidebar, where settings/logout live).

- (dashboard)/layout.tsx: fetch profiles.full_name alongside the
  existing identity queries; pass userName + userEmail into
  DashboardNav.
- DashboardNav: restore CompanySwitcher at the top of the sidebar
  (pre-redesign placement). Bottom-left popover trigger now shows the
  signed-in user's name + single-letter initial (accountInitial helper
  falls back to email's first char, then "?"). Popover header carries
  full name + email; items unchanged (Inställningar, Hjälp, Support,
  Logga ut). CompanySwitcher removed from inside the popover — nested
  dropdowns were awkward and the top placement is where it belongs.

Tests: 4112. Build: green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(pending): trim the agent context strip

The row-level AgentContextStrip on /pending was rendering the model
name (eu.anthropic.claude-sonnet-4-6) and the full atoms array
(horizontal/swedish-vat, vertical/konsult-it, …) inline, which made
each row 60–80 chars of mostly-the-same metadata. Reviewers never
scan that text; they scan amounts and decide approve/reject.

Now the strip shows only the conversation deep-link
(Konversation #<short id>) — the one piece that's actually useful for
diving into context. Model + atoms remain available in agent_metadata
for debugging surfaces; they're just not in the list view.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(agent): shared ground rules + paragraph breaks after tool calls

Two regressions surfaced in real usage. Both are systemic.

Shared agent ground rules:
- /chat surface (general.help) was happily inventing four-digit BAS
  account numbers ("Debet 6212 - Molntjänster…", "Kredit 2614 - Ingående
  moms…") and proposing booking decisions on invoices it had never
  seen, with no follow-up questions about currency/scope/etc.
- transaction-categorization had those rules baked into its prompt;
  general-help / bokslut-step / invoice-draft / supplier-invoice-review
  / verifikation-draft / vat-review never inherited them.
- Extracted lib/agent/intents/shared-rules.ts with five cross-cutting
  rules: underlag first (check inbox + ask user to upload to
  Dokumentinkorgen when missing), ask follow-ups when ambiguous, never
  write four-digit BAS account numbers in chat (category names only),
  cite atoms / load skills (don't guess), check counterparty history
  before proposing.
- Injected renderAgentGroundRules() into all six intents above.
  transaction-categorization left alone — it has more detailed inline
  rules tied to its specific underlag-flow.

Paragraph break after tool calls:
- text_delta from the model often resumes after a tool call without a
  leading newline ("kategoriseras." → gnubok_query_journal runs → "Inget
  historik hittades…" appended directly). Markdown rendered the
  concatenation as one paragraph.
- AgentChat text_delta handler now inserts \n\n when (a) the buffer
  ends with text content, (b) the incoming delta starts with text
  content, (c) at least one tool call has run, and (d) the buffer
  doesn't already end with a blank line.

Tests: 4112. Build: green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(nav): default-open dropdown groups; closing is per-user

Dropdowns started collapsed which meant first-time users had to open
each group to discover what's inside. Inverted the state: default open,
user can collapse, active route still forces a group open.

- manualExpanded → manualCollapsed (semantics flip)
- toggleGroup unchanged externally; flips the bit
- isGroupExpanded returns !manualCollapsed[g] || hasActiveChild

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(agent): rate-safe v1→v2 TIC upgrade, counterparty defaults, profile settings

Three pre-ship quality wins.

Rate-limit-safe TIC v2 upgrade:
- The /profile endpoint fans out to ~13 Lens calls; the account has a
  ~3000/mo ceiling. Force-refreshing every pre-v2 (v1) snapshot across
  the customer base would blow the budget.
- ensureTicSnapshot gains an `upgradeV1` flag. A cached snapshot still
  inside the 7-day window is re-fetched only when (a) the caller passes
  upgradeV1 AND (b) the snapshot is v1-shaped (missing the v2-only
  `statuses` key). Gated to the two agent-onboarding call sites — a
  deliberate, once-per-company action and the only consumer of the v2
  sections. Workspace + signup keep the natural 7-day staleness, so the
  v1→v2 migration is lazy and bounded to companies actually building an
  agent.

Known-counterparty defaults (shared-rules):
- Agent now proposes a sensible default for well-known counterparties
  instead of asking the same question monthly: Almi → lån, Tillväxtverket/
  Vinnova/EU-stöd → bidrag, Skatteverket → skatt/avgift or återbäring,
  Bolagsverket → avgift, Försäkringskassan → ersättning, EF private
  withdrawal → eget uttag. Stated as an assumption the user can correct,
  not a hard rule — underlag/history still wins.

Företagsprofil settings page:
- New /settings/agent-profile (Företagsprofil / "Company profile"):
  view + edit the agent's company profile after onboarding — assistant
  name + avatar, the profile summary the agent reasons from, and a
  read-only chip view of loaded specialities (atoms). Backed by the
  existing GET/PATCH /api/agent/profile.
- New GET /api/agent/atom-titles?ids= resolves atom slugs → human titles
  for the chips (registry is globally-readable reference data).
- Added to SettingsSidebar; i18n keys agent_profile (sv "Företagsprofil"
  / en "Company profile").

Note: /chat already redirects unverified users to / (chat layout guard),
and / renders WelcomeGate → /onboarding/agent. No redirect work needed.
AgentSetupBanner.tsx is orphaned dead code (WelcomeGate superseded it).

Tests: 4112. Build: green. Both new routes compile.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(nav,agent): Hem=Översikt + separate Assistent button; memory dedup

Nav restructure:
- "Hem" now points to / (Översikt dashboard) again, not /chat. The agent
  chat gets its own top-level nav entry "Assistent" (Sparkles icon) → /chat.
  Mobile bottom nav mirrors this (Hem / Assistent / Transaktioner).
- / restored to render DashboardContent (the Översikt) for built-agent
  users instead of redirecting to /chat. Users who haven't built their
  assistant yet still get WelcomeGate (the build-agent checklist); once
  verified, / shows the dashboard. Chat is reachable anytime via its nav
  entry. Restored main's dashboard data-fetch; added an agent_profiles
  verified_at probe to drive the WelcomeGate branch.
- i18n: nav.assistant ("Assistent" / "Assistant").

agent_memory dedup (gnubok_remember_fact):
- The agent re-remembers the same fact constantly (e.g. "Vercel = omvänd
  skattskyldighet" on every Vercel categorization), which would bloat
  agent_memory with paraphrases over months.
- Before insert, compare the incoming fact against the 300 most-recent
  active memories by word-set Jaccard similarity (lowercased, punctuation-
  stripped, stopwords dropped). A near-duplicate (≥0.82) is treated as
  already-known: bump its relevance toward the new score + refresh
  updated_at instead of writing a new row. Embedding-free, zero added
  latency beyond one bounded SELECT.

Tests: 4112. Build: green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(agent,nav): företagsprofil=Bolagsuppgifter, avatar nav icon, dedupe greeting

Företagsprofil settings page (the right content this time):
- Replaced the agent atoms/summary panel with CompanyProfileView — a
  read-only "Bolagsuppgifter" view of the cached TIC company snapshot
  (name, org-nr, form, address, F-skatt/Moms/Arbetsgivare, SNI, bank,
  verksamhet, employees, latest financials, status traffic-lights,
  fiscal year, firmateckning, företrädare). Server component reads the
  companies.tic_snapshot column directly — no extension import, stays
  inside the core-build boundary.
- Route renamed /settings/agent-profile → /settings/company-profile.
  Removed the old AgentProfilePanel + the now-unused /api/agent/atom-titles
  endpoint.

"Assistent" nav icon = the agent's chosen avatar:
- DashboardNav reads agent identity from AgentSheetProvider and renders
  the onboarding-chosen avatar for the /chat ("Assistent") entry across
  desktop sidebar, mobile drawer, and mobile bottom nav. Falls back to
  the Sparkles glyph pre-onboarding (no avatar yet).

Nav cleanup:
- Dropped the beta badge from Underlag.
- Filtered the TIC workspace (/e/general/tic, "Företagsprofil") out of
  the nav — the same Bolagsuppgifter now lives under Inställningar →
  Företagsprofil, so it shouldn't appear in two places.

Doubled intake greeting fix:
- /chat/intake fires an invoke with no conversation_id, then swaps the
  URL to /chat/[id] the instant the `conversation` event lands — which
  can beat the greeting being persisted. /chat/[id] then hydrated with 0
  messages and, because the auto-fire guard keyed on (id && messages>0),
  fired a SECOND invoke on the same conversation → two greetings.
  Guard now keys on conversation-id presence alone: a set id means
  resume, never bootstrap. Closes the race.

Tests: 4112. Build: green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(agent): paragraph-break-after-tool split words mid-stream

The earlier "insert \n\n when text resumes after a tool call" heuristic
re-evaluated on EVERY text_delta (any delta not starting/ending with
whitespace, once a tool had run). Streaming deltas arrive in sub-word
chunks, so it injected breaks between fragments of the same word:
"minnes\n\nno\n\nterna", "kund\n\nrep\n\nresentation".

Replace the per-delta heuristic with a consume-once ref:
- tool_use sets breakBeforeNextTextRef = true
- the next text_delta consumes it: prepends \n\n exactly once (only when
  the buffer has content, doesn't already end in whitespace, and the
  delta doesn't start with whitespace), then clears the flag

So the break fires once per tool→text resume, never mid-word.

Build: green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(agent): much shorter replies, representation headcount + VAT cap, dot separator

Brevity (system-prompt Svarsformat — affects every reply):
- Hard "korthet är regel nummer ett": aim for 2-4 sentences, lead with
  the answer/action, no warm-up ("Här är vad som gäller…"), don't derive
  VAT in prose, don't restate what the approval card shows, one question
  at a time. The agent was writing textbook-length essays.

Representation rule now in shared-rules (so verifikation-draft, vat-review,
etc. all get it — previously only transaction-categorization had it, which
is why the verifikation flow guessed 25% VAT and skipped the cap):
- Require ANTAL deltagare (headcount), not just one name — the moms
  deduction is per person (underlag cap 300 kr/person ex moms).
- Use the receipt's ACTUAL VAT rate (usually 12% on food), never assume
  25%.
- Meal representation isn't income-tax deductible (post-2017); whole cost
  booked as non-deductible representation.

Verifikation description separator:
- createTransactionJournalEntry appended notes with an em-dash
  ("Utlägg Eatnam — Deltagare:…"), violating house style. Switched to a
  middle dot " · ". journal_entries has no separate notes column — the
  description IS the BFL verifikationstext / audit field, so deltagare +
  syfte correctly live there.

Tests: 4112. Build: green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(settings): tidy Bolagsuppgifter — no status colours, clean firmateckning

From first-look feedback on the Företagsprofil page:

- Status: dropped the coloured traffic-light badges (red/yellow/green).
  Per the design system semantic colour is data-only, never chrome, so
  status now renders as plain label + date. Also filtered to dated
  entries only — Bolagsverket emits flags like "Har aldrig varit verksam"
  with no date that read as noise next to the real status. Ceased status
  gets muted destructive text (the one chrome colour the system keeps).

- Firmateckning: the source text carries ">" list markers and crams
  several rules onto one line, and repeats "Firman tecknas av styrelsen"
  across rows. cleanSignatory() strips the markers, normalises whitespace,
  splits run-on "Firman tecknas …" clauses onto separate lines, and the
  render dedupes — so each rule reads as its own sentence.

Build: green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(mcp): inbox items expose all terminal links + processed flag

The Eatnam receipt was booked against its bank transaction (so the inbox
row had matched_transaction_id + created_journal_entry_id set), yet the
agent reported it as loose/unmatched and a duplicate risk. Root cause:
gnubok_list_inbox_items only selected and returned matched_supplier_id +
created_supplier_invoice_id — the supplier-invoice path. The
transaction-match and direct-journal-entry paths were invisible, so any
receipt cleared via /transactions looked unprocessed.

- list_inbox_items now selects + returns matched_transaction_id and
  created_journal_entry_id alongside the supplier fields, plus a derived
  `processed` boolean (true when ANY of the three terminal links is set).
- New unprocessed_only=true input filters to items with no terminal link
  — the "what still needs handling" view that prevents the agent from
  flagging already-booked docs as duplicates. (Fetches a wider window
  then filters client-side so limit applies post-filter.)
- Description updated to document the processed semantics, within the
  280-char tool-description budget.

The DB linkage itself already worked: /transactions attach-document sets
matched_transaction_id, and commitCategorizeTransaction stamps
created_journal_entry_id. This was purely a read/surface gap.

Tests: 4112 (+ MCP description guard). Build: green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(mcp): repair stage-but-never-commit tools + consolidate tool surface

- post_annual_depreciation AND reverse_entry were never in the pending_operations operation_type CHECK, so both staged then died with check_violation at INSERT. Add the CHECK migration, a commitPostAnnualDepreciation executor (reusing commitAnnualPostings), risk tier, and the PendingOperationType union member.
- Salary tools de-risked: calculate_salary_run calls runSalaryCalculation() directly (no self-fetch/forged cookie); create_salary_run uses a transactional create-run helper with compensating delete; generate_agi actually generates + persists the declaration.
- import_sie parses + validates at stage time with a content-rich preview (company, fiscal year, voucher/account counts, balance) instead of a blind byte count.
- batch-match-invoices passed user.id where companyId was expected (silently matched zero).
- VAT report+widget merged behind render_ui; gnubok_search_tools ranks by relevance; gnubok_feedback readOnlyHint corrected; tools/list instruction text fixed; income decision-tree + GL/query_journal cross-refs added.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(agent): load skill atom bodies from the DB so they survive the build

Skill bodies were read from disk at runtime (.claude/skills/**/SKILL.md); on Vercel the dynamic readFile path isn't traced into the lambda and on Docker .claude/ is excluded, so atoms loaded EMPTY in production — a despecialized agent. Inline the bodies into agent_atom_registry instead:
- Migration adds body + mcp_exposed columns; a build-time generator (scripts/generate-skill-bodies.ts) emits a deterministic dollar-quoted seed migration with a content-hash manifest + --check CI guard.
- Read sites (mcp-server atoms.ts, chat system-prompt.ts, composer prewarm) read body from the DB, with a dev-only disk fallback. mcp_exposed curates which atoms the MCP exposes (swarm-* never become atoms).
- The seed script + generator share scripts/lib/atom-discovery.ts; estimated_tokens now reflects SKILL.md only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(agent): safe the in-app assistant — gating, FAB de-confliction, rate limit, friendly errors

- Hide all agent entry points until verified_at: the Assistent nav tab (sidebar + mobile) and the agent-memory settings tab now match the floating FAB's gate.
- FAB de-confliction: /kpi -> kpi.explain and /bookkeeping/year-end -> bokslut.step so the floating button opens the SAME assistant as the page button (no two-agents-on-one-page).
- Generous per-user rate limit (30/min, 1000/day) on /api/agent/invoke, /onboarding/stream, /composer via a new agent_rate_counters table + check_and_increment_agent_quota RPC; fails open. Bounds runaway Bedrock spend without touching normal users.
- Friendly errors: Bedrock 429/timeout/5xx normalized to Swedish (friendlyModelError) in run-turn + the invoke route; the chat client surfaces the server's friendly message instead of a raw HTTP status.
- /chat/new validates ?intent= against the registry so bad deep-links fall back to general.help instead of rendering a broken-looking error.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(agent): keep /chat read-only — redirect categorization + swap the "categorize" suggestion for a VAT-report question

general.help (the /chat assistant) is read-only, but it still gave per-transaction bokföringsförslag in prose and asked "godkänner du dessa?" — an analysis the user can't act on (no write tool, no per-tx underlag). Strengthen the prompt to redirect categorization/bokföring to the per-transaction flow (open the transaction -> "Fråga om denna transaktion", where the agent sees the underlag and stages a real ApprovalCard); a short overview is still allowed. Add a guard test locking in no-write-tools + the redirect language. Swap the /chat empty-state "Hjälp mig kategorisera" chip (which lured users into exactly this dead-end) for a VAT-report question the read-only assistant can actually answer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(pending): declutter the review queue rows + header

Fold the conversation deep-link onto the actor label (drop the separate
"Konversation #xxxx" strip and its icon), hide the quick-pick when there's
only one operation type (it duplicated "Markera alla"), and drop the "(0)"
from the disabled bulk-approve button.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(vat): enhance VAT handling by integrating document validation and improving error messaging

* feat(settings): add assistant knowledge surface + consolidate settings tabs

Expose the agent's skill atoms (agent_atom_registry) in a read-only surface
beside the existing memory view, and tighten the settings tab bar from 14 to
10 tabs.

- New GET /api/agent/skills + AgentSkillsPanel: lists active, mcp_exposed
  atoms grouped by tier (Kärnkompetens / bransch / bolagssituation), flags
  which are active for the company from agent_profiles, and lazy-loads each
  SKILL.md body on expand.
- New /settings/assistant tab with a Minne/Kompetens toggle (?view=skills);
  /settings/agent-memory and /settings/agent-skills redirect into it.
- Merge Företagsprofil (TIC snapshot) into the Företag tab via
  CompanyProfileSection; /settings/company-profile redirects.
- Merge Skatteverket-anslutningen into the Skatt tab — OAuth returnTo and the
  callback toast now target /settings/tax; /settings/skatteverket redirects.
- Drop the Säkerhetsbackup tab (already under Importera/Exportera).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(inbox): keep booked underlag out of the unmatched queue + widen match window

- categorize: after booking an inbox underlag onto a verifikat, backfill the
  inbox row's matched_transaction_id + created_journal_entry_id so it stops
  showing as unmatched (mirrors the /attach-document paperclip path).
- TransactionMatchPicker: bias the candidate window forward (60d before →
  180d after the invoice date) so late payments aren't dropped before scoring,
  and widen the ranking date tolerance to 120d so the true match floats to the
  top instead of collapsing to "Svag match". Fix "okatigoriserade" typo.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* wip: bundle in-progress branch work + agent onboarding chat optimizations

Captures the uncommitted work-in-progress on this branch so it lives on the
remote. Heterogeneous changeset — bundled as one commit since the work was
already entangled across files.

Headline change in this commit (from this session):
- Remove the double interview in agent onboarding. Phase B's verification-
  question form stepper is gone — the Phase C chat (onboarding.intake) now
  owns the entire interview and reads the composer's verification_questions
  server-side as its question bank.
- ReviewCard collapses from 3 steps to 2 (meet → review-and-confirm) with
  value-first ordering: profile + "vad jag kan hjälpa dig med" + facts +
  optional seed note. CTA reads "Möt {namn}" to signal the chat follows.
- ChatIntakeStarter handoff subcopy updated to match reality (assistant
  greets first; user can leave anytime).
- Stamp agent_profiles.intake_completed_at server-side in
  app/api/agent/invoke/route.ts on the first user-typed reply in any
  onboarding.intake conversation (idempotent IS NULL guard, best-effort).
  Closes the previously dead-write column and unlocks the opportunistic-
  follow-up hook the migration anticipated.

Plus in-progress branch work being carried forward (not introduced here):
agent runtime + intent prompts, composer + atom-discovery scripts, MCP
server skills surface, onboarding flow components, dashboard/inbox tweaks,
two new agent_atom_registry migrations, additional agent-chat tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(agent): drop inline "Fråga assistenten" affordances — rely on the FAB

The bottom-right "Fråga {namn}" FAB (AgentTrigger) is already route-aware
and picks the right intent per page, so duplicating it as inline page-
header buttons and empty-state links is noise. Removed:

- EmptyState `agentHelp` link ("Eller fråga {namn} hur du kommer igång")
  + the AgentHelpLink component + agent_default_name/agent_ask_link i18n
  keys + the agentHelp props on EmptyInvoices/EmptyCustomers/EmptyTransactions.
- AgentSparkleButton on /bookkeeping (verifikation.draft) and /kpi
  (kpi.explain) page headers.

The FAB stays — when verified, it appears on those routes and routes to
the right intent automatically.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(agent): gate the last two ungated "Fråga assistenten" affordances

Both surfaces previously called useAgentSheet directly without checking
identity.isVerified, so they appeared pre-onboarding (everywhere else the
FAB / sparkle buttons / /chat / Assistent nav are all gated on verified_at).

- Settings page header: remove the "Fråga {namn}" pill entirely. The FAB
  covers /settings routes route-aware (settings.help) — no need for a
  duplicate inline trigger.
- Invoice inbox transaction picker: hide the "Fråga assistenten" button
  when the agent isn't built. Done at the parent (InvoiceInboxWorkspace)
  by passing onAskAssistant only when identity.isVerified is true; the
  child renders the button only when the callback is present.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(tic,onboarding,agent): single-call TIC lookup + director-aware narrative voice

- TIC: collapse the company lookup from 6 endpoint calls to 1
  (search-public already exposes sniCodes, bank accounts, emails, phones,
  and registration flags). Derive fiscal-year MM-DD from
  mostRecentFinancialSummary; newly-registered companies fall through to
  the client's first-year defaults.
- Onboarding: BankID picker no longer auto-provisions companies. Every
  pick routes through the wizard with orgnr (and entity_type via the
  CompanyRoles match) prefilled; F-skatt/VAT/address get confirmed in
  steps 2-4 instead of being auto-fetched. createCompanyFromOnboarding
  reuses CompanyLookupResult and adds a defensive top-level catch so
  server-action errors surface to the UI instead of being redacted.
- Agent composer: loadUserDirectorship() checks BankID CompanyRoles for
  a director-like position (ceo/boardMember/chairman/externalSignatory,
  active) before the narrative uses second-person ownership voice
  ("Du driver…"); unknown users get neutral third-person voice so we
  never put ownership words in the user's mouth.

Tests cover loadUserDirectorship, narrative voice, tic-fetch path,
onboarding page, and updated TIC client + lookup/profile suites.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(tic): extend agent-onboarding TIC budget to 10s + backfill stranded org_numbers

The 5s TIC fetch timeout aborted client-side before the upstream Lens
fan-out (~13 calls) could complete, but the in-flight upstream calls
still counted against quota — actions.ts already documents ~530 wasted
calls from this in May. Same bug still applied to the agent-onboarding
stream path. Adds an optional `timeoutMs` to `ensureTicSnapshot` so
deliberate wait-screen callers (agent onboarding stream) can run with
10s while background/dev callers stay on the conservative 5s default.

Page-level server fetch (page.tsx) intentionally stays at 5s to avoid
blocking TTFB without a visible progress affordance.

Backfill migration mirrors `company_settings.org_number` to
`companies.org_number` for the 105 cases where it's safe (after dedup
+ conflict filtering). 56 of those are on active companies — unblocks
duplicate guards, SIE/SRU exports, and TIC fallback chain. Zero TIC
API calls — pure data move. Idempotent.

Also sweeps a pre-existing SSRF guard on the stream route's origin
derivation that was sitting unstaged in the working tree — it lives in
the same diff hunks as the TIC budget change and couldn't be split cleanly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* wip: bundle in-progress branch work

Sweep up uncommitted agent/MCP/RLS work-in-progress so the branch is fully
backed up to origin. Not reviewed in detail — committed as-is to preserve
working state alongside the TIC fixes in the previous commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(agent): tag the "Bygg din bokföringsassistent" CTA as Beta

Adds a Beta badge next to the assistant-setup heading on the dashboard
banner, dashboard inline card, and onboarding checklist row. Also drops
the stale "Gratis i 30 dagar" subline from the dashboard card.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(build,migrations): PendingOperationType salary ops + resolve migration version collisions

PR #584 went red on three things:

1. core-only build / Vercel: `lib/pending-operations/commit.ts:2666` switched on
   'create_salary_run' and 'generate_agi' but `PendingOperationType` was missing
   both literals. Add them to the union.

2. Supabase preview: migration version 20260526120000 collided with main's
   newly-merged 20260526120000_fix_replace_sie_import_hard_delete.sql.
   Bump the branch's pair to 20260526120050 / 20260526120051 — still ahead of
   20260526120100_restvardeavskrivning so ordering is preserved.

3. 20260527170000 was used twice on this branch
   (_agent_rls_with_check + _journal_entry_no_doc_required). Bump the second
   to 20260527170100 so the pair stays orderable and Supabase doesn't choke
   on the duplicate schema_migrations PK.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ci): reword comment so core-only guard stops flagging it

The "Check no core imports from extensions" step greps for the literal
\`from '@/extensions/\` across lib/, app/api/, components/. A comment in
lib/agent/composer/tic-fetch.ts quoted the exact pattern verbatim to
explain *why* the file does a self-fetch instead of importing the TIC
extension directly — which the grep matched even though no actual
import exists.

Rewrite the line to keep the same meaning without the literal pattern.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Emil <emilmattsson14@gmail.com>
This commit is contained in:
Jakob Wennberg
2026-05-28 11:27:01 +02:00
committed by GitHub
co-authored by Claude Opus 4.7 Emil
parent a9b43ebeb7
commit f53725b20a
243 changed files with 48104 additions and 1543 deletions
@@ -0,0 +1,174 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { loadUserDirectorship } from '../inputs'
// The composer can only put "Du driver…" in the user's mouth when we have
// evidence the user actually directs this company. The signal: BankID
// CompanyRoles for the active user, matched against this company's orgnr,
// with a director-like positionType still active. These tests pin every
// branch — false positives here means we'd narrate ownership for an
// accountant or employee, which is the exact UX bug we just fixed.
function buildSupabase(opts: {
companyOrgNumber?: string | null
enrichment?: unknown
}) {
return {
from: vi.fn().mockImplementation((table: string) => {
const chain: Record<string, unknown> = {}
const methods = ['select', 'eq', 'maybeSingle', 'single']
for (const m of methods) {
chain[m] = () => {
if (m === 'single' && table === 'companies') {
return Promise.resolve({
data: { org_number: opts.companyOrgNumber ?? null },
error: null,
})
}
if (m === 'maybeSingle' && table === 'bankid_enrichment') {
return Promise.resolve({
data: opts.enrichment ?? null,
error: null,
})
}
return chain
}
}
return chain
}),
} as never
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('loadUserDirectorship', () => {
it('returns confirmedDirector=true when user has a boardMember position at this orgnr', async () => {
const supabase = buildSupabase({
companyOrgNumber: '5560125790',
enrichment: {
company_roles: [
{
companyRegistrationNumber: '5560125790',
positionTypes: ['boardMember'],
positionEnd: null,
},
],
},
})
const result = await loadUserDirectorship(supabase, 'co-1')
expect(result.confirmedDirector).toBe(true)
})
it('returns confirmedDirector=true for ceo role', async () => {
const supabase = buildSupabase({
companyOrgNumber: '5560125790',
enrichment: {
company_roles: [
{
companyRegistrationNumber: '5560125790',
positionTypes: ['ceo'],
positionEnd: null,
},
],
},
})
const result = await loadUserDirectorship(supabase, 'co-1')
expect(result.confirmedDirector).toBe(true)
})
it('matches even when orgnr is hyphen-formatted in CompanyRoles', async () => {
const supabase = buildSupabase({
companyOrgNumber: '5560125790',
enrichment: {
company_roles: [
{
companyRegistrationNumber: '556012-5790',
positionTypes: ['chairman'],
positionEnd: null,
},
],
},
})
const result = await loadUserDirectorship(supabase, 'co-1')
expect(result.confirmedDirector).toBe(true)
})
it('returns confirmedDirector=false when company has no org_number (manual-name signup)', async () => {
const supabase = buildSupabase({
companyOrgNumber: null,
enrichment: { company_roles: [] },
})
const result = await loadUserDirectorship(supabase, 'co-1')
expect(result.confirmedDirector).toBe(false)
})
it('returns confirmedDirector=false when user has no BankID enrichment (email signup)', async () => {
const supabase = buildSupabase({
companyOrgNumber: '5560125790',
enrichment: null,
})
const result = await loadUserDirectorship(supabase, 'co-1')
expect(result.confirmedDirector).toBe(false)
})
it('returns confirmedDirector=false when CompanyRoles has no match for this orgnr', async () => {
const supabase = buildSupabase({
companyOrgNumber: '5560125790',
enrichment: {
company_roles: [
{
companyRegistrationNumber: '5567890123', // different company
positionTypes: ['ceo'],
positionEnd: null,
},
],
},
})
const result = await loadUserDirectorship(supabase, 'co-1')
expect(result.confirmedDirector).toBe(false)
})
it('returns confirmedDirector=false when position has already ended', async () => {
const supabase = buildSupabase({
companyOrgNumber: '5560125790',
enrichment: {
company_roles: [
{
companyRegistrationNumber: '5560125790',
positionTypes: ['boardMember'],
// 1 year ago
positionEnd: new Date(Date.now() - 365 * 24 * 3600_000).toISOString(),
},
],
},
})
const result = await loadUserDirectorship(supabase, 'co-1')
expect(result.confirmedDirector).toBe(false)
})
it('returns confirmedDirector=false for non-director positions (deputyBoardMember, auditor)', async () => {
const supabase = buildSupabase({
companyOrgNumber: '5560125790',
enrichment: {
company_roles: [
{
companyRegistrationNumber: '5560125790',
positionTypes: ['deputyBoardMember', 'auditor'],
positionEnd: null,
},
],
},
})
const result = await loadUserDirectorship(supabase, 'co-1')
expect(result.confirmedDirector).toBe(false)
})
})
@@ -0,0 +1,82 @@
import { describe, it, expect } from 'vitest'
import { fallbackNarrative } from '../fallback'
import type { ComposerInputs } from '../inputs'
// The agent profile narrative has two voices depending on whether the user
// is a verified director at the company:
//
// - confirmed director → "Du driver Coredination AB..."
// - everyone else → "Coredination AB är..."
//
// The Sonnet path injects this via the system prompt (covered manually in
// system-prompt eval), so these tests target the deterministic fallback —
// which is what ships when Sonnet times out or the API key is missing.
// The fallback is the worst-case render and must never put presumptive
// ownership words in a non-director user's mouth.
function makeInputs(overrides: Partial<ComposerInputs>): ComposerInputs {
return {
companyId: 'co-1',
companyName: 'Coredination AB',
entityType: 'aktiebolag',
ticSnapshot: null,
ticFetchedAt: null,
companySettings: null,
sieSummary: null,
bankingSummary: null,
atomIndex: [],
userIsConfirmedDirector: false,
...overrides,
}
}
describe('fallbackNarrative voice branching', () => {
it('uses second-person "Du driver" when user is a confirmed director', () => {
const text = fallbackNarrative(makeInputs({ userIsConfirmedDirector: true }))
expect(text).toMatch(/Du driver Coredination AB som aktiebolag\./)
expect(text).toMatch(/din verksamhet/i)
})
it('uses neutral third-person when user is NOT a confirmed director', () => {
const text = fallbackNarrative(makeInputs({ userIsConfirmedDirector: false }))
expect(text).toMatch(/Coredination AB är ett aktiebolag\./)
// Critical: must not assume the user owns or runs the company.
expect(text).not.toMatch(/\bDu driver\b/)
expect(text).not.toMatch(/\bdin verksamhet\b/i)
})
it('keeps neutral voice for enskild firma when not confirmed', () => {
const text = fallbackNarrative(
makeInputs({
entityType: 'enskild_firma',
companyName: 'Anna Andersson',
userIsConfirmedDirector: false,
}),
)
expect(text).toMatch(/Anna Andersson är en enskild firma\./)
expect(text).not.toMatch(/Du driver/)
})
it('uses second-person for enskild firma when director is confirmed', () => {
const text = fallbackNarrative(
makeInputs({
entityType: 'enskild_firma',
companyName: 'Anna Andersson',
userIsConfirmedDirector: true,
}),
)
expect(text).toMatch(/Du driver Anna Andersson som enskild firma\./)
})
it('falls back gracefully when entityType is unknown', () => {
const text = fallbackNarrative(
makeInputs({
entityType: 'handelsbolag',
userIsConfirmedDirector: false,
}),
)
// Generic neutral form — still no "Du driver".
expect(text).toMatch(/Coredination AB är ett företag/)
expect(text).not.toMatch(/Du driver/)
})
})
@@ -0,0 +1,54 @@
import { describe, expect, it } from 'vitest'
import { AtomSelectionSchema, ATOM_SELECTION_TOOL_SCHEMA } from '../schemas'
describe('AtomSelectionSchema', () => {
it('accepts a minimal valid selection', () => {
const result = AtomSelectionSchema.safeParse({
horizontal_atoms: ['horizontal/swedish-vat'],
vertical_atoms: [],
modifier_atoms: [],
is_multi_vertical: false,
verification_questions: [],
uncertainty_notes: [],
})
expect(result.success).toBe(true)
})
it('rejects missing fields', () => {
const result = AtomSelectionSchema.safeParse({
horizontal_atoms: ['horizontal/swedish-vat'],
})
expect(result.success).toBe(false)
})
it('rejects wrong types', () => {
const result = AtomSelectionSchema.safeParse({
horizontal_atoms: 'not-an-array',
vertical_atoms: [],
modifier_atoms: [],
is_multi_vertical: false,
verification_questions: [],
uncertainty_notes: [],
})
expect(result.success).toBe(false)
})
})
describe('ATOM_SELECTION_TOOL_SCHEMA', () => {
it('declares the same required fields as the Zod schema', () => {
// Sanity: both schemas must list the same required keys, or atom selection
// requests will silently drop fields the Zod parser then rejects.
expect(ATOM_SELECTION_TOOL_SCHEMA.required).toEqual([
'horizontal_atoms',
'vertical_atoms',
'modifier_atoms',
'is_multi_vertical',
'verification_questions',
'uncertainty_notes',
])
})
it('forbids additional properties so hallucinated keys fail loudly', () => {
expect(ATOM_SELECTION_TOOL_SCHEMA.additionalProperties).toBe(false)
})
})
@@ -0,0 +1,314 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { ensureTicSnapshot } from '../tic-fetch'
// `ensureTicSnapshot` is the single chokepoint between the agent build path
// and the TIC /profile endpoint. Every TIC call from agent onboarding goes
// through here, and the 3000/mo Lens budget makes the cache / fallback
// branches load-bearing. These tests cover:
//
// - cache hit (fresh): no /profile call
// - cache miss (no snapshot): /profile fetched + persisted
// - cache hit but stale (>7d): refetch + persist
// - cache hit but v1 shape + upgradeV1=true: refetch + persist
// - cache hit but v1 shape + upgradeV1=false: stays v1 (budget protection)
// - /profile fetch fails: returns existing snapshot (degraded, doesn't crash)
// - company has no org_number anywhere: returns fallback null
const ORIGIN = 'http://localhost:3000'
const COMPANY_ID = 'company-uuid'
function buildSupabase(
selectResult: { data: unknown; error: unknown } = { data: null, error: null },
settingsSelectResult: { data: unknown; error: unknown } = { data: null, error: null },
updateResult: { error: unknown } = { error: null },
) {
const updateCalls: unknown[][] = []
const fromCalls: string[] = []
const from = vi.fn().mockImplementation((table: string) => {
fromCalls.push(table)
// Build a chain that resolves to whichever select result matches the
// table. The function under test queries `companies` first, then
// optionally `company_settings`, then `companies.update`.
const chain: Record<string, unknown> = {}
const methods = ['select', 'eq', 'limit', 'maybeSingle', 'single']
for (const m of methods) {
chain[m] = () => {
if (m === 'single') {
return Promise.resolve(table === 'companies' ? selectResult : settingsSelectResult)
}
if (m === 'maybeSingle') {
return Promise.resolve(table === 'companies' ? selectResult : settingsSelectResult)
}
return chain
}
}
chain.update = (payload: unknown) => {
updateCalls.push([payload])
const updateChain: Record<string, unknown> = {}
updateChain.eq = () => Promise.resolve(updateResult)
return updateChain
}
return chain
})
return {
supabase: { from } as never,
fromCalls,
updateCalls,
}
}
beforeEach(() => {
vi.stubGlobal('fetch', vi.fn())
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('ensureTicSnapshot — cache hit', () => {
it('returns cached snapshot without hitting TIC when the row is fresh and v2-shaped', async () => {
const fetchedAt = new Date(Date.now() - 60_000).toISOString() // 1 min ago
const cached = { statuses: [], companyName: 'Cached AB' }
const { supabase, fromCalls } = buildSupabase({
data: { org_number: '5560125790', tic_snapshot: cached, tic_snapshot_fetched_at: fetchedAt },
error: null,
})
const result = await ensureTicSnapshot({
supabase,
companyId: COMPANY_ID,
cookieHeader: '',
origin: ORIGIN,
})
expect(result.source).toBe('cached')
expect(result.snapshot).toEqual(cached)
expect(fetch).not.toHaveBeenCalled()
// Only the companies SELECT should have run — no profile fetch, no update.
expect(fromCalls).toEqual(['companies'])
})
it('does NOT call /profile when cache is fresh and upgradeV1=false, even if snapshot is v1', async () => {
// V1 snapshot = no `statuses` key. Without upgradeV1=true, we accept it
// as-is to protect the TIC budget across the customer base.
const v1Snapshot = { companyName: 'V1 AB' /* no `statuses` */ }
const fetchedAt = new Date(Date.now() - 60_000).toISOString()
const { supabase } = buildSupabase({
data: { org_number: '5560125790', tic_snapshot: v1Snapshot, tic_snapshot_fetched_at: fetchedAt },
error: null,
})
const result = await ensureTicSnapshot({
supabase,
companyId: COMPANY_ID,
cookieHeader: '',
origin: ORIGIN,
// upgradeV1 omitted -> defaults to false
})
expect(result.source).toBe('cached')
expect(result.snapshot).toEqual(v1Snapshot)
expect(fetch).not.toHaveBeenCalled()
})
})
describe('ensureTicSnapshot — cache miss & refetch', () => {
it('fetches /profile and persists when no snapshot exists', async () => {
const profile = { statuses: [], companyName: 'Fresh AB' }
vi.mocked(fetch).mockResolvedValue(
new Response(JSON.stringify({ data: profile }), { status: 200 }),
)
const { supabase, updateCalls } = buildSupabase({
data: { org_number: '5560125790', tic_snapshot: null, tic_snapshot_fetched_at: null },
error: null,
})
const result = await ensureTicSnapshot({
supabase,
companyId: COMPANY_ID,
cookieHeader: 'sb-auth=abc',
origin: ORIGIN,
})
expect(result.source).toBe('fetched')
expect(result.snapshot).toEqual(profile)
// Hit the /profile endpoint with cookie forwarded
expect(fetch).toHaveBeenCalledTimes(1)
const fetchUrl = vi.mocked(fetch).mock.calls[0][0] as string
expect(fetchUrl).toContain('/api/extensions/ext/tic/profile')
expect(fetchUrl).toContain('org_number=5560125790')
// Persisted via UPDATE
expect(updateCalls).toHaveLength(1)
const persisted = updateCalls[0][0] as Record<string, unknown>
expect(persisted.tic_snapshot).toEqual(profile)
expect(persisted.tic_snapshot_fetched_at).toBeDefined()
})
it('refetches when cached snapshot is stale (>7 days)', async () => {
const eightDaysAgo = new Date(Date.now() - 8 * 24 * 3600_000).toISOString()
const oldSnapshot = { statuses: [], companyName: 'Old AB' }
const freshProfile = { statuses: [], companyName: 'Fresh AB' }
vi.mocked(fetch).mockResolvedValue(
new Response(JSON.stringify({ data: freshProfile }), { status: 200 }),
)
const { supabase } = buildSupabase({
data: {
org_number: '5560125790',
tic_snapshot: oldSnapshot,
tic_snapshot_fetched_at: eightDaysAgo,
},
error: null,
})
const result = await ensureTicSnapshot({
supabase,
companyId: COMPANY_ID,
cookieHeader: '',
origin: ORIGIN,
})
expect(result.source).toBe('fetched')
expect(result.snapshot).toEqual(freshProfile)
expect(fetch).toHaveBeenCalledTimes(1)
})
it('refetches v1 snapshot when upgradeV1=true, even if still fresh', async () => {
const v1Snapshot = { companyName: 'V1 AB' /* no statuses */ }
const v2Profile = { statuses: [], companyName: 'V2 AB' }
vi.mocked(fetch).mockResolvedValue(
new Response(JSON.stringify({ data: v2Profile }), { status: 200 }),
)
const { supabase } = buildSupabase({
data: {
org_number: '5560125790',
tic_snapshot: v1Snapshot,
tic_snapshot_fetched_at: new Date().toISOString(),
},
error: null,
})
const result = await ensureTicSnapshot({
supabase,
companyId: COMPANY_ID,
cookieHeader: '',
origin: ORIGIN,
upgradeV1: true,
})
expect(result.source).toBe('fetched')
expect(result.snapshot).toEqual(v2Profile)
expect(fetch).toHaveBeenCalledTimes(1)
})
})
describe('ensureTicSnapshot — degraded paths', () => {
it('returns fallback null when companies row does not exist', async () => {
const { supabase } = buildSupabase({ data: null, error: null })
const result = await ensureTicSnapshot({
supabase,
companyId: COMPANY_ID,
cookieHeader: '',
origin: ORIGIN,
})
expect(result.source).toBe('fallback')
expect(result.snapshot).toBeNull()
expect(fetch).not.toHaveBeenCalled()
})
it('falls back to company_settings.org_number when companies.org_number is null', async () => {
const profile = { statuses: [], companyName: 'EF Person' }
vi.mocked(fetch).mockResolvedValue(
new Response(JSON.stringify({ data: profile }), { status: 200 }),
)
const { supabase } = buildSupabase(
{ data: { org_number: null, tic_snapshot: null, tic_snapshot_fetched_at: null }, error: null },
{ data: { org_number: '8001011231' }, error: null },
)
const result = await ensureTicSnapshot({
supabase,
companyId: COMPANY_ID,
cookieHeader: '',
origin: ORIGIN,
})
expect(result.source).toBe('fetched')
// Org number from company_settings flowed into the profile URL
const fetchUrl = vi.mocked(fetch).mock.calls[0][0] as string
expect(fetchUrl).toContain('org_number=8001011231')
})
it('returns fallback null when no org_number is available anywhere', async () => {
const { supabase } = buildSupabase(
{ data: { org_number: null, tic_snapshot: null, tic_snapshot_fetched_at: null }, error: null },
{ data: { org_number: null }, error: null },
)
const result = await ensureTicSnapshot({
supabase,
companyId: COMPANY_ID,
cookieHeader: '',
origin: ORIGIN,
})
expect(result.source).toBe('fallback')
expect(result.snapshot).toBeNull()
expect(fetch).not.toHaveBeenCalled()
})
it('returns existing (stale) snapshot when /profile fetch fails', async () => {
const staleSnapshot = { companyName: 'Stale AB' }
vi.mocked(fetch).mockResolvedValue(new Response('upstream down', { status: 502 }))
const { supabase } = buildSupabase({
data: {
org_number: '5560125790',
tic_snapshot: staleSnapshot,
tic_snapshot_fetched_at: new Date(Date.now() - 8 * 24 * 3600_000).toISOString(), // forces refetch
},
error: null,
})
const result = await ensureTicSnapshot({
supabase,
companyId: COMPANY_ID,
cookieHeader: '',
origin: ORIGIN,
})
expect(result.source).toBe('fallback')
// Degrade to the stale snapshot rather than crash — the agent build path
// depends on this contract so a TIC outage never blocks onboarding.
expect(result.snapshot).toEqual(staleSnapshot)
})
it('returns existing snapshot when /profile fetch throws (network error / timeout)', async () => {
const staleSnapshot = { companyName: 'Stale AB' }
vi.mocked(fetch).mockRejectedValue(new Error('ECONNREFUSED'))
const { supabase } = buildSupabase({
data: {
org_number: '5560125790',
tic_snapshot: staleSnapshot,
tic_snapshot_fetched_at: new Date(Date.now() - 8 * 24 * 3600_000).toISOString(),
},
error: null,
})
const result = await ensureTicSnapshot({
supabase,
companyId: COMPANY_ID,
cookieHeader: '',
origin: ORIGIN,
})
expect(result.source).toBe('fallback')
expect(result.snapshot).toEqual(staleSnapshot)
})
})
+459
View File
@@ -0,0 +1,459 @@
import { getAnthropic, OPUS_MODEL } from './client'
import { AtomSelectionSchema, ATOM_SELECTION_TOOL_SCHEMA, type AtomSelection } from './schemas'
import type { ComposerInputs } from './inputs'
const SYSTEM_PROMPT = `Du komponerar en specialiserad svensk bokföringsassistent åt ett företag.
Du får:
- Företagets TIC-snapshot från Bolagsverket / Lens API. Inkluderar utöver grundfält (org-nummer, juridisk form, SNI, F-skatt/moms/arbetsgivarregistrering, anställdaintervall, omsättningsintervall, verksamhetsbeskrivning, senaste finansiella rapporter):
- statuses[]: nuvarande och historiska bolagsstatus med trafikljus (red/yellow/green/neutral) och isCeased-flagga. Om isCeased eller red: kompositionen ska FORTSÄTTA men nämn det i uncertainty_notes.
- signatory[]: firmateckningsregler i fritext ("Firman tecknas av styrelsen", "två i förening", "av en ledamot ensam"). En enda ledamot som tecknar ensam pekar starkt mot enpersonsbolag.
- board: styrelsesammansättning — numberOfBoardMembers, numberOfDeputyBoardMembers, hasVacancy. Mer än 1 ledamot utan suppleant pekar bort från enpersonsmodifier.
- representatives[]: aktiva personer (CEO, ledamöter, revisor) med positionType. Räkna unika personer för ownership-signal.
- beneficialOwners[]: verklig huvudman per Bolagsverket. AUKTORITATIV ägarstrukturkälla. En enda namngiven owner = bekräftat enpersonsbolag. Två eller fler = multi-owner; välj INTE single-shareholder-ab-fmb.
- payrolls[]: faktiska lönefilingar (payroll2-array per period med antal anställda + summa preliminärskatt). Om TOM trots att registration.payroll = true: arbetsgivaren är registrerad men har inte faktiskt betalat lön ännu. Välj INTE swedish-payroll i det läget — felaktig signal från statisk registrering är vanlig för nystartade AB.
- fiscalYear: nuvarande räkenskapsårskonfiguration med startMonthDay/endMonthDay. Brutet räkenskapsår (annat än 01-01/12-31) är vanligt i konsult-AB och påverkar bokslut-atomvalet.
- KÄNDA FAKTA från företagets inställningar — saker användaren redan har angett (momsperiod, räkenskapsår, F-skatt-status, anställda, bokföringsmetod)
- Eventuell sammanfattning från importerad SIE-fil (topp-konton, topp-motparter, antal år)
- Eventuell sammanfattning från bankhistorik. Varje topp-motpart har:
- belopp i kr (abs)
- riktning: 'in' (intäkt/inbetalning), 'ut' (kostnad/utbetalning), eller 'in+ut'
- bokföringsstatus: 'OBOKFÖRD' (minst en transaktion ej bokförd) eller 'bokförd'
KRITISKT: ställ INTE en verifieringsfråga om en motpart där riktningen är entydig OCH alla transaktioner är bokförda. T.ex. en motpart märkt "(ut, bokförd)" är redan klassad som kostnad och redan kategoriserad. Att fråga "är detta en intäkt eller kostnad?" är fel. Fokusera frågorna på OBOKFÖRD-motparter där det finns en bokningsbeslutning kvar att fatta.
- Ett register över tillgängliga atomer (horizontal/vertical/modifier) med beskrivning, SNI-prefix och utlösare
Din uppgift:
1. Välj ALLA horisontella atomer som är relevanta för verksamheten. De flesta företag behöver swedish-vat, swedish-invoice-compliance och swedish-year-end-closing. Lägg till swedish-payroll BARA om payrolls[] visar faktiska filingar (icke-tom payroll2-array) ELLER KÄNDA FAKTA bekräftar pågående löneutbetalning — inte enbart för att registration.payroll = true. Lägg till SRU/financial-reporting för AB. Lägg till asset-accounting om SIE visar 12xx-konton. Lägg till project-accounting om signalerna pekar mot tjänsteföretag med projekt. Lägg till tax-planning för aktiebolag.
2. Välj noll, en eller flera vertikala atomer (industri) baserat på SNI-prefix, verksamhetsbeskrivning och motpartsmönster. Tomt om ingen passar.
3. Välj modifier-atomer som faktiskt är sanna:
- single-shareholder-ab-fmb: VÄLJ när beneficialOwners[] har exakt en person OCH legal form = AB. Avstå annars (även om bolaget "ser litet ut").
- enskild-firma: om EF.
- small-employer: om payrolls[] visar 1–9 anställda i senaste filing.
4. is_multi_vertical = true endast om företaget faktiskt har två etablerade affärsben.
5. Skriv 3-6 korta svenska verifieringsfrågor som användaren behöver bekräfta — fokusera på de högsta osäkerheterna.
KRITISKT: Ställ INTE frågor vars svar redan finns i KÄNDA FAKTA eller TIC-snapshot. Användaren har redan sagt detta. Att fråga igen är slöseri med deras tid.
- Om "Momsperiod" finns i KÄNDA FAKTA: fråga inte om momsperiod
- Om "Anställda" finns i KÄNDA FAKTA: fråga inte om anställda
- Om TIC visar F-skatt/momsregistrering: fråga inte om det
- Om beneficialOwners[] finns: fråga INTE "vem äger bolaget?" eller "är du ensamägare?" — det är redan auktoritativt besvarat
- Om payrolls[] visar antal anställda: fråga INTE "hur många anställda?"
- Om fiscalYear finns: fråga INTE om räkenskapsårsstart/slut
- Om SNI-koder finns: fråga inte om branschen i allmänhet, men du KAN fråga om en specifik nyans (t.ex. "Säljer ni mest 25%- eller 12%-momsvaror?")
Fokusera istället på frågor vars svar du inte kan se: specifika balansposter (t.ex. "Vad gäller ALMI-beloppet, lån eller bidrag?"), arbetssätt (faktureringscadens, kund-geografi), planerade förändringar (kommande löneutbetalning, expansion, fastighetsförvärv).
6. Skriv 1-3 svenska uncertainty_notes till utvecklaren som granskar valet senare. Inkludera explicit notering om statuses[] visar isCeased eller red-status.
Stil i all text du skriver (verifieringsfrågor och notes): använd ALDRIG tankstreck (— eller –). Använd kommatecken, punkt, eller "till" för intervall ("2,5 till 5 miljoner"). Hård regel.
Använd verktyget compose_agent_profile för att svara. Använd aldrig fritext.`
export async function selectAtoms(inputs: ComposerInputs): Promise<AtomSelection> {
const anthropic = getAnthropic()
const userPrompt = buildUserPrompt(inputs)
const response = await anthropic.messages.create({
model: OPUS_MODEL,
max_tokens: 2048,
system: SYSTEM_PROMPT,
messages: [{ role: 'user', content: userPrompt }],
tools: [
{
name: 'compose_agent_profile',
description: 'Spara den valda atomuppsättningen för företaget.',
input_schema: ATOM_SELECTION_TOOL_SCHEMA,
},
],
tool_choice: { type: 'tool', name: 'compose_agent_profile' },
})
// Forced tool_use guarantees exactly one tool_use block. We still validate
// defensively in case the API ever returns something unexpected.
const toolUse = response.content.find((b) => b.type === 'tool_use')
if (!toolUse || toolUse.type !== 'tool_use') {
throw new Error('Opus did not return a tool_use block')
}
const parsed = AtomSelectionSchema.safeParse(toolUse.input)
if (!parsed.success) {
throw new Error(`Atom selection failed Zod validation: ${parsed.error.message}`)
}
// Enforce that selected atom IDs exist in the registry index we showed
// the model. Hallucinated IDs would silently break the runtime loader.
const knownIds = new Set(inputs.atomIndex.map((a) => a.id))
const allSelected = [
...parsed.data.horizontal_atoms,
...parsed.data.vertical_atoms,
...parsed.data.modifier_atoms,
]
const unknown = allSelected.filter((id) => !knownIds.has(id))
if (unknown.length > 0) {
// Drop unknown IDs rather than failing — composer can still produce a
// useful profile. Surface in uncertainty_notes so a reviewer sees it.
parsed.data.horizontal_atoms = parsed.data.horizontal_atoms.filter((id) => knownIds.has(id))
parsed.data.vertical_atoms = parsed.data.vertical_atoms.filter((id) => knownIds.has(id))
parsed.data.modifier_atoms = parsed.data.modifier_atoms.filter((id) => knownIds.has(id))
parsed.data.uncertainty_notes = [
...parsed.data.uncertainty_notes,
`Composer returned ${unknown.length} unknown atom id(s): ${unknown.join(', ')}`,
]
}
// Belt-and-braces: filter redundant questions deterministically even if
// the model ignored the "do not ask about KÄNDA FAKTA" instruction.
parsed.data.verification_questions = filterRedundantQuestions(
parsed.data.verification_questions,
inputs,
parsed.data.modifier_atoms,
)
return parsed.data
}
// Drops questions whose answer is already settled in company_settings or
// TIC snapshot. Each question is matched against keyword patterns —
// keep this conservative so we never accidentally drop a legitimate
// nuance question (e.g. "Säljer ni mest 25%- eller 12%-momsvaror?" is
// kept even when moms_period is known, because it's about VAT RATE not
// VAT PERIOD).
//
// Exported so the stream endpoint can re-apply it to fallback selections too
// — fallbackAtomSelection generates questions from a template that doesn't
// know about KÄNDA FAKTA. Belt-and-braces against both model misbehavior
// and the fallback path.
export function filterRedundantQuestions(
questions: string[],
inputs: ComposerInputs,
selectedModifiers: string[] = [],
): string[] {
const s = inputs.companySettings
const tic = inputs.ticSnapshot as
| {
registration?: { fTax?: boolean; vat?: boolean; payroll?: boolean }
employeeRange?: string | null
beneficialOwners?: { name: string }[]
}
| null
const knowsMomsPeriod = !!s?.moms_period
const knowsEmployees = s?.has_employees != null || s?.employee_count != null || !!tic?.employeeRange
const knowsFiscalYear = s?.fiscal_year_start_month != null
const knowsFSkatt = s?.f_skatt != null || tic?.registration?.fTax != null
const knowsVatRegistered = s?.vat_registered != null || tic?.registration?.vat != null
const knowsAccountingMethod = !!s?.accounting_method
// Ownership is settled by EITHER: the composer picked the single-
// shareholder modifier, OR Bolagsverket's beneficial-owner register has
// exactly one person on file (sole verklig huvudman per Lag 2017:631).
// Either signal is enough to drop the redundant question.
const ownerCount = Array.isArray(tic?.beneficialOwners) ? tic.beneficialOwners.length : 0
const knowsOwnershipSingle =
selectedModifiers.includes('modifier/single-shareholder-ab-fmb') || ownerCount === 1
const knowsOwners = ownerCount > 0
return questions.filter((q) => {
const lower = q.toLowerCase()
// Ownership ("är du ensamägare", "äger du majoriteten", "vem äger
// bolaget"…) — drop when EITHER the single-shareholder modifier is set
// OR Bolagsverket's beneficial-owner register confirms a single owner.
if (
knowsOwnershipSingle &&
/(ensamägare|enda ägare|majoriteten av aktierna|vem äger|aktieägare|fåmansbolag.*ensam|verksam i bolaget.*ensam)/.test(
lower,
)
) {
return false
}
// Verklig huvudman — if TIC says we have owners, don't ask who they are.
if (knowsOwners && /(verklig huvudman|huvudmän)/.test(lower)) {
return false
}
// Moms period — "månad/kvartal/år" all together is the giveaway.
if (
knowsMomsPeriod &&
lower.includes('momsperiod') &&
(lower.includes('månad') || lower.includes('kvartal') || lower.includes('år'))
) {
return false
}
// Employees — pattern "har bolaget anställda" or "har du anställda".
if (knowsEmployees && /har\s+(bolaget|du|ni|företaget)\s+anställda/.test(lower)) {
return false
}
// Fiscal year — "räkenskapsår" + ("januari"|"month names"|"börjar").
if (knowsFiscalYear && lower.includes('räkenskapsår') && /(börjar|januari|kalenderår|brutet)/.test(lower)) {
return false
}
// F-skatt — "är bolaget registrerat för f-skatt" type questions.
if (knowsFSkatt && /f[-\s]?skatt/.test(lower) && /(registrerad|registrerat|aktiv)/.test(lower)) {
return false
}
// VAT registration — "är ni momsregistrerade" type questions.
if (knowsVatRegistered && /(momsregistrerad|registrerade?\s+för\s+moms)/.test(lower)) {
return false
}
// Accounting method — "fakturametoden eller kontantmetoden".
if (knowsAccountingMethod && /(fakturamet|kontantmet|bokföringsmet)/.test(lower)) {
return false
}
return true
})
}
function buildUserPrompt(inputs: ComposerInputs): string {
const lines: string[] = []
lines.push(`# Företag`)
lines.push(`Namn: ${inputs.companyName}`)
lines.push(`Juridisk form (gnubok): ${inputs.entityType}`)
lines.push('')
const known = buildKnownFacts(inputs)
if (known.length > 0) {
lines.push(`# KÄNDA FAKTA (fråga inte om dessa)`)
for (const line of known) lines.push(`- ${line}`)
lines.push('')
}
if (inputs.ticSnapshot) {
lines.push(`# TIC-snapshot`)
lines.push('```json')
lines.push(JSON.stringify(redactTic(inputs.ticSnapshot), null, 2))
lines.push('```')
if (inputs.ticFetchedAt) lines.push(`Hämtad: ${inputs.ticFetchedAt}`)
lines.push('')
} else {
lines.push(`# TIC-snapshot`)
lines.push('Saknas. Förlita dig på företagsnamn, gnubok-entity_type och övriga signaler.')
lines.push('')
}
if (inputs.sieSummary) {
lines.push(`# SIE-sammanfattning`)
lines.push(`Antal år: ${inputs.sieSummary.year_count}`)
if (inputs.sieSummary.top_accounts.length > 0) {
lines.push('Topp-konton (abs-belopp):')
for (const a of inputs.sieSummary.top_accounts.slice(0, 20)) {
lines.push(` ${a.account.padEnd(8)} ${Math.round(a.abs_amount).toLocaleString('sv-SE')} kr`)
}
}
if (inputs.sieSummary.top_counterparties.length > 0) {
lines.push('Topp-motparter (från transaktionsbeskrivningar):')
for (const c of inputs.sieSummary.top_counterparties.slice(0, 10)) {
lines.push(` ${c.name} — ${Math.round(c.abs_amount).toLocaleString('sv-SE')} kr`)
}
}
lines.push('')
}
if (inputs.bankingSummary) {
lines.push(`# Banktransaktioner (12 mån)`)
if (inputs.bankingSummary.monthly_volume != null) {
lines.push(
`Snittvolym per månad: ${Math.round(inputs.bankingSummary.monthly_volume).toLocaleString('sv-SE')} kr`,
)
}
lines.push(
`Antal obokförda transaktioner: ${inputs.bankingSummary.unbooked_count}`,
)
if (inputs.bankingSummary.top_counterparties.length > 0) {
lines.push('Topp-motparter (riktning + bokföringsstatus):')
for (const c of inputs.bankingSummary.top_counterparties.slice(0, 20)) {
// direction tells Opus whether this counterparty is a source of
// income, a cost, or both. has_unbooked says whether there's still
// a transaction waiting for the user to book — only those are
// legitimate verification-question fodder.
const dirLabel =
c.direction === 'in' ? 'in' : c.direction === 'out' ? 'ut' : 'in+ut'
const bookedLabel = c.has_unbooked ? 'OBOKFÖRD' : 'bokförd'
lines.push(
` ${c.name}: ${Math.round(c.abs_amount).toLocaleString('sv-SE')} kr (${dirLabel}, ${bookedLabel})`,
)
}
}
lines.push('')
}
lines.push(`# Atomregister`)
lines.push('')
lines.push('## Horizontal')
for (const a of inputs.atomIndex.filter((x) => x.tier === 'horizontal')) {
lines.push(`- ${a.id}: ${a.description.slice(0, 240)}`)
}
const verticals = inputs.atomIndex.filter((x) => x.tier === 'vertical')
lines.push('')
lines.push('## Vertical')
if (verticals.length === 0) {
lines.push('(inga vertikala atomer i registret ännu — välj alltid en tom array)')
} else {
for (const a of verticals) {
const sni = a.sni_prefixes.length > 0 ? ` [SNI ${a.sni_prefixes.join(', ')}]` : ''
lines.push(`- ${a.id}${sni}: ${a.description.slice(0, 240)}`)
}
}
const modifiers = inputs.atomIndex.filter((x) => x.tier === 'modifier')
lines.push('')
lines.push('## Modifier')
if (modifiers.length === 0) {
lines.push('(inga modifier-atomer i registret ännu — välj alltid en tom array)')
} else {
for (const a of modifiers) {
lines.push(`- ${a.id}: ${a.description.slice(0, 240)}`)
}
}
return lines.join('\n')
}
// Surfaces user-settled facts in a tight bullet list the composer can scan
// before generating questions. Anything in here is OFF-LIMITS for the
// verification_questions list — the user already said it.
function buildKnownFacts(inputs: ComposerInputs): string[] {
const out: string[] = []
const s = inputs.companySettings
if (s) {
if (s.moms_period) {
const label =
s.moms_period === 'monthly'
? 'månadsvis'
: s.moms_period === 'quarterly'
? 'kvartalsvis'
: s.moms_period === 'yearly'
? 'årligen'
: s.moms_period
out.push(`Momsperiod: ${label}`)
}
if (s.fiscal_year_start_month != null) {
out.push(`Räkenskapsår börjar månad ${s.fiscal_year_start_month}`)
}
if (s.f_skatt != null) {
out.push(`F-skatt: ${s.f_skatt ? 'aktiv' : 'saknas'}`)
}
if (s.vat_registered != null) {
out.push(`Momsregistrerad: ${s.vat_registered ? 'ja' : 'nej'}`)
}
if (s.has_employees != null || s.employee_count != null) {
const ec = s.employee_count
if (typeof ec === 'number') {
out.push(`Anställda: ${ec}`)
} else if (s.has_employees != null) {
out.push(`Anställda: ${s.has_employees ? 'ja' : 'nej'}`)
}
}
if (s.pays_salaries != null) {
out.push(`Betalar ut lön: ${s.pays_salaries ? 'ja' : 'nej'}`)
}
if (s.accounting_method) {
out.push(`Bokföringsmetod: ${s.accounting_method}`)
}
if (s.city) {
out.push(`Säte: ${s.city}`)
}
}
const tic = inputs.ticSnapshot as
| {
registration?: { fTax?: boolean; vat?: boolean; payroll?: boolean }
employeeRange?: string | null
sniCodes?: { code: string; name: string }[]
purpose?: string | null
beneficialOwners?: {
name: string
extentDescription?: string | null
extentCode?: string | null
}[]
}
| null
if (tic) {
if (tic.registration) {
const flags: string[] = []
if (tic.registration.fTax) flags.push('F-skatt')
if (tic.registration.vat) flags.push('moms')
if (tic.registration.payroll) flags.push('arbetsgivare')
if (flags.length > 0) out.push(`Bolagsverket-registreringar: ${flags.join(', ')}`)
}
if (tic.employeeRange) out.push(`Anställdaintervall (TIC): ${tic.employeeRange}`)
if (Array.isArray(tic.sniCodes) && tic.sniCodes.length > 0) {
// Dedupe by code (TIC sometimes returns the same SNI twice).
const seen = new Set<string>()
const codes = tic.sniCodes
.filter((s) => {
if (seen.has(s.code)) return false
seen.add(s.code)
return true
})
.map((s) => `${s.code} ${s.name}`)
.join('; ')
out.push(`SNI: ${codes}`)
}
if (tic.purpose) {
out.push(`Verksamhetsbeskrivning: ${tic.purpose}`)
}
if (Array.isArray(tic.beneficialOwners) && tic.beneficialOwners.length > 0) {
// Verklig huvudman per Bolagsverket — authoritative ownership data.
// Composer must NOT ask "are you the sole owner?" when this is set.
const owners = tic.beneficialOwners
.map((o) => {
const extent = o.extentDescription ?? o.extentCode ?? ''
return extent ? `${o.name} (${extent})` : o.name
})
.join('; ')
out.push(
`Verkliga huvudmän (Bolagsverket): ${owners}${tic.beneficialOwners.length === 1 ? ' — ensam ägare' : ''}`,
)
}
}
return out
}
// Drop fields from the TIC snapshot that the composer doesn't need and that
// inflate token count or carry PII unnecessarily. After the v2 migration we
// include the new ownership/governance/payroll sections — these change atom
// selection materially (payroll signal goes from "is registered" to "has
// actual filings"; ownership signal goes from heuristic to authoritative).
// Excluded: bankAccounts, email, phone, fiscalYearHistory, financialReports
// — high token cost, low atom-selection signal.
function redactTic(snapshot: Record<string, unknown>): Record<string, unknown> {
const allowed = new Set([
'orgNumber',
'companyName',
'legalEntityType',
'registrationDate',
'activityStatus',
'purpose',
'registration',
'sector',
'employeeRange',
'turnoverRange',
'sniCodes',
'address',
'financials',
// v2 governance + ownership — settles redundant questions deterministically
'beneficialOwners',
'signatory',
'board',
'representatives',
// v2 payroll history — distinguishes "registered" vs "has actually filed"
'payrolls',
// v2 status entries — refuse to compose for ceased/liquidated companies
'statuses',
// v2 fiscal year — already exposed as a known fact via fiscal_year_start_month
// but having the raw object lets Opus reason about brutet räkenskapsår
'fiscalYear',
])
const out: Record<string, unknown> = {}
for (const [k, v] of Object.entries(snapshot)) {
if (allowed.has(k)) out[k] = v
}
return out
}
+59
View File
@@ -0,0 +1,59 @@
import AnthropicBedrock from '@anthropic-ai/bedrock-sdk'
let cached: AnthropicBedrock | null = null
// Single AnthropicBedrock client for the agent composer + chat loop. Matches
// the credential surface the rest of the codebase already uses (see
// extensions/general/invoice-inbox/lib/extract-invoice-fields.ts) so:
//
// 1. There's no separate ANTHROPIC_API_KEY to provision and rotate.
// 2. All Claude traffic stays in eu-north-1 — important for Swedish
// accounting data under BFL retention.
// 3. Failures and quotas show up in one AWS surface, not two.
//
// Trade-off vs. the direct Anthropic API: Bedrock's prompt-cache TTL is
// 5 minutes (default) rather than the 1h the plan §10 specifies. We still
// pass `cache_control: { type: 'ephemeral', ttl: '1h' }` in the system
// prompt assembly — Bedrock currently ignores the explicit TTL and uses 5m.
// Cache effectiveness drops on multi-minute gaps but the loop still works.
// Revisit if/when Bedrock exposes longer TTLs or if cost forces the direct
// API.
export function getAnthropic(): AnthropicBedrock {
if (cached) return cached
const awsRegion = process.env.AWS_REGION || 'eu-north-1'
const awsAccessKey = process.env.AWS_ACCESS_KEY_ID
const awsSecretKey = process.env.AWS_SECRET_ACCESS_KEY
// When both static keys are present, pass them. Otherwise omit them so the
// SDK falls back to the AWS credential provider chain (instance profile,
// IRSA, EKS pod identity, ...). The two-overload SDK refuses a mix.
cached =
awsAccessKey && awsSecretKey
? new AnthropicBedrock({ awsRegion, awsAccessKey, awsSecretKey })
: new AnthropicBedrock({ awsRegion })
return cached
}
// Bedrock model IDs. Region prefix `eu.` keeps inference inside eu-north-1.
// Both are env-overridable so ops can swap models without a code deploy.
//
// Per plan §14 the composer's atom-selection call should run on Opus 4.7 for
// the higher-stakes selection reasoning. Opus 4.7 is not yet enabled on this
// AWS Bedrock account (403 "not available for this account" — request access
// on the AWS console under Bedrock → Model access). For now we point OPUS at
// Sonnet 4.6 so the composer still works; atom selection on Sonnet is still
// good — it's a structured-output call via tool_use forcing, not deep
// reasoning. Flip BEDROCK_OPUS_MODEL_ID back to eu.anthropic.claude-opus-4-7
// once Opus access lands.
export const OPUS_MODEL = process.env.BEDROCK_OPUS_MODEL_ID || 'eu.anthropic.claude-sonnet-4-6'
export const SONNET_MODEL = process.env.BEDROCK_SONNET_MODEL_ID || 'eu.anthropic.claude-sonnet-4-6'
// Extended-thinking budgets (budget_tokens) for the chat intents. These are
// ceilings, not floors: the model spends only what a turn needs, so a generous
// cap improves hard turns (multi-source VAT synthesis, anomaly detection)
// without taxing simple ones. run-turn derives max_tokens = budget + 4096, so
// raising these is safe — no manual max_tokens bookkeeping. Tiered to match the
// model split: DEEP for the Opus / heavy-reasoning intents, STANDARD for the
// rest. Early-stage default favours reasoning quality over token cost; dial
// down here in one place if latency/cost ever bites.
export const THINKING_BUDGET_STANDARD = 6000
export const THINKING_BUDGET_DEEP = 12000
+137
View File
@@ -0,0 +1,137 @@
import type { ComposerInputs } from './inputs'
import type { AtomSelection } from './schemas'
// Deterministic atom selection used when the Opus call times out or fails.
//
// Per plan §7 Phase A: "Fall back to a default vertical from SNI prefix and
// a generic horizontal set (vat, invoice, year-end)". The result is good
// enough that the user can proceed; they can refine atom selection in Phase B
// or rebuild later.
//
// We pick deliberately conservatively — better to load a few extra horizontals
// than to miss one. The agent loop pays for cache, not for content; an extra
// 8k tokens of swedish-financial-reporting on a sole-trader profile is cheap
// noise, while missing swedish-vat on any Swedish company is a correctness bug.
export function fallbackAtomSelection(inputs: ComposerInputs): AtomSelection {
const knownIds = new Set(inputs.atomIndex.map((a) => a.id))
const has = (id: string) => knownIds.has(id)
const isAB = inputs.entityType === 'aktiebolag'
const isEF = inputs.entityType === 'enskild_firma'
const tic = inputs.ticSnapshot as
| {
registration?: { payroll?: boolean }
sniCodes?: { code: string; name: string }[]
employeeRange?: string | null
}
| null
const isEmployer = Boolean(tic?.registration?.payroll)
const horizontal: string[] = []
// These three apply to every Swedish business.
pushIfKnown(horizontal, 'horizontal/swedish-vat', has)
pushIfKnown(horizontal, 'horizontal/swedish-invoice-compliance', has)
pushIfKnown(horizontal, 'horizontal/swedish-year-end-closing', has)
pushIfKnown(horizontal, 'horizontal/swedish-accounting-compliance', has)
// SIE and assets are common needs across both entity types.
pushIfKnown(horizontal, 'horizontal/swedish-sie-import-export', has)
pushIfKnown(horizontal, 'horizontal/swedish-asset-accounting', has)
if (isAB) {
pushIfKnown(horizontal, 'horizontal/swedish-financial-reporting', has)
pushIfKnown(horizontal, 'horizontal/swedish-sru-filing', has)
pushIfKnown(horizontal, 'horizontal/swedish-tax-planning', has)
}
if (isEmployer) {
pushIfKnown(horizontal, 'horizontal/swedish-payroll', has)
}
// Vertical fallback: best-effort SNI-prefix match. Empty list is acceptable
// — vertical atoms are not yet authored (Phase 3).
const verticals: string[] = []
const sniCodes = tic?.sniCodes ?? []
if (sniCodes.length > 0) {
for (const atom of inputs.atomIndex) {
if (atom.tier !== 'vertical') continue
const matches = sniCodes.some((sni) =>
atom.sni_prefixes.some((prefix) => sni.code.startsWith(prefix)),
)
if (matches) verticals.push(atom.id)
}
}
// Modifier fallback: pick what we can derive from entity_type + employer flag.
const modifiers: string[] = []
if (isAB) {
pushIfKnown(modifiers, 'modifier/single-shareholder-ab-fmb', has)
}
if (isEF) {
pushIfKnown(modifiers, 'modifier/enskild-firma', has)
}
if (isEmployer) {
pushIfKnown(modifiers, 'modifier/small-employer', has)
}
return {
horizontal_atoms: horizontal,
vertical_atoms: verticals,
modifier_atoms: modifiers,
is_multi_vertical: verticals.length > 1,
verification_questions: buildFallbackQuestions(inputs),
uncertainty_notes: ['Selection produced by deterministic fallback — Opus call failed or was skipped.'],
}
}
function pushIfKnown(arr: string[], id: string, has: (id: string) => boolean) {
if (has(id)) arr.push(id)
}
function buildFallbackQuestions(inputs: ComposerInputs): string[] {
const qs: string[] = []
if (!inputs.ticSnapshot) {
qs.push('Vad är din huvudsakliga verksamhet? (några ord räcker)')
}
if (inputs.entityType === 'aktiebolag') {
qs.push('Är du ensamägare till bolaget?')
qs.push('Har bolaget anställda förutom dig?')
}
qs.push('Vilken momsperiod använder ni — månad, kvartal eller år?')
return qs
}
// Build a minimal Swedish narrative for the fallback path so Phase B has
// something to render even when the Sonnet call also failed. Mirrors the
// Sonnet prompt's voice-branching: second-person only when the user is a
// confirmed director, neutral otherwise.
export function fallbackNarrative(inputs: ComposerInputs): string {
const parts: string[] = []
const name = inputs.companyName || 'företaget'
const isAB = inputs.entityType === 'aktiebolag'
const isEF = inputs.entityType === 'enskild_firma'
const form = isAB ? 'aktiebolag' : isEF ? 'enskild firma' : null
if (inputs.userIsConfirmedDirector) {
if (form) {
parts.push(`Du driver ${name} som ${form}.`)
} else {
parts.push(`Du driver ${name}.`)
}
} else {
if (form) {
parts.push(`${name} är ${form === 'enskild firma' ? 'en enskild firma' : 'ett aktiebolag'}.`)
} else {
parts.push(`${name} är ett företag i gnubok.`)
}
}
parts.push(
'Jag har laddat de svenska reglerna som gäller bredast — moms, fakturering, bokslut och årsavslutning.',
)
parts.push(
inputs.userIsConfirmedDirector
? 'Berätta gärna lite mer om din verksamhet i nästa steg så kan jag skräddarsy stöden mer.'
: 'Berätta gärna lite mer om verksamheten i nästa steg så kan jag skräddarsy stöden mer.',
)
return parts.join(' ')
}
+90
View File
@@ -0,0 +1,90 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { gatherComposerInputs, inputsToSourceSignals } from './inputs'
import { selectAtoms } from './atom-selection'
import { writeNarrative } from './narrative'
import { preWarmAtomCache } from './prewarm'
import { OPUS_MODEL } from './client'
import type { ComposedProfile } from './schemas'
interface ComposeOptions {
// When true, runs the selection + narrative + pre-warm but does not write
// to agent_profiles. Useful for evaluating composer output before commit.
dryRun?: boolean
// When true, skips cache pre-warm. Tests and CI typically set this.
skipPrewarm?: boolean
}
// Top-level composer: gather inputs → select atoms (Opus) → write narrative
// (Sonnet) → persist agent_profiles row → fire-and-forget cache pre-warm.
//
// See dev_docs/specialized-agent-plan.md §6.
export async function composeAgentProfile(
supabase: SupabaseClient,
companyId: string,
options: ComposeOptions = {},
): Promise<ComposedProfile> {
const inputs = await gatherComposerInputs(supabase, companyId)
const selection = await selectAtoms(inputs)
const profileSummary = await writeNarrative(inputs, selection)
const sourceSignals = inputsToSourceSignals(inputs)
const composedAt = new Date().toISOString()
const composerModel = OPUS_MODEL
if (!options.dryRun) {
const { error } = await supabase
.from('agent_profiles')
.upsert(
{
company_id: companyId,
horizontal_atoms: selection.horizontal_atoms,
vertical_atoms: selection.vertical_atoms,
modifier_atoms: selection.modifier_atoms,
profile_summary: profileSummary,
source_signals: sourceSignals,
composed_at: composedAt,
composer_model: composerModel,
composer_version: 1,
},
{ onConflict: 'company_id' },
)
if (error) throw new Error(`Failed to upsert agent_profiles: ${error.message}`)
}
if (!options.skipPrewarm && !options.dryRun) {
// Resolve atom body paths from the registry and fire pre-warm.
const allIds = [
...selection.horizontal_atoms,
...selection.vertical_atoms,
...selection.modifier_atoms,
]
if (allIds.length > 0) {
const { data: rows } = await supabase
.from('agent_atom_registry')
.select('id, body')
.in('id', allIds)
const bodies = (rows ?? [])
.map((r: { body: string | null }) => r.body ?? '')
.filter((b: string) => b.length > 0)
// Intentionally not awaited — pre-warm must not block the response.
void preWarmAtomCache({ atomBodies: bodies })
}
}
return {
companyId,
horizontalAtoms: selection.horizontal_atoms,
verticalAtoms: selection.vertical_atoms,
modifierAtoms: selection.modifier_atoms,
isMultiVertical: selection.is_multi_vertical,
verificationQuestions: selection.verification_questions,
uncertaintyNotes: selection.uncertainty_notes,
profileSummary,
sourceSignals,
composerModel,
composedAt,
}
}
export type { ComposedProfile } from './schemas'
+405
View File
@@ -0,0 +1,405 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import type { SourceSignals } from './schemas'
// Atom registry index row — the metadata-only shape the composer sees when
// picking a loadout. We never send full atom bodies to the Opus call;
// metadata is enough for selection.
export interface AtomRegistryIndexRow {
id: string
tier: 'horizontal' | 'vertical' | 'modifier'
title: string
description: string
sni_prefixes: string[]
trigger_signals: Record<string, unknown>
estimated_tokens: number
version: number
}
export async function loadAtomRegistryIndex(
supabase: SupabaseClient,
): Promise<AtomRegistryIndexRow[]> {
const { data, error } = await supabase
.from('agent_atom_registry')
.select('id, tier, title, description, sni_prefixes, trigger_signals, estimated_tokens, version')
.eq('is_active', true)
.is('parent_atom_id', null) // top-level skills only; reference children are load-on-demand
.order('id')
if (error) throw new Error(`Failed to load agent_atom_registry: ${error.message}`)
return (data ?? []) as AtomRegistryIndexRow[]
}
export async function loadCompanyTicSnapshot(
supabase: SupabaseClient,
companyId: string,
): Promise<{ snapshot: Record<string, unknown> | null; fetchedAt: string | null; name: string; entityType: string }> {
const { data, error } = await supabase
.from('companies')
.select('name, entity_type, tic_snapshot, tic_snapshot_fetched_at')
.eq('id', companyId)
.single()
if (error) throw new Error(`Failed to load company ${companyId}: ${error.message}`)
return {
snapshot: (data?.tic_snapshot as Record<string, unknown> | null) ?? null,
fetchedAt: (data?.tic_snapshot_fetched_at as string | null) ?? null,
name: data?.name ?? '',
entityType: data?.entity_type ?? '',
}
}
// Known facts from `company_settings` — the onboarding form persists these
// (moms_period, fiscal_year_start_month, f_skatt, employees, city). Composer
// uses them as KNOWN inputs so it stops generating verification questions
// about already-settled values.
export interface CompanySettingsForComposer {
city: string | null
moms_period: string | null
fiscal_year_start_month: number | null
f_skatt: boolean | null
vat_registered: boolean | null
employee_count: number | null
has_employees: boolean | null
pays_salaries: boolean | null
accounting_method: string | null
}
export async function loadCompanySettings(
supabase: SupabaseClient,
companyId: string,
): Promise<CompanySettingsForComposer | null> {
const { data } = await supabase
.from('company_settings')
.select(
'city, moms_period, fiscal_year_start_month, f_skatt, vat_registered, employee_count, has_employees, pays_salaries, accounting_method',
)
.eq('company_id', companyId)
.maybeSingle()
return (data ?? null) as CompanySettingsForComposer | null
}
// Whether the currently-onboarding user is a confirmed director / signatory
// at this company per BankID CompanyRoles. When true, the narrative is safe
// to use second-person ownership voice ("Du driver…"); when false (manual-
// orgnr signup, accountant-on-behalf-of, etc.) the narrative falls back to
// neutral third-person ("Coredination AB är…") so we don't put words about
// ownership in the user's mouth.
//
// We match the user's enrichment row against this company's org_number.
// `companyId` is the gnubok UUID — we need to read the orgnr from
// `companies` to do the match. Cheap (single SELECT each) and only runs once
// per agent build.
//
// Director-like positions per Bolagsverket: 'ceo', 'boardMember', 'chairman',
// 'externalSignatory'. Deputy positions ('deputyBoardMember') and external
// auditors are intentionally excluded — they don't run the company day-to-day.
const DIRECTOR_POSITION_TYPES = new Set([
'ceo',
'boardMember',
'chairman',
'externalSignatory',
// Lowercase variants in case TIC normalises differently
'CEO',
'BoardMember',
'Chairman',
'ExternalSignatory',
])
export async function loadUserDirectorship(
supabase: SupabaseClient,
companyId: string,
): Promise<{ confirmedDirector: boolean }> {
// Read this company's org_number — the BankID CompanyRoles row keys on
// companyRegistrationNumber, not the gnubok company UUID.
const { data: companyRow } = await supabase
.from('companies')
.select('org_number')
.eq('id', companyId)
.single()
const orgNumber = (companyRow?.org_number as string | null)?.replace(/[\s-]/g, '')
if (!orgNumber) return { confirmedDirector: false }
// Read the active user's enrichment row. Composer runs inside the user's
// request context (RLS-scoped client), so .maybeSingle() only sees the row
// for the authenticated user — no need to join through company_members.
const { data: enrichmentRow } = await supabase
.from('bankid_enrichment')
.select('company_roles')
.maybeSingle()
const roles = (enrichmentRow?.company_roles ?? []) as Array<{
companyRegistrationNumber?: string
positionTypes?: string[]
positionEnd?: string | null
}>
if (!Array.isArray(roles) || roles.length === 0) return { confirmedDirector: false }
const match = roles.find(
(r) => r.companyRegistrationNumber?.replace(/[\s-]/g, '') === orgNumber,
)
if (!match) return { confirmedDirector: false }
// Position must be a director-type AND not already ended.
const nowIso = new Date().toISOString()
if (match.positionEnd && match.positionEnd < nowIso) return { confirmedDirector: false }
const positions = match.positionTypes ?? []
const isDirector = positions.some((p) => DIRECTOR_POSITION_TYPES.has(p))
return { confirmedDirector: isDirector }
}
interface SieSummary {
top_accounts: { account: string; abs_amount: number }[]
top_counterparties: { name: string; abs_amount: number }[]
year_count: number
}
// Build a coarse SIE summary from the most-recent imported SIE for the
// company. Used by the composer as a verticality signal (e.g. a top-spend
// account 1465 — alcohol inventory — strongly suggests restaurang).
//
// Returns null when no SIE has been imported. The composer must still work
// without SIE data; TIC sniCodes carry most of the signal on their own.
export async function loadSieSummary(
supabase: SupabaseClient,
companyId: string,
): Promise<SieSummary | null> {
const { data: imports, error: importsErr } = await supabase
.from('sie_imports')
.select('id, fiscal_year_start, fiscal_year_end')
.eq('company_id', companyId)
.eq('status', 'completed')
.order('created_at', { ascending: false })
.limit(20)
if (importsErr) return null
if (!imports || imports.length === 0) return null
// Fiscal-year span across all completed imports (approximate).
const years = new Set(
imports.map((r: { fiscal_year_start: string | null }) => {
const v = r.fiscal_year_start
return v ? v.slice(0, 4) : ''
}),
)
years.delete('')
// Top-20 account magnitudes across journal_entry_lines for the company.
// Cheaper than aggregating SIE line-items directly because the lines have
// already landed in journal_entry_lines after import.
const { data: lines, error: linesErr } = await supabase.rpc('agent_top_accounts_for_company', {
p_company_id: companyId,
p_limit: 20,
})
// RPC is optional — if it doesn't exist yet, fall back to an inline group-by.
// Either way, we tolerate missing data and return what we have.
let topAccounts: { account: string; abs_amount: number }[] = []
if (!linesErr && Array.isArray(lines)) {
topAccounts = (lines as { account_number: string; abs_amount: number }[]).map((l) => ({
account: l.account_number,
abs_amount: Number(l.abs_amount) || 0,
}))
}
// Coarse counterparty rollup off the bank-statement description string.
// `transactions.description` is the raw text from the bank — not
// normalized — so this is a noisy signal. The composer treats it as a hint
// alongside TIC sniCodes, which carry the strong industry signal.
const { data: tx } = await supabase
.from('transactions')
.select('description, amount')
.eq('company_id', companyId)
.limit(2000)
const cpAgg = new Map<string, number>()
if (Array.isArray(tx)) {
for (const t of tx as { description: string | null; amount: number | string | null }[]) {
const name = normalizeCounterparty(t.description)
if (!name) continue
const amt = Math.abs(Number(t.amount) || 0)
cpAgg.set(name, (cpAgg.get(name) ?? 0) + amt)
}
}
const topCounterparties = Array.from(cpAgg.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 10)
.map(([name, abs_amount]) => ({ name, abs_amount }))
return {
top_accounts: topAccounts,
top_counterparties: topCounterparties,
year_count: years.size,
}
}
interface BankingCounterparty {
name: string
abs_amount: number
// 'in' → money coming in (income / refund / loan disbursement)
// 'out' → money going out (cost / supplier payment / repayment)
// 'mixed' → both directions present (rare — typically transfers or
// returns). The composer should not assume a category from
// mixed counterparties.
direction: 'in' | 'out' | 'mixed'
// True when at least one transaction for this counterparty still has
// journal_entry_id IS NULL. Composer should only generate verification
// questions about counterparties where this is true — the others are
// already settled and re-asking wastes the user's time.
has_unbooked: boolean
}
interface BankingSummary {
top_counterparties: BankingCounterparty[]
monthly_volume: number | null
unbooked_count: number
}
// POC: re-use transactions table for banking counterparties. A first-class
// Enable Banking summary lives behind the enable-banking extension and is
// post-POC.
//
// Booking-aware: each rolled-up counterparty carries `direction` (sign of
// the transactions) and `has_unbooked` (any row without journal_entry_id).
// Both signals exist to keep the composer from asking dumb questions —
// "is this a cost or an income?" when the sign is clearly negative,
// "how should this be booked?" when there's no unbooked transaction left.
export async function loadBankingSummary(
supabase: SupabaseClient,
companyId: string,
): Promise<BankingSummary | null> {
const { data, error } = await supabase
.from('transactions')
.select('description, amount, date, journal_entry_id')
.eq('company_id', companyId)
.gte('date', oneYearAgo())
.order('date', { ascending: false })
.limit(5000)
if (error || !Array.isArray(data) || data.length === 0) return null
interface Bucket {
absAmount: number
hasInflow: boolean
hasOutflow: boolean
hasUnbooked: boolean
}
const cpAgg = new Map<string, Bucket>()
let totalVolume = 0
let unbookedCount = 0
for (const t of data as {
description: string | null
amount: number | string | null
journal_entry_id: string | null
}[]) {
const signedAmt = Number(t.amount) || 0
const absAmt = Math.abs(signedAmt)
totalVolume += absAmt
if (!t.journal_entry_id) unbookedCount++
const name = normalizeCounterparty(t.description)
if (!name) continue
const prev = cpAgg.get(name) ?? {
absAmount: 0,
hasInflow: false,
hasOutflow: false,
hasUnbooked: false,
}
prev.absAmount += absAmt
if (signedAmt > 0) prev.hasInflow = true
if (signedAmt < 0) prev.hasOutflow = true
if (!t.journal_entry_id) prev.hasUnbooked = true
cpAgg.set(name, prev)
}
const top: BankingCounterparty[] = Array.from(cpAgg.entries())
.sort((a, b) => b[1].absAmount - a[1].absAmount)
.slice(0, 20)
.map(([name, b]) => ({
name,
abs_amount: b.absAmount,
direction: b.hasInflow && b.hasOutflow ? 'mixed' : b.hasInflow ? 'in' : 'out',
has_unbooked: b.hasUnbooked,
}))
return {
top_counterparties: top,
monthly_volume: totalVolume > 0 ? Math.round(totalVolume / 12) : null,
unbooked_count: unbookedCount,
}
}
function oneYearAgo(): string {
const d = new Date()
d.setFullYear(d.getFullYear() - 1)
return d.toISOString().slice(0, 10)
}
// Cheap counterparty extraction from a bank-statement description. Strips
// reference numbers, dates, and common suffixes; truncates to ~40 chars.
// Post-POC: replace with the matcher in lib/transactions/.
function normalizeCounterparty(raw: string | null | undefined): string | null {
if (!raw) return null
const cleaned = raw
.replace(/\b\d{4,}\b/g, ' ') // strip long digit runs (refs)
.replace(/[/*:|]/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.slice(0, 40)
return cleaned.length >= 3 ? cleaned : null
}
// Composite input for the Opus selection call.
export interface ComposerInputs {
companyId: string
companyName: string
entityType: string
ticSnapshot: Record<string, unknown> | null
ticFetchedAt: string | null
companySettings: CompanySettingsForComposer | null
sieSummary: SieSummary | null
bankingSummary: BankingSummary | null
atomIndex: AtomRegistryIndexRow[]
// True when BankID CompanyRoles confirms the active user holds a
// director-like position at this company. Controls whether the narrative
// uses second-person ownership voice ("Du driver…") or neutral
// third-person ("Coredination AB är…"). Default false so unknown users
// never get the presumptive voice.
userIsConfirmedDirector: boolean
}
export async function gatherComposerInputs(
supabase: SupabaseClient,
companyId: string,
): Promise<ComposerInputs> {
const [
{ snapshot, fetchedAt, name, entityType },
atomIndex,
sieSummary,
bankingSummary,
companySettings,
directorship,
] = await Promise.all([
loadCompanyTicSnapshot(supabase, companyId),
loadAtomRegistryIndex(supabase),
loadSieSummary(supabase, companyId).catch(() => null),
loadBankingSummary(supabase, companyId).catch(() => null),
loadCompanySettings(supabase, companyId).catch(() => null),
loadUserDirectorship(supabase, companyId).catch(() => ({ confirmedDirector: false })),
])
return {
companyId,
companyName: name,
entityType,
ticSnapshot: snapshot,
ticFetchedAt: fetchedAt,
companySettings,
sieSummary,
bankingSummary,
atomIndex,
userIsConfirmedDirector: directorship.confirmedDirector,
}
}
export function inputsToSourceSignals(inputs: ComposerInputs): SourceSignals {
return {
tic: inputs.ticSnapshot,
sie_summary: inputs.sieSummary,
banking_summary: inputs.bankingSummary,
atom_registry_version: inputs.atomIndex.reduce((acc, a) => acc + a.version, 0),
}
}
+109
View File
@@ -0,0 +1,109 @@
import { getAnthropic, SONNET_MODEL } from './client'
import type { AtomSelection } from './schemas'
import type { ComposerInputs } from './inputs'
// Two voices: second-person ownership ("Du driver…") only when BankID
// CompanyRoles has confirmed the active user holds a director-like position
// at this company. Otherwise we use neutral third-person ("Coredination AB
// är…") so manual-orgnr signups (accountant-on-behalf-of, employees,
// family members setting up a parent's company) don't get a narrative
// presuming they personally own the company. Same content, different voice.
const SHARED_PROMPT_HEADER = `Du skriver en kort, saklig profil av ett företag. Profilen visas för användaren under rubriken "Profil" och används som bakgrund åt en bokföringsassistent. Den är INTE en hälsning och INTE ett chattmeddelande.
Stil:
- Max 80 ord, två till tre meningar.
- Saklig och konkret. Inga floskler, inga utropstecken, inga emoji, ingen avslutande fråga ("Stämmer det?").
- Skriv ALDRIG i jag-form ("Jag ser att…"). Det är en beskrivning, inte assistenten som talar.
- Använd ALDRIG tankstreck (— eller –). Använd kommatecken, punkt eller skriv "till" för intervall ("2,5 till 5 miljoner"). Hård regel.
Innehåll:
1. Beskriv verksamheten med egna ord utifrån SNI-koder och verksamhetsbeskrivning: vad företaget gör, juridisk form och ägarbild om den är känd. Återge inte verksamhetsbeskrivningen ordagrant — den visas redan separat under "Verksamhet".
2. Avsluta med en mening om vad assistenten är inställd på att hjälpa till med för den här typen av verksamhet, utifrån de valda specialiteterna.
Skriv endast själva profiltexten. Ingen rubrik, inga punktlistor.`
const VOICE_DIRECTOR = `\n\nRöst: Andra person, ägar-/ledningsperspektiv. "Du driver…", "Din verksamhet…". Användaren är verifierad styrelseledamot eller firmatecknare i bolaget.`
const VOICE_NEUTRAL = `\n\nRöst: Tredje person, neutral. "Coredination AB är…", "Bolaget bedriver…". Användaren kan vara ägare, anställd eller redovisningskonsult — vi vet inte. Skriv ALDRIG "Du driver…", "Din verksamhet…" eller andra formuleringar som antar att användaren själv äger eller leder bolaget. Använd företagsnamnet eller "Bolaget".`
function systemPromptFor(userIsConfirmedDirector: boolean): string {
return SHARED_PROMPT_HEADER + (userIsConfirmedDirector ? VOICE_DIRECTOR : VOICE_NEUTRAL)
}
export async function writeNarrative(
inputs: ComposerInputs,
selection: AtomSelection,
): Promise<string> {
const anthropic = getAnthropic()
const response = await anthropic.messages.create({
model: SONNET_MODEL,
max_tokens: 400,
system: systemPromptFor(inputs.userIsConfirmedDirector),
messages: [
{
role: 'user',
content: buildUserPrompt(inputs, selection),
},
],
})
const text = response.content
.filter((b) => b.type === 'text')
.map((b) => (b as { type: 'text'; text: string }).text)
.join('')
.trim()
return text
}
function buildUserPrompt(inputs: ComposerInputs, selection: AtomSelection): string {
const lines: string[] = []
lines.push(`Företag: ${inputs.companyName}`)
lines.push(`Juridisk form: ${inputs.entityType}`)
if (inputs.ticSnapshot) {
const tic = inputs.ticSnapshot as Record<string, unknown>
const sni = (tic.sniCodes as { code: string; name: string }[] | undefined) ?? []
if (sni.length > 0) {
lines.push(`SNI: ${sni.map((s) => `${s.code} ${s.name}`).join('; ')}`)
}
if (typeof tic.purpose === 'string' && tic.purpose.trim().length > 0) {
// Verksamhetsbeskrivning is the most important signal for the
// confirming voice — pass it verbatim so the model can paraphrase.
lines.push(`Verksamhetsbeskrivning (Bolagsverket): ${tic.purpose as string}`)
}
const reg = tic.registration as { fTax?: boolean; vat?: boolean; payroll?: boolean } | undefined
if (reg) {
const flags = [
reg.fTax ? 'F-skatt' : null,
reg.vat ? 'momsregistrerad' : null,
reg.payroll ? 'arbetsgivare' : null,
].filter(Boolean)
if (flags.length > 0) lines.push(`Registreringar: ${flags.join(', ')}`)
}
if (tic.employeeRange) lines.push(`Anställda: ${tic.employeeRange as string}`)
if (tic.turnoverRange) lines.push(`Omsättning: ${tic.turnoverRange as string}`)
const owners = tic.beneficialOwners as
| { name: string; extentDescription?: string | null }[]
| undefined
if (Array.isArray(owners) && owners.length > 0) {
const names = owners.map((o) => o.name).join(', ')
lines.push(
`Verkliga huvudmän: ${names}${owners.length === 1 ? ' (ensam ägare)' : ''}`,
)
}
}
if (inputs.sieSummary && inputs.sieSummary.top_accounts.length > 0) {
const top = inputs.sieSummary.top_accounts.slice(0, 5)
lines.push(`Topp-konton i SIE: ${top.map((a) => a.account).join(', ')}`)
}
lines.push('')
lines.push(`Valda horizontals: ${selection.horizontal_atoms.join(', ') || '(inga)'}`)
lines.push(`Valda verticals: ${selection.vertical_atoms.join(', ') || '(inga)'}`)
lines.push(`Valda modifiers: ${selection.modifier_atoms.join(', ') || '(inga)'}`)
return lines.join('\n')
}
+45
View File
@@ -0,0 +1,45 @@
import { getAnthropic, SONNET_MODEL } from './client'
// Cache pre-warm after composition: fire a max_tokens: 1 request with the
// assembled atom bodies so the Block 1 cache prefix lands warm before the
// user's first chat turn. Best-effort — if this fails, the loop still works,
// just with a cold first turn.
//
// Bodies come from the DB (agent_atom_registry.body), not disk — so pre-warm
// no longer depends on .claude/skills being present at runtime. In dev before
// `npm run skills:generate` has seeded bodies, the list is empty and pre-warm
// simply no-ops (a cold first turn, which is acceptable for a dev convenience).
//
// Note: we use max_tokens: 1 (not 0). The Anthropic API requires at least
// 1 output token. Pre-warm cost is dominated by input processing, so a
// single output token is negligible.
//
// Plan ref: §6 (cache pre-warming), §10 (caching strategy).
export async function preWarmAtomCache(opts: {
atomBodies: string[]
ttl?: '5m' | '1h'
}): Promise<void> {
const { atomBodies, ttl = '1h' } = opts
const bodies = atomBodies.filter((b) => b && b.length > 0)
if (bodies.length === 0) return
const anthropic = getAnthropic()
try {
await anthropic.messages.create({
model: SONNET_MODEL,
max_tokens: 1,
system: [
{
type: 'text',
text: bodies.join('\n\n---\n\n'),
cache_control: { type: 'ephemeral', ttl },
},
],
messages: [{ role: 'user', content: 'warmup' }],
})
} catch {
// Fire-and-forget — pre-warm failure must never block the composer.
}
}
+101
View File
@@ -0,0 +1,101 @@
import { z } from 'zod'
// Atom selection output. The Opus call returns this via tool_use forcing —
// the model is forced to invoke `compose_agent_profile(...)` once, which
// gives us Zod-validatable structured output instead of free-text JSON.
export const AtomSelectionSchema = z.object({
horizontal_atoms: z
.array(z.string())
.describe('Atom IDs of horizontal regulatory skills to load (e.g. "horizontal/swedish-vat").'),
vertical_atoms: z
.array(z.string())
.describe(
'Atom IDs of industry/vertical atoms (e.g. "vertical/konsult-it"). Empty array if no vertical fits.',
),
modifier_atoms: z
.array(z.string())
.describe(
'Atom IDs of cross-cutting modifier atoms (e.g. "modifier/single-shareholder-ab-fmb").',
),
is_multi_vertical: z
.boolean()
.describe('True when the company genuinely spans more than one industry.'),
verification_questions: z
.array(z.string())
.describe(
'Short Swedish questions the user must confirm during Phase B verification. Highest-leverage uncertainties only.',
),
uncertainty_notes: z
.array(z.string())
.describe('Free-form notes the composer wants to surface to a developer reviewing the selection.'),
})
export type AtomSelection = z.infer<typeof AtomSelectionSchema>
// JSON-Schema for the tool_use forcing. Anthropic's API requires the tool
// schema in plain JSON Schema (not Zod). Kept in sync with AtomSelectionSchema
// by convention; the response is re-validated through Zod after parsing.
// Typed as the SDK's InputSchema shape (mutable) so it satisfies the
// Tool.input_schema parameter.
export const ATOM_SELECTION_TOOL_SCHEMA: {
type: 'object'
properties: Record<string, unknown>
required: string[]
additionalProperties: boolean
} = {
type: 'object',
properties: {
horizontal_atoms: { type: 'array', items: { type: 'string' } },
vertical_atoms: { type: 'array', items: { type: 'string' } },
modifier_atoms: { type: 'array', items: { type: 'string' } },
is_multi_vertical: { type: 'boolean' },
verification_questions: { type: 'array', items: { type: 'string' } },
uncertainty_notes: { type: 'array', items: { type: 'string' } },
},
required: [
'horizontal_atoms',
'vertical_atoms',
'modifier_atoms',
'is_multi_vertical',
'verification_questions',
'uncertainty_notes',
],
additionalProperties: false,
}
// Source signals snapshot. Persisted in agent_profiles.source_signals so the
// selection is reconstructable later even if upstream data (TIC, SIE) changes.
export const SourceSignalsSchema = z.object({
tic: z.record(z.string(), z.unknown()).nullable(),
sie_summary: z
.object({
top_accounts: z.array(z.object({ account: z.string(), abs_amount: z.number() })),
top_counterparties: z.array(z.object({ name: z.string(), abs_amount: z.number() })),
year_count: z.number(),
})
.nullable(),
banking_summary: z
.object({
top_counterparties: z.array(z.object({ name: z.string(), abs_amount: z.number() })),
monthly_volume: z.number().nullable(),
})
.nullable(),
atom_registry_version: z.number(),
})
export type SourceSignals = z.infer<typeof SourceSignalsSchema>
// Output of the full composer pipeline.
export interface ComposedProfile {
companyId: string
horizontalAtoms: string[]
verticalAtoms: string[]
modifierAtoms: string[]
isMultiVertical: boolean
verificationQuestions: string[]
uncertaintyNotes: string[]
profileSummary: string
sourceSignals: SourceSignals
composerModel: string
composedAt: string
}
+184
View File
@@ -0,0 +1,184 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { createLogger } from '@/lib/logger'
const log = createLogger('agent.composer.tic-fetch')
// Live-fetch the TIC company profile via the existing extension HTTP route
// and cache it on `companies.tic_snapshot`. Used by the agent onboarding
// stream (Phase A step 1) and the /onboarding/agent server component so the
// review card has SNI, verksamhetsbeskrivning, address, and recent financials
// without requiring the user to have visited the TIC workspace beforehand.
//
// Why HTTP self-fetch rather than a direct import: core-build CI forbids
// imports from @/extensions/ in lib/agent/*. Going through the extension's
// public HTTP surface keeps the boundary intact and works the same in dev
// and on Vercel. The TIC handler already accepts cookie-auth, so we just
// forward the user's session cookie.
//
// Stale-cache policy: anything cached within the last 7 days is reused
// verbatim. TIC data is slow-changing (sniCodes, registration, address
// rarely flip) so this avoids re-hitting TIC on every page load.
//
// Rate budget: the /profile endpoint fans out to ~13 TIC (Lens) calls and
// the account has a ~3000/mo ceiling. So we DON'T eagerly re-fetch every
// pre-v2 (v1) snapshot — that would blow the budget across the customer
// base. Instead, v1 snapshots upgrade to v2 lazily: only when a caller
// that actually consumes the v2 sections passes `upgradeV1: true` (today
// just the agent-onboarding paths, a deliberate once-per-company action).
const STALE_AFTER_MS = 7 * 24 * 60 * 60 * 1000 // 7 days
// Default fetch timeout. The agent-onboarding callers override with a longer
// budget (10s) since the user is on a wait-screen with visible progress and
// the prior 5s default killed every signup-time fetch in May (~530 wasted
// upstream Lens calls — the abort fired client-side but the upstream calls
// kept running and counted against quota). Other callers (background jobs,
// dev tooling) stay on the conservative default.
const FETCH_TIMEOUT_MS = 5_000
export interface TicSnapshotResult {
snapshot: Record<string, unknown> | null
source: 'cached' | 'fetched' | 'fallback'
}
// A snapshot written before the TIC v2 migration lacks the v2-only
// `statuses` section. v2 always includes the key (possibly an empty
// array), so its absence is a reliable "this is a v1 snapshot" signal.
function isV1Snapshot(snapshot: Record<string, unknown> | null): boolean {
return snapshot != null && !('statuses' in snapshot)
}
export async function ensureTicSnapshot(opts: {
supabase: SupabaseClient
companyId: string
cookieHeader: string
// Origin to use for the internal self-fetch. The caller derives this from
// the incoming request's host header so dev (localhost:3000), preview
// (vercel.app), and production all reach their own instance of the TIC
// route. Falls back to NEXT_PUBLIC_APP_URL when not supplied — fine for
// background jobs but wrong for request-scoped paths because that env var
// is the production canonical URL even in dev.
origin?: string
// When true, a cached snapshot still inside the 7-day window is
// re-fetched if it's a pre-v2 (v1) shape. Gated to deliberate, bounded
// callers (agent onboarding) so the v1→v2 upgrade doesn't fan out across
// every company and exhaust the monthly TIC budget.
upgradeV1?: boolean
// Override the default 5s fetch timeout. Use when the caller has a UI
// affordance for waiting (agent onboarding wait-screen) so legitimate
// fetches don't get aborted before the ~13-call Lens fan-out completes —
// which was the root cause of the May 2026 quota-burn incident.
timeoutMs?: number
}): Promise<TicSnapshotResult> {
const {
supabase,
companyId,
cookieHeader,
origin,
upgradeV1 = false,
timeoutMs = FETCH_TIMEOUT_MS,
} = opts
const { data: companyRow } = await supabase
.from('companies')
.select('org_number, tic_snapshot, tic_snapshot_fetched_at')
.eq('id', companyId)
.single()
if (!companyRow) return { snapshot: null, source: 'fallback' }
const cachedSnapshot = companyRow.tic_snapshot as Record<string, unknown> | null
const needsV2Upgrade = upgradeV1 && isV1Snapshot(cachedSnapshot)
// Fresh cache hit — nothing to do. (Unless the caller needs v2 fields and
// the cache is still v1, in which case we fall through to a refetch.)
if (
cachedSnapshot &&
!isStale(companyRow.tic_snapshot_fetched_at as string | null) &&
!needsV2Upgrade
) {
return { snapshot: cachedSnapshot, source: 'cached' }
}
// Org number drifts: some onboarding flows persist it on company_settings
// only (TicWorkspace reads from there). Prefer companies.org_number but
// fall back to company_settings.org_number so existing companies aren't
// permanently blocked from TIC enrichment.
let orgNumber = (companyRow.org_number as string | null) ?? null
if (!orgNumber) {
const { data: settingsRow } = await supabase
.from('company_settings')
.select('org_number')
.eq('company_id', companyId)
.maybeSingle()
orgNumber = (settingsRow?.org_number as string | null) ?? null
}
if (!orgNumber) {
return { snapshot: (companyRow.tic_snapshot as Record<string, unknown> | null) ?? null, source: 'fallback' }
}
const profile = await fetchTicProfile(orgNumber, cookieHeader, origin, timeoutMs)
if (!profile) {
// Fall through with whatever (possibly stale) snapshot we already have.
return {
snapshot: (companyRow.tic_snapshot as Record<string, unknown> | null) ?? null,
source: 'fallback',
}
}
// Persist. Best-effort — if the update fails, we still return the profile
// we just fetched so the current request can use it.
const { error } = await supabase
.from('companies')
.update({
tic_snapshot: profile,
tic_snapshot_fetched_at: new Date().toISOString(),
})
.eq('id', companyId)
if (error) {
// Stale data is fine for the current request — but a silent write
// failure means the next caller re-fetches TIC unnecessarily and the
// monthly TIC budget bleeds. Surface it via the structured logger.
log.warn('tic snapshot persist failed', { error: error.message, companyId })
}
return { snapshot: profile, source: 'fetched' }
}
function isStale(fetchedAt: string | null): boolean {
if (!fetchedAt) return true
const ts = Date.parse(fetchedAt)
if (Number.isNaN(ts)) return true
return Date.now() - ts > STALE_AFTER_MS
}
async function fetchTicProfile(
orgNumber: string,
cookieHeader: string,
origin: string | undefined,
timeoutMs: number,
): Promise<Record<string, unknown> | null> {
const baseUrl = origin || process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'
const url = `${baseUrl}/api/extensions/ext/tic/profile?org_number=${encodeURIComponent(orgNumber)}`
try {
const res = await fetch(url, {
headers: cookieHeader ? { cookie: cookieHeader } : undefined,
signal: AbortSignal.timeout(timeoutMs),
})
if (!res.ok) {
log.warn('tic profile non-ok', { url, status: res.status })
return null
}
const body = (await res.json()) as { data?: Record<string, unknown> }
return body.data ?? null
} catch (err) {
// Network error, timeout, TIC extension disabled, TIC API misconfigured.
// Any of these is a normal fallback — return null so the caller can
// degrade gracefully.
log.warn('tic profile fetch failed', {
url,
error: err instanceof Error ? err.message : String(err),
})
return null
}
}