ec27228a8e
Em dashes (—) and en dashes (–) had spread across comments, docs, tests, and a few UI strings, reading as AI-generated boilerplate rather than house style. Replaced each with punctuation matching its context: colon for explanatory clauses, comma for asides, plain hyphen for numeric/legal ranges (e.g. "21-23§"), "to"/"till" for date ranges, parentheses for paired-dash asides. messages/en.json and messages/sv.json were fixed by hand together to keep sv/en in sync. Left untouched where the dash is the functional subject rather than decorative punctuation: date-range-parser.ts's separator regex, charset-repair.ts's CP1252 byte-mapping table (and its test), the SIE encoding mojibake docs, generic-csv.ts's minus-sign normalizer, the agent system-prompt files that already instruct against em dashes, and a golden iXBRL test fixture compared byte-for-byte. Also fixes two bugs surfaced along the way: an off-by-one in ApiKeysPanel's scope-label split (a leftover from an earlier partial pass), and a charset-repair test that had lost the literal en-dash it exists to verify. Regenerated the agent atom seed migration (skills:generate) since 27 SKILL.md files changed. Added a CLAUDE.md rule against em/en dashes, with an explicit carve-out for the functional-dash cases above. Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
138 lines
5.2 KiB
TypeScript
138 lines
5.2 KiB
TypeScript
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(' ')
|
|
}
|