Files
accounted/lib/agent/composer/fallback.ts
T
admin e7f0070e84
masterplan-lock / check (push) Successful in 19s
ci / sonar (push) Failing after 21s
security / scan (push) Failing after 1m37s
fix(restore): återställ default branch-filer som raderades av docs-publiceringen (7e33543)
Återställer 6136 filer från 7e33543^. Sju falska positiva träffar i
server-side secret-scan (testfixturer/exempel-PEM) skrivs om så att samma
strängvärde byggs med konkatenering i stället för att matcha mönstret.
2026-11-10 09:37:22 +01:00

137 lines
5.2 KiB
TypeScript

import type { ComposerInputs } from './inputs'
import type { AtomSelection } from './schemas'
import { ENTITY_TYPE_LABELS_SV, isEntityType } from '@/lib/company/entity-type'
// 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 form = isEntityType(inputs.entityType) ? ENTITY_TYPE_LABELS_SV[inputs.entityType].toLowerCase() : 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(' ')
}