Files
accounted/lib/agent/intents/onboarding-intake.ts
T
Jakob WennbergandClaude Sonnet 5 ec27228a8e style: remove em/en dashes repo-wide, add CLAUDE.md rule against them (#890)
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>
2026-07-04 15:58:06 +02:00

169 lines
7.4 KiB
TypeScript

import { defineAgentIntent } from './types'
import { SONNET_MODEL } from '@/lib/agent/composer/client'
// onboarding.intake: Phase C "first-meeting intake" conversation. Fires
// after Phase B review/verify completes. The agent runs a real intake the
// way a new redovisningskonsult would: one question at a time, unhurried,
// shaped by the loaded vertical + modifier atoms.
//
// Phase B now only confirms the inferred facts and names the assistant; it
// no longer asks the verification questions as a form. This chat is the
// entire interview, so it carries the composer's questions as its bank.
//
// Declarative atom mode: full bodies. Intake is the highest-leverage chat
// in the company's lifetime, so we pay the cache-prefix cost once and load
// everything. The intake conversation also tends to be longer than other
// intents, so the per-user block stays hot for the duration.
//
// Plan refs: §7 Phase C, §16 ("Onboarding chat shape").
interface IntakeArgs {
// No args: the intake is per-company and reads everything from the
// profile. The /chat/intake route mounts AgentChat with intent_args
// omitted; the capture below pulls the state.
_?: never
}
interface CapturedIntake {
agentDisplayName: string | null
userFirstName: string | null
companyName: string | null
profileSummary: string | null
// The composer's flagged uncertainties. Phase B no longer asks these as a
// form: the chat intake is the only place they get answered, so the agent
// treats them as its highest-leverage question bank.
verificationQuestions: string[]
intakeAlreadyCompleted: boolean
// Titles of the loaded specialty atoms: used in the prompt to remind the
// agent which industry depth it can lean on for follow-up questions.
loadedAtomTitles: string[]
}
export const onboardingIntake = defineAgentIntent<IntakeArgs, CapturedIntake>({
id: 'onboarding.intake',
buttonLabel: 'Starta introduktion',
sheetTitle: 'Introduktion',
atoms: {
mode: 'declarative',
horizontal: ['swedish-accounting-compliance'],
includeCompanyVertical: true,
includeCompanyModifiers: true,
},
// No write tools: the intake conversation only captures memory. Specific
// staged operations come later from other intents once the agent knows the
// business.
tools: [
'gnubok_search_tools',
'gnubok_list_skills',
'gnubok_load_skill',
'gnubok_remember_fact',
'gnubok_forget_fact',
],
model: SONNET_MODEL,
capture: async (_args, { supabase, companyId, userId }) => {
const [
{ data: profile },
{ data: company },
{ data: userProfile },
] = await Promise.all([
supabase
.from('agent_profiles')
.select(
'display_name, profile_summary, verification_questions, intake_completed_at, vertical_atoms, modifier_atoms, horizontal_atoms',
)
.eq('company_id', companyId)
.maybeSingle(),
supabase.from('companies').select('name').eq('id', companyId).maybeSingle(),
supabase.from('profiles').select('full_name').eq('id', userId).maybeSingle(),
])
const verificationQuestions =
((profile?.verification_questions as string[] | null) ?? []).filter(
(q) => typeof q === 'string' && q.trim().length > 0,
)
const atomIds = [
...((profile?.vertical_atoms as string[] | null) ?? []),
...((profile?.modifier_atoms as string[] | null) ?? []),
]
let loadedAtomTitles: string[] = []
if (atomIds.length > 0) {
const { data: atoms } = await supabase
.from('agent_atom_registry')
.select('id, title')
.in('id', atomIds)
loadedAtomTitles = ((atoms ?? []) as { title: string }[]).map((r) => r.title)
}
const fullName = (userProfile?.full_name as string | null) ?? null
const userFirstName = fullName ? fullName.split(' ')[0] : null
return {
agentDisplayName: (profile?.display_name as string | null) ?? null,
userFirstName,
companyName: (company?.name as string | null) ?? null,
profileSummary: (profile?.profile_summary as string | null) ?? null,
verificationQuestions,
intakeAlreadyCompleted: !!profile?.intake_completed_at,
loadedAtomTitles,
}
},
promptTemplate: ({ captured }) => {
const agent = captured.agentDisplayName?.trim() || 'din bokföringsassistent'
const user = captured.userFirstName?.trim() || null
const lines: string[] = []
// Identity + tone: first message the user reads should feel like a
// person, not a form. Hedge against the agent immediately listing
// questions: the directive below is explicit.
lines.push(
`Du är ${agent}. Detta är ditt allra första möte med ${user ?? 'användaren'}${captured.companyName ? ` på ${captured.companyName}` : ''}.`,
)
lines.push('')
if (captured.profileSummary) {
lines.push('Detta är vad du redan vet om verksamheten (från Bolagsverket + uppgifter användaren bekräftat):')
lines.push(captured.profileSummary)
lines.push('')
}
// The composer flagged these as the highest-leverage uncertainties. This
// intake is the only place they get answered: weave them into the
// conversation naturally, starting with the ones that matter most. Never
// dump them on the user as a list.
if (captured.verificationQuestions.length > 0) {
lines.push('Det här är de viktigaste sakerna du fortfarande är osäker på och vill få klarhet i under samtalet (väv in dem naturligt, en i taget, börja med de viktigaste):')
for (const q of captured.verificationQuestions) lines.push(` • ${q}`)
lines.push('')
}
if (captured.loadedAtomTitles.length > 0) {
lines.push(`Dina laddade specialiteter ger dig djup för branschspecifika följdfrågor: ${captured.loadedAtomTitles.join(', ')}.`)
lines.push('Använd dem för att forma 3-7 frågor som hjälper dig förstå verksamheten på riktigt: det som en ny redovisningskonsult skulle vilja veta vid första mötet.')
lines.push('')
}
// Operational guidance. Keep it firm: one question at a time, not a
// wall. Persist via memory. End naturally: don't force a "complete"
// signal. Plan §7: "If the user wants to skip: they close the sheet."
lines.push('Hur du genomför mötet:')
lines.push('• Inled med en kort, varm hälsning: presentera dig kort. Inget formellt, inga punktlistor.')
lines.push('• Ställ EN fråga i taget och vänta in svaret. Aldrig en mur av frågor.')
lines.push('• Lyssna ordentligt. Följ upp naturligt: en intresserad fördjupande följdfråga är ofta värt mer än nästa nya fråga.')
lines.push('• När användaren säger något betydelsefullt (återkommande kund, hyresavtal, lönepolicy, anställda du inte visste om, kunder utomlands…): spara det med gnubok_remember_fact med source_ref="onboarding_intake" och relevance_score 0.9. Berätta inte att du sparar; gör det tyst.')
lines.push('• Behöver du djupare branschkunskap för en följdfråga? Använd gnubok_load_skill på rätt atom.')
lines.push('• Sikta på 5-10 frågor totalt över hela samtalet. Inte mer. Bättre färre, men bra.')
lines.push('• Avsluta naturligt när du har en bra bild: säg att ni kan fortsätta nästa gång ni ses i bokföringen, och att du kommer minnas det här samtalet.')
lines.push('')
lines.push('Svara på svenska. Ditt första meddelande ska vara hälsningen + första frågan. Skriv det nu.')
return lines.join('\n')
},
})