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>
426 lines
15 KiB
TypeScript
426 lines
15 KiB
TypeScript
/**
|
||
* Dimension helpers for the MCP server (PR3 of the dimensions plan:
|
||
* dev_docs/dimensions_implementation_plan.md §6).
|
||
*
|
||
* Two responsibilities, shared by the dimension tools and the dims-bag write
|
||
* tools (gnubok_create_voucher / gnubok_correct_entry):
|
||
*
|
||
* 1. Registry access: ensure_company_dimensions + the same two-query fetch
|
||
* the dashboard GET /api/dimensions uses, returning the identical shape.
|
||
*
|
||
* 2. Resolve-don't-select: an incoming dimension value may be an object
|
||
* code ("KS01") OR a natural-language name ("Villa Almgren tak"). The
|
||
* server resolves exact code → exact name → fuse.js fuzzy over the
|
||
* dimension's ACTIVE values. A single high-confidence hit resolves (and
|
||
* is echoed back with its confidence); multiple/low-confidence candidates
|
||
* reject with a ranked list so the agent retries with a code or stages
|
||
* gnubok_create_dimension_value. NO auto-create, ever: agents must not
|
||
* silently mint reporting values.
|
||
*
|
||
* Resolution honours the validation contract: company_settings.dimensions_enabled
|
||
* is fetched ONCE; when false the bags pass through verbatim (free-text
|
||
* backward compatibility: the engine skips validation too), when true every
|
||
* value must land on an active registry value. Untagged entries cost zero
|
||
* queries.
|
||
*/
|
||
import Fuse from 'fuse.js'
|
||
import { roundOre } from '@/lib/money'
|
||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||
import { z } from 'zod'
|
||
import {
|
||
DimensionsBagSchema,
|
||
normalizeLineDimensions,
|
||
type LineDimensions,
|
||
} from '@/lib/bookkeeping/dimension-resolver'
|
||
|
||
// ── Registry shapes (mirror GET /api/dimensions exactly) ─────────────────────
|
||
|
||
export interface DimensionValueEntry {
|
||
id: string
|
||
code: string
|
||
name: string
|
||
is_active: boolean
|
||
start_date: string | null
|
||
end_date: string | null
|
||
}
|
||
|
||
export interface DimensionRegistryEntry {
|
||
id: string
|
||
sie_dim_no: number
|
||
name: string
|
||
resets_annually: boolean
|
||
is_system: boolean
|
||
is_active: boolean
|
||
sort_order: number
|
||
values: DimensionValueEntry[]
|
||
}
|
||
|
||
/**
|
||
* Lazy get-or-create of the system dims (1 = Kostnadsställe, 6 = Projekt).
|
||
* Idempotent; keeps core zero-config for companies that never touch dimensions.
|
||
*/
|
||
export async function ensureCompanyDimensions(
|
||
supabase: SupabaseClient,
|
||
companyId: string,
|
||
): Promise<void> {
|
||
const { error } = await supabase.rpc('ensure_company_dimensions', {
|
||
p_company_id: companyId,
|
||
})
|
||
if (error) throw new Error(`Failed to seed system dimensions: ${error.message}`)
|
||
}
|
||
|
||
/**
|
||
* Fetch the full registry incl. values: the same two queries and the same
|
||
* nested shape as the dashboard GET /api/dimensions, so agents and the
|
||
* register UI see one consistent contract.
|
||
*/
|
||
export async function fetchDimensionRegistry(
|
||
supabase: SupabaseClient,
|
||
companyId: string,
|
||
): Promise<DimensionRegistryEntry[]> {
|
||
const { data: dims, error: dimsError } = await supabase
|
||
.from('dimensions')
|
||
.select('id, sie_dim_no, name, resets_annually, is_system, is_active, sort_order')
|
||
.eq('company_id', companyId)
|
||
.order('sort_order', { ascending: true })
|
||
.order('sie_dim_no', { ascending: true })
|
||
if (dimsError) throw new Error(`Failed to list dimensions: ${dimsError.message}`)
|
||
|
||
const { data: values, error: valuesError } = await supabase
|
||
.from('dimension_values')
|
||
.select('id, dimension_id, code, name, is_active, start_date, end_date')
|
||
.eq('company_id', companyId)
|
||
.order('code', { ascending: true })
|
||
if (valuesError) throw new Error(`Failed to list dimension values: ${valuesError.message}`)
|
||
|
||
const valuesByDimension = new Map<string, DimensionValueEntry[]>()
|
||
for (const v of (values ?? []) as Array<DimensionValueEntry & { dimension_id: string }>) {
|
||
const bucket = valuesByDimension.get(v.dimension_id) ?? []
|
||
bucket.push({
|
||
id: v.id,
|
||
code: v.code,
|
||
name: v.name,
|
||
is_active: v.is_active,
|
||
start_date: v.start_date,
|
||
end_date: v.end_date,
|
||
})
|
||
valuesByDimension.set(v.dimension_id, bucket)
|
||
}
|
||
|
||
return ((dims ?? []) as Array<Omit<DimensionRegistryEntry, 'values'>>).map((d) => ({
|
||
...d,
|
||
values: valuesByDimension.get(d.id) ?? [],
|
||
}))
|
||
}
|
||
|
||
// ── Input parsing ────────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* MCP boundary schema for a dimensions bag where values may be NAMES, not just
|
||
* codes. Looser than DimensionsBagSchema on length (names go up to 120 chars,
|
||
* dimension_values.name CHECK) but keeps the SIE-framing charset ban. After
|
||
* resolution the final bags are re-validated against DimensionsBagSchema (the
|
||
* exact schema the API layer and staged commit path use) so nothing loose is
|
||
* ever staged.
|
||
*/
|
||
const DimensionsInputSchema = z.record(
|
||
z.string().regex(/^[1-9]\d*$/, 'nyckeln måste vara ett SIE-dimensionsnummer (t.ex. "1" eller "6")'),
|
||
z
|
||
.string()
|
||
.min(1)
|
||
.max(120)
|
||
.regex(/^[^"{}]+$/, 'värdet får inte innehålla ", { eller }'),
|
||
)
|
||
|
||
/**
|
||
* Parse an untyped `dimensions` / `default_dimensions` tool argument. Throws a
|
||
* loud, actionable error on invalid shape: the MCP boundary is an input gate,
|
||
* unlike coerceDimensionsBag (which silently drops on the trusted staged path).
|
||
*/
|
||
export function parseDimensionsArg(raw: unknown, field: string): LineDimensions | undefined {
|
||
if (raw === undefined || raw === null) return undefined
|
||
const parsed = DimensionsInputSchema.safeParse(raw)
|
||
if (!parsed.success) {
|
||
const issue = parsed.error.issues[0]
|
||
throw new Error(
|
||
`Invalid ${field}: ${issue?.message ?? 'ogiltig dimensionsmap'}. ` +
|
||
'Expected {"<sie_dim_no>":"<kod eller namn>"}, e.g. {"1":"KS01","6":"P001"}.',
|
||
)
|
||
}
|
||
const dims = normalizeLineDimensions({ dimensions: parsed.data })
|
||
return Object.keys(dims).length > 0 ? dims : undefined
|
||
}
|
||
|
||
// ── Resolve-don't-select ─────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Fuse score cutoff for a single high-confidence fuzzy hit. Fuse scores run
|
||
* 0 (perfect) → 1 (no relation); 0.3 matches the app-wide precedent for
|
||
* registry lookups (components/import/OpeningBalanceRowEditor.tsx uses
|
||
* threshold 0.3 for BAS account search). Anything above is "plausible but not
|
||
* safe to attach": plan risk #5 is a confident-but-wrong project tag.
|
||
*/
|
||
export const DIMENSION_FUZZY_SCORE_MAX = 0.3
|
||
/**
|
||
* Minimum score gap to the runner-up before a fuzzy hit counts as unambiguous.
|
||
* Two near-tied candidates (e.g. "P001 Villa Alm" vs "P002 Villa Alm etapp 2")
|
||
* must be disambiguated by the agent, not guessed at.
|
||
*/
|
||
export const DIMENSION_FUZZY_AMBIGUITY_MARGIN = 0.15
|
||
|
||
/** Echoed in tool responses for every non-exact resolution. */
|
||
export interface DimensionResolution {
|
||
dimension: number
|
||
input: string
|
||
resolved_code: string
|
||
resolved_name: string
|
||
/** 1 for exact code/name matches; 1 − fuse score (2 decimals) for fuzzy. */
|
||
confidence: number
|
||
}
|
||
|
||
/** Typed rejection so tests/tools can distinguish resolution failures. */
|
||
export class DimensionResolutionError extends Error {
|
||
readonly dimension: number
|
||
readonly input: string
|
||
readonly candidates: Array<{ code: string; name: string; confidence: number }>
|
||
|
||
constructor(
|
||
message: string,
|
||
dimension: number,
|
||
input: string,
|
||
candidates: Array<{ code: string; name: string; confidence: number }> = [],
|
||
) {
|
||
super(message)
|
||
this.name = 'DimensionResolutionError'
|
||
this.dimension = dimension
|
||
this.input = input
|
||
this.candidates = candidates
|
||
}
|
||
}
|
||
|
||
function dimensionLabel(dim: Pick<DimensionRegistryEntry, 'sie_dim_no' | 'name'>): string {
|
||
if (dim.sie_dim_no === 1) return 'kostnadsställe'
|
||
if (dim.sie_dim_no === 6) return 'projekt'
|
||
return dim.name
|
||
}
|
||
|
||
interface ResolvedValue {
|
||
code: string
|
||
name: string
|
||
exact: boolean
|
||
confidence: number
|
||
}
|
||
|
||
/**
|
||
* Resolve one input (code or name) inside one dimension. Pure: takes registry
|
||
* data, so it is unit-testable without a DB. Throws DimensionResolutionError
|
||
* on archived / unknown / ambiguous inputs; never auto-creates.
|
||
*/
|
||
export function resolveValueInDimension(
|
||
dim: DimensionRegistryEntry,
|
||
input: string,
|
||
): ResolvedValue {
|
||
const active = dim.values.filter((v) => v.is_active)
|
||
const label = dimensionLabel(dim)
|
||
|
||
// 1. Exact code match among active values: the fast path, not echoed.
|
||
const exactCode = active.find((v) => v.code === input)
|
||
if (exactCode) return { code: exactCode.code, name: exactCode.name, exact: true, confidence: 1 }
|
||
|
||
// Archived guard: an exact code hit on an inactive value is a hard stop:
|
||
// same rule the engine enforces at draft time.
|
||
const archived = dim.values.find((v) => !v.is_active && v.code === input)
|
||
if (archived) {
|
||
throw new DimensionResolutionError(
|
||
`"${input}" är arkiverat: återaktivera värdet för att använda det.`,
|
||
dim.sie_dim_no,
|
||
input,
|
||
)
|
||
}
|
||
|
||
// 2. Exact (case-insensitive) code or exact name match: unambiguous but
|
||
// echoed, since the stored code differs from the raw input.
|
||
const lowered = input.trim().toLowerCase()
|
||
const exactish = active.filter(
|
||
(v) => v.code.toLowerCase() === lowered || v.name.trim().toLowerCase() === lowered,
|
||
)
|
||
if (exactish.length === 1) {
|
||
return { code: exactish[0].code, name: exactish[0].name, exact: false, confidence: 1 }
|
||
}
|
||
if (exactish.length > 1) {
|
||
throw new DimensionResolutionError(
|
||
`"${input}" matchar flera värden i ${label} (dimension ${dim.sie_dim_no}): ` +
|
||
exactish.map((v) => `"${v.code}" (${v.name})`).join(', ') +
|
||
'. Ange en exakt kod i stället.',
|
||
dim.sie_dim_no,
|
||
input,
|
||
exactish.map((v) => ({ code: v.code, name: v.name, confidence: 1 })),
|
||
)
|
||
}
|
||
|
||
// 3. Fuzzy over active values.
|
||
const fuse = new Fuse(active, {
|
||
keys: ['code', 'name'],
|
||
includeScore: true,
|
||
threshold: 0.4,
|
||
})
|
||
const hits = fuse.search(input)
|
||
|
||
if (hits.length === 0) {
|
||
throw new DimensionResolutionError(
|
||
`Okänt ${label}: "${input}" (dimension ${dim.sie_dim_no}). Skapa värdet i registret först: ` +
|
||
'stage det med gnubok_create_dimension_value, eller ange en befintlig kod ' +
|
||
'(gnubok_list_dimension_values).',
|
||
dim.sie_dim_no,
|
||
input,
|
||
)
|
||
}
|
||
|
||
const top = hits[0]
|
||
const topScore = top.score ?? 1
|
||
const runnerUpScore = hits[1]?.score ?? Number.POSITIVE_INFINITY
|
||
const unambiguous =
|
||
topScore <= DIMENSION_FUZZY_SCORE_MAX &&
|
||
runnerUpScore - topScore >= DIMENSION_FUZZY_AMBIGUITY_MARGIN
|
||
|
||
if (unambiguous) {
|
||
return {
|
||
code: top.item.code,
|
||
name: top.item.name,
|
||
exact: false,
|
||
confidence: roundOre(1 - topScore),
|
||
}
|
||
}
|
||
|
||
const candidates = hits.slice(0, 5).map((h) => ({
|
||
code: h.item.code,
|
||
name: h.item.name,
|
||
confidence: roundOre(1 - (h.score ?? 1)),
|
||
}))
|
||
throw new DimensionResolutionError(
|
||
`Kunde inte entydigt matcha "${input}" mot ett ${label} (dimension ${dim.sie_dim_no}). Kandidater: ` +
|
||
candidates.map((c) => `"${c.code}" (${c.name}, ${Math.round(c.confidence * 100)}%)`).join(', ') +
|
||
'. Ange en exakt kod, eller skapa ett nytt värde med gnubok_create_dimension_value.',
|
||
dim.sie_dim_no,
|
||
input,
|
||
candidates,
|
||
)
|
||
}
|
||
|
||
export interface ResolveBagsResult {
|
||
/** Same length/positions as the input; values rewritten to registry codes. */
|
||
bags: Array<LineDimensions | undefined>
|
||
/** One entry per distinct non-exact (dimension, input) pair. */
|
||
resolutions: DimensionResolution[]
|
||
}
|
||
|
||
/**
|
||
* Resolve the dimension bags of a whole voucher in one pass.
|
||
*
|
||
* Query budget (validation contract): zero queries when no line carries a
|
||
* bag; one company_settings read when dimensions are disabled (free-text
|
||
* passthrough); ensure-RPC + two registry reads when enabled. Never per-line.
|
||
*/
|
||
export async function resolveDimensionBags(
|
||
supabase: SupabaseClient,
|
||
companyId: string,
|
||
bags: Array<LineDimensions | undefined>,
|
||
): Promise<ResolveBagsResult> {
|
||
const hasAny = bags.some((b) => b && Object.keys(b).length > 0)
|
||
if (!hasAny) return { bags, resolutions: [] }
|
||
|
||
const { data: settings } = await supabase
|
||
.from('company_settings')
|
||
.select('dimensions_enabled')
|
||
.eq('company_id', companyId)
|
||
.maybeSingle()
|
||
|
||
const finalize = (resolved: Array<LineDimensions | undefined>): void => {
|
||
// Whatever path produced the bags, the staged result must satisfy THE bag
|
||
// schema (max 40-char codes, SIE-safe charset): otherwise the commit-side
|
||
// coerceDimensionsBag would silently drop the whole bag at booking time.
|
||
for (const bag of resolved) {
|
||
if (!bag) continue
|
||
const check = DimensionsBagSchema.safeParse(bag)
|
||
if (!check.success) {
|
||
const issue = check.error.issues[0]
|
||
throw new Error(
|
||
`Ogiltig dimensionskod: ${issue?.message ?? 'ogiltigt värde'}. ` +
|
||
'Koder får vara max 40 tecken: registrera värdet i registret och referera det via kod eller namn.',
|
||
)
|
||
}
|
||
}
|
||
}
|
||
|
||
if (!(settings as { dimensions_enabled?: boolean } | null)?.dimensions_enabled) {
|
||
// Dimensions not enabled: free-text passthrough, exactly like the engine's
|
||
// validation rule 2: existing API/MCP writers keep working unchanged.
|
||
finalize(bags)
|
||
return { bags, resolutions: [] }
|
||
}
|
||
|
||
await ensureCompanyDimensions(supabase, companyId)
|
||
const registry = await fetchDimensionRegistry(supabase, companyId)
|
||
const byDimNo = new Map(registry.map((d) => [d.sie_dim_no, d]))
|
||
|
||
const cache = new Map<string, ResolvedValue & { dimension: number; input: string }>()
|
||
const resolveOne = (dimNo: string, input: string) => {
|
||
const key = `${dimNo}\u0000${input}`
|
||
const cached = cache.get(key)
|
||
if (cached) return cached
|
||
const dim = byDimNo.get(Number(dimNo))
|
||
if (!dim) {
|
||
throw new DimensionResolutionError(
|
||
`Okänd dimension ${dimNo}. Registrerade dimensioner: ` +
|
||
(registry.map((d) => `${d.sie_dim_no} (${d.name})`).join(', ') || 'inga') +
|
||
'. Anropa gnubok_list_dimensions.',
|
||
Number(dimNo),
|
||
input,
|
||
)
|
||
}
|
||
const resolved = { ...resolveValueInDimension(dim, input), dimension: dim.sie_dim_no, input }
|
||
cache.set(key, resolved)
|
||
return resolved
|
||
}
|
||
|
||
const outBags = bags.map((bag) => {
|
||
if (!bag || Object.keys(bag).length === 0) return bag
|
||
const out: LineDimensions = {}
|
||
for (const [dimNo, value] of Object.entries(bag)) {
|
||
out[dimNo] = resolveOne(dimNo, value).code
|
||
}
|
||
return out
|
||
})
|
||
|
||
const resolutions: DimensionResolution[] = [...cache.values()]
|
||
.filter((r) => !r.exact)
|
||
.map((r) => ({
|
||
dimension: r.dimension,
|
||
input: r.input,
|
||
resolved_code: r.code,
|
||
resolved_name: r.name,
|
||
confidence: r.confidence,
|
||
}))
|
||
|
||
finalize(outBags)
|
||
return { bags: outBags, resolutions }
|
||
}
|
||
|
||
/**
|
||
* Per-line effective bag for a voucher tool: the line's own normalized map
|
||
* (explicit bag wins over the deprecated cost_center/project aliases) filled
|
||
* with voucher-level defaults for keys the line does not set itself.
|
||
*/
|
||
export function mergeLineDimensions(
|
||
line: { dimensions?: LineDimensions; cost_center?: string; project?: string },
|
||
defaults: LineDimensions | undefined,
|
||
): LineDimensions | undefined {
|
||
const own = normalizeLineDimensions({
|
||
dimensions: line.dimensions ?? null,
|
||
cost_center: line.cost_center ?? null,
|
||
project: line.project ?? null,
|
||
})
|
||
const merged = { ...(defaults ?? {}), ...own }
|
||
return Object.keys(merged).length > 0 ? merged : undefined
|
||
}
|