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>
110 lines
5.1 KiB
TypeScript
110 lines
5.1 KiB
TypeScript
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')
|
||
}
|