feat(mcp): byte-exact SIE upload path + brevity and memory-first onboarding (#1954)
From the fourth E2E run: the agent correctly refused to reproduce a 104 KB SIE file token by token (silent mid-verifikat truncation) and dead-ended to the web wizard, and its replies were walls of compliance prose. 1. gnubok_create_sie_upload: signed same-origin upload URL (reuses the pending-document infra; .se/.sie/.si only, 50 MB HTTP cap). gnubok_sie_preflight and gnubok_import_sie accept upload_id as the byte-exact source, plus optional sha256 (hex of the raw bytes) verified on the upload_id/base64 paths so truncation is DETECTED, never silent. Inline content above 120k chars is refused with a pointer to the upload flow. Scope bookkeeping:write (same intent as import_sie). 2. Skill: brevity rule (max ~8 short lines per reply, one warning per step, no legal essays), memory-first rule (check what is already known before asking the opening questions), the upload-first SIE step, and gnubok_explain_voucher_gap after import for skipped voucher numbers. 3. CONNECTORS.md starter prompt rewritten memory-first so it stays copy-paste ready without the user's own data in it. Plugin v1.2.2. tools/list ceiling 62K to 62.4K documented in the bench. Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Jakob Wennberg
Claude Fable 5
parent
dcb5d329a3
commit
cd9fb5b717
@@ -2,7 +2,7 @@
|
|||||||
"name": "accounted",
|
"name": "accounted",
|
||||||
"displayName": "Accounted",
|
"displayName": "Accounted",
|
||||||
"description": "Official Accounted plugin: Swedish double-entry bookkeeping flows for Claude. Connects your ledger over MCP and ships short workflow skills (daily bookkeeping, health check, month close, VAT, payroll, year-end) that work from the company's live data and load Swedish accounting knowledge from the product when needed. Every write is staged for your approval; nothing is booked on its own.",
|
"description": "Official Accounted plugin: Swedish double-entry bookkeeping flows for Claude. Connects your ledger over MCP and ships short workflow skills (daily bookkeeping, health check, month close, VAT, payroll, year-end) that work from the company's live data and load Swedish accounting knowledge from the product when needed. Every write is staged for your approval; nothing is booked on its own.",
|
||||||
"version": "1.2.1",
|
"version": "1.2.2",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Accounted (erp-mafia)"
|
"name": "Accounted (erp-mafia)"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -14,14 +14,9 @@ One-click add on claude.ai (opens the Add custom connector dialog prefilled; the
|
|||||||
https://claude.ai/customize/connectors?modal=add-custom-connector&connectorName=Accounted&connectorUrl=https%3A%2F%2Fapp.accounted.se%2Fapi%2Fextensions%2Fext%2Fmcp-server%2Fmcp%3Ftool_namespace%3Daccounted
|
https://claude.ai/customize/connectors?modal=add-custom-connector&connectorName=Accounted&connectorUrl=https%3A%2F%2Fapp.accounted.se%2Fapi%2Fextensions%2Fext%2Fmcp-server%2Fmcp%3Ftool_namespace%3Daccounted
|
||||||
```
|
```
|
||||||
|
|
||||||
Pair the link with a starter prompt the user pastes as their first message. The short form works because the server-side onboarding skill carries the whole flow; the long form pre-answers the three opening questions and saves a round-trip:
|
Pair the link with a starter prompt the user pastes as their first message. It stays copy-paste ready for everyone because it points the agent at what it already knows (Claude memory, earlier chats) instead of containing the user's own data; the server-side onboarding skill carries the rest of the flow:
|
||||||
|
|
||||||
> Sätt upp mitt företag i Accounted.
|
> Sätt upp mitt företag i Accounted. Utgå från det du redan vet om mig och mitt bolag (organisationsnummer, bank, tidigare bokföringssystem) och fråga bara efter det som saknas. Håll det kort.
|
||||||
|
|
||||||
> Sätt upp mitt företag i Accounted.
|
|
||||||
> Organisationsnummer: `<orgnr>`
|
|
||||||
> Tidigare bokföringssystem: `<t.ex. Fortnox, eller inget>`
|
|
||||||
> Bank: `<t.ex. Swedbank>`
|
|
||||||
|
|
||||||
## How the connection works
|
## How the connection works
|
||||||
|
|
||||||
|
|||||||
@@ -230,9 +230,13 @@ describe('tools/list payload size guard', () => {
|
|||||||
// the scan-before-import step the skill instructs for shared SIE
|
// the scan-before-import step the skill instructs for shared SIE
|
||||||
// files, so default-catalog for the same Claude.ai reason; ~390
|
// files, so default-catalog for the same Claude.ai reason; ~390
|
||||||
// tokens (schema carries the mappings-passthrough contract).
|
// tokens (schema carries the mappings-passthrough contract).
|
||||||
|
// * 62K to 62.4K with gnubok_create_sie_upload + upload_id/sha256 on the
|
||||||
|
// two SIE tools: the byte-exact upload path after a real 104 KB file
|
||||||
|
// dead-ended in chat (a model cannot reproduce 30k tokens verbatim
|
||||||
|
// without silent-truncation risk); skill-instructed, so default catalog.
|
||||||
// Long-term answer to growth is leaning harder on gnubok_search_tools: if this
|
// Long-term answer to growth is leaning harder on gnubok_search_tools: if this
|
||||||
// fires again, prefer trimming descriptions or making a tool opt-in via search
|
// fires again, prefer trimming descriptions or making a tool opt-in via search
|
||||||
// before bumping further.
|
// before bumping further.
|
||||||
expect(approxTokens).toBeLessThan(62_000)
|
expect(approxTokens).toBeLessThan(62_400)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -184,4 +184,45 @@ describe('gnubok_sie_preflight', () => {
|
|||||||
it('rejects a call with neither content field', async () => {
|
it('rejects a call with neither content field', async () => {
|
||||||
await expect(run({})).rejects.toMatchObject({ code: 'VALIDATION_ERROR' })
|
await expect(run({})).rejects.toMatchObject({ code: 'VALIDATION_ERROR' })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('refuses oversized inline content instead of risking silent mid-verifikat truncation', async () => {
|
||||||
|
const huge = VALID_SIE + '\n' + '#KONTO 9999 "x"\n'.repeat(10_000)
|
||||||
|
await expect(run({ file_content: huge })).rejects.toMatchObject({
|
||||||
|
code: 'VALIDATION_ERROR',
|
||||||
|
message: expect.stringContaining('gnubok_create_sie_upload'),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('verifies sha256 on the base64 path and rejects a mismatch as truncation', async () => {
|
||||||
|
const bytes = Buffer.from(VALID_SIE, 'utf8')
|
||||||
|
const { createHash } = await import('node:crypto')
|
||||||
|
const good = createHash('sha256').update(bytes).digest('hex')
|
||||||
|
|
||||||
|
const ok = await run({ file_content_base64: bytes.toString('base64'), sha256: good })
|
||||||
|
expect(ok.verdict).toBe('ok')
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
run({ file_content_base64: bytes.toString('base64'), sha256: 'a'.repeat(64) })
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
code: 'VALIDATION_ERROR',
|
||||||
|
message: expect.stringContaining('sha256 mismatch'),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('gnubok_create_sie_upload', () => {
|
||||||
|
const uploadTool = tools.find((t) => t.name === 'gnubok_create_sie_upload')!
|
||||||
|
|
||||||
|
it('is registered with the filename-only contract', () => {
|
||||||
|
expect(uploadTool).toBeDefined()
|
||||||
|
expect(
|
||||||
|
(uploadTool.inputSchema as { required: string[] }).required
|
||||||
|
).toEqual(['filename'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects a filename that is not a SIE file', async () => {
|
||||||
|
await expect(
|
||||||
|
uploadTool.execute({ filename: 'export.xlsx' }, COMPANY_ID, 'user-1', {} as never)
|
||||||
|
).rejects.toMatchObject({ code: 'VALIDATION_ERROR' })
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -257,8 +257,11 @@ import {
|
|||||||
createPendingDocumentUpload,
|
createPendingDocumentUpload,
|
||||||
uploadDocument,
|
uploadDocument,
|
||||||
MAX_DOCUMENT_SIZE,
|
MAX_DOCUMENT_SIZE,
|
||||||
|
buildPendingDocumentStoragePath,
|
||||||
|
DOCUMENTS_BUCKET,
|
||||||
} from '@/lib/core/documents/document-service'
|
} from '@/lib/core/documents/document-service'
|
||||||
import { toSameOriginStorageUrl } from '@/lib/core/documents/storage-proxy'
|
import { toSameOriginStorageUrl } from '@/lib/core/documents/storage-proxy'
|
||||||
|
import { createHash } from 'node:crypto'
|
||||||
import { extractInvoiceFields, ExtractionSchema as InvoiceExtractionSchema, AgentExtractionSchema } from '@/extensions/general/invoice-inbox/lib/extract-invoice-fields'
|
import { extractInvoiceFields, ExtractionSchema as InvoiceExtractionSchema, AgentExtractionSchema } from '@/extensions/general/invoice-inbox/lib/extract-invoice-fields'
|
||||||
import { mirrorExtractionToDocument } from '@/extensions/general/invoice-inbox/lib/mirror-extraction'
|
import { mirrorExtractionToDocument } from '@/extensions/general/invoice-inbox/lib/mirror-extraction'
|
||||||
// Skatteverket filing tools (PR5). Cross-extension lib import, same sanctioned
|
// Skatteverket filing tools (PR5). Cross-extension lib import, same sanctioned
|
||||||
@@ -1445,22 +1448,101 @@ export function isDefaultCatalogTool(tool: { catalogVisibility?: 'default' | 'se
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve SIE file content from tool args: plain text (the model read the
|
* Inline SIE content above this length is refused: a model reproducing tens
|
||||||
* attachment) or base64 (exact bytes, e.g. from a code-execution sandbox).
|
* of thousands of tokens verbatim WILL eventually truncate mid-#VER, and a
|
||||||
* The base64 path runs the same encoding detection as the HTTP upload route,
|
* truncated file that still parses imports silently incomplete bookkeeping.
|
||||||
* so CP437 exports keep their åäö instead of arriving pre-mangled through a
|
* Larger files go through gnubok_create_sie_upload (raw bytes, no model in
|
||||||
* host's UTF-8 read. Returns null when neither field is usable.
|
* the path). ~120k chars ≈ a few thousand verifikat rows.
|
||||||
*/
|
*/
|
||||||
async function decodeSieToolContent(args: Record<string, unknown>): Promise<string | null> {
|
const MAX_INLINE_SIE_CHARS = 120_000
|
||||||
if (typeof args.file_content === 'string' && args.file_content.length > 0) {
|
|
||||||
return args.file_content
|
/**
|
||||||
|
* Resolve SIE file content from tool args, three sources in priority order:
|
||||||
|
*
|
||||||
|
* - upload_id: raw bytes PUT to a gnubok_create_sie_upload URL. The only
|
||||||
|
* path with no model in the loop; required for large files.
|
||||||
|
* - file_content_base64: exact bytes base64-encoded (e.g. from a
|
||||||
|
* code-execution sandbox).
|
||||||
|
* - file_content: plain text as the model read the attachment.
|
||||||
|
*
|
||||||
|
* Byte paths run the same encoding detection as the HTTP upload route, so
|
||||||
|
* CP437 exports keep their åäö. An optional sha256 (hex, of the RAW BYTES)
|
||||||
|
* is verified on the two byte-exact paths and proves the content was not
|
||||||
|
* truncated or altered in transit; it cannot be checked for plain text
|
||||||
|
* (the model's read may legitimately differ from the file's bytes).
|
||||||
|
* Returns null when no source is usable.
|
||||||
|
*/
|
||||||
|
async function resolveSieToolContent(
|
||||||
|
args: Record<string, unknown>,
|
||||||
|
companyId: string,
|
||||||
|
userId: string
|
||||||
|
): Promise<string | null> {
|
||||||
|
const sha256 = typeof args.sha256 === 'string' ? args.sha256.trim().toLowerCase() : null
|
||||||
|
const verifyBytes = (buffer: Buffer, source: string) => {
|
||||||
|
if (!sha256) return
|
||||||
|
const actual = createHash('sha256').update(buffer).digest('hex')
|
||||||
|
if (actual !== sha256) {
|
||||||
|
throw Object.assign(
|
||||||
|
new Error(
|
||||||
|
`sha256 mismatch on ${source}: expected ${sha256}, got ${actual}. The content was truncated or altered; re-send it.`
|
||||||
|
),
|
||||||
|
{ code: 'VALIDATION_ERROR' }
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (typeof args.file_content_base64 === 'string' && args.file_content_base64.length > 0) {
|
|
||||||
const { detectEncoding, decodeBuffer } = await import('@/lib/import/sie-parser')
|
const { detectEncoding, decodeBuffer } = await import('@/lib/import/sie-parser')
|
||||||
const buffer = Buffer.from(args.file_content_base64, 'base64')
|
|
||||||
|
if (typeof args.upload_id === 'string' && args.upload_id.length > 0) {
|
||||||
|
const fileName = typeof args.filename === 'string' ? args.filename : ''
|
||||||
|
if (!fileName) {
|
||||||
|
throw Object.assign(new Error('filename is required together with upload_id'), {
|
||||||
|
code: 'VALIDATION_ERROR',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const service = createServiceClientNoCookies()
|
||||||
|
const pendingPath = buildPendingDocumentStoragePath(companyId, userId, args.upload_id, fileName)
|
||||||
|
const { data, error } = await service.storage.from(DOCUMENTS_BUCKET).download(pendingPath)
|
||||||
|
if (error || !data) {
|
||||||
|
throw Object.assign(
|
||||||
|
new Error(
|
||||||
|
'No uploaded file found for this upload_id. PUT the raw file bytes to the upload_url from gnubok_create_sie_upload first (same upload_id and filename).'
|
||||||
|
),
|
||||||
|
{ code: 'NOT_FOUND' }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const buffer = Buffer.from(await data.arrayBuffer())
|
||||||
|
verifyBytes(buffer, 'the uploaded file')
|
||||||
const arrayBuffer = buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength)
|
const arrayBuffer = buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength)
|
||||||
return decodeBuffer(arrayBuffer, detectEncoding(arrayBuffer))
|
return decodeBuffer(arrayBuffer, detectEncoding(arrayBuffer))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (typeof args.file_content_base64 === 'string' && args.file_content_base64.length > 0) {
|
||||||
|
if (args.file_content_base64.length > MAX_INLINE_SIE_CHARS) {
|
||||||
|
throw Object.assign(
|
||||||
|
new Error(
|
||||||
|
'File too large to pass inline safely. Use gnubok_create_sie_upload, PUT the raw bytes to its upload_url, and pass the upload_id here instead.'
|
||||||
|
),
|
||||||
|
{ code: 'VALIDATION_ERROR' }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const buffer = Buffer.from(args.file_content_base64, 'base64')
|
||||||
|
verifyBytes(buffer, 'file_content_base64')
|
||||||
|
const arrayBuffer = buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength)
|
||||||
|
return decodeBuffer(arrayBuffer, detectEncoding(arrayBuffer))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof args.file_content === 'string' && args.file_content.length > 0) {
|
||||||
|
if (args.file_content.length > MAX_INLINE_SIE_CHARS) {
|
||||||
|
throw Object.assign(
|
||||||
|
new Error(
|
||||||
|
'File too large to pass inline safely (silent mid-verifikat truncation risk). Use gnubok_create_sie_upload, PUT the raw bytes to its upload_url, and pass the upload_id here instead.'
|
||||||
|
),
|
||||||
|
{ code: 'VALIDATION_ERROR' }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return args.file_content
|
||||||
|
}
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -16170,6 +16252,60 @@ export const tools: McpTool[] = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: 'gnubok_create_sie_upload',
|
||||||
|
title: 'Create SIE Upload',
|
||||||
|
description:
|
||||||
|
'Short-lived URL for a model-free SIE upload: PUT the raw .se/.sie bytes (max 50 MB) to upload_url, then pass upload_id (+ same filename) to gnubok_sie_preflight and gnubok_import_sie. Required for files too large to pass inline; add sha256 of the bytes there to prove integrity.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
additionalProperties: false,
|
||||||
|
properties: {
|
||||||
|
filename: {
|
||||||
|
type: 'string',
|
||||||
|
minLength: 1,
|
||||||
|
maxLength: 255,
|
||||||
|
description: 'File name with extension, for example "export.se"',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['filename'],
|
||||||
|
},
|
||||||
|
outputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
additionalProperties: false,
|
||||||
|
properties: {
|
||||||
|
upload_id: { type: 'string' },
|
||||||
|
upload_url: { type: 'string' },
|
||||||
|
expires_at: { type: 'string' },
|
||||||
|
},
|
||||||
|
required: ['upload_id', 'upload_url', 'expires_at'],
|
||||||
|
},
|
||||||
|
annotations: {
|
||||||
|
readOnlyHint: false,
|
||||||
|
destructiveHint: false,
|
||||||
|
idempotentHint: false,
|
||||||
|
openWorldHint: false,
|
||||||
|
},
|
||||||
|
async execute(args, companyId, userId, supabase) {
|
||||||
|
const fileName = args.filename as string
|
||||||
|
const lower = fileName.toLowerCase()
|
||||||
|
if (!lower.endsWith('.se') && !lower.endsWith('.sie') && !lower.endsWith('.si')) {
|
||||||
|
throw Object.assign(new Error('filename must end in .se, .sie or .si'), {
|
||||||
|
code: 'VALIDATION_ERROR',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const uploadId = crypto.randomUUID()
|
||||||
|
const reservation = await createPendingDocumentUpload(supabase, companyId, userId, uploadId, fileName)
|
||||||
|
// Served from the app origin: agent sandboxes only reach the MCP host,
|
||||||
|
// not <project>.supabase.co. See storage-proxy.ts.
|
||||||
|
return {
|
||||||
|
upload_id: reservation.uploadId,
|
||||||
|
upload_url: toSameOriginStorageUrl(reservation.signedUrl),
|
||||||
|
expires_at: reservation.expiresAt,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
name: 'gnubok_sie_preflight',
|
name: 'gnubok_sie_preflight',
|
||||||
title: 'SIE Preflight Scan',
|
title: 'SIE Preflight Scan',
|
||||||
@@ -16179,8 +16315,10 @@ export const tools: McpTool[] = [
|
|||||||
type: 'object',
|
type: 'object',
|
||||||
additionalProperties: false,
|
additionalProperties: false,
|
||||||
properties: {
|
properties: {
|
||||||
file_content: { type: 'string', description: 'Full SIE file contents as text' },
|
file_content: { type: 'string', description: 'Full SIE file contents as text (small files only)' },
|
||||||
file_content_base64: { type: 'string', description: 'Exact file bytes base64-encoded (preferred when available: preserves CP437 åäö)' },
|
file_content_base64: { type: 'string', description: 'Exact file bytes base64-encoded (preserves CP437 åäö)' },
|
||||||
|
upload_id: { type: 'string', description: 'From gnubok_create_sie_upload after PUTting the bytes; the only safe path for large files' },
|
||||||
|
sha256: { type: 'string', description: 'Hex sha256 of the raw file bytes; verified on upload_id/base64 paths to prove nothing was truncated' },
|
||||||
filename: { type: 'string' },
|
filename: { type: 'string' },
|
||||||
},
|
},
|
||||||
required: ['filename'],
|
required: ['filename'],
|
||||||
@@ -16205,11 +16343,13 @@ export const tools: McpTool[] = [
|
|||||||
idempotentHint: true,
|
idempotentHint: true,
|
||||||
openWorldHint: false,
|
openWorldHint: false,
|
||||||
},
|
},
|
||||||
async execute(args, companyId, _userId, supabase) {
|
async execute(args, companyId, userId, supabase) {
|
||||||
const content = await decodeSieToolContent(args)
|
const content = await resolveSieToolContent(args, companyId, userId)
|
||||||
if (!content) {
|
if (!content) {
|
||||||
throw Object.assign(
|
throw Object.assign(
|
||||||
new Error('Provide the SIE file as file_content (text) or file_content_base64 (exact bytes).'),
|
new Error(
|
||||||
|
'Provide the SIE file as upload_id (from gnubok_create_sie_upload), file_content_base64, or file_content.'
|
||||||
|
),
|
||||||
{ code: 'VALIDATION_ERROR' }
|
{ code: 'VALIDATION_ERROR' }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -16341,8 +16481,10 @@ export const tools: McpTool[] = [
|
|||||||
type: 'object',
|
type: 'object',
|
||||||
additionalProperties: false,
|
additionalProperties: false,
|
||||||
properties: {
|
properties: {
|
||||||
file_content: { type: 'string', description: 'Full SIE file contents' },
|
file_content: { type: 'string', description: 'Full SIE file contents (small files only)' },
|
||||||
file_content_base64: { type: 'string', description: 'Exact file bytes base64-encoded; alternative to file_content (preserves CP437 åäö)' },
|
file_content_base64: { type: 'string', description: 'Exact file bytes base64-encoded; alternative to file_content (preserves CP437 åäö)' },
|
||||||
|
upload_id: { type: 'string', description: 'From gnubok_create_sie_upload after PUTting the bytes; the only safe path for large files' },
|
||||||
|
sha256: { type: 'string', description: 'Hex sha256 of the raw file bytes; verified on upload_id/base64 paths' },
|
||||||
filename: { type: 'string', description: 'Original filename' },
|
filename: { type: 'string', description: 'Original filename' },
|
||||||
mappings: {
|
mappings: {
|
||||||
type: 'array',
|
type: 'array',
|
||||||
@@ -16363,12 +16505,12 @@ export const tools: McpTool[] = [
|
|||||||
async execute(args, companyId, userId, supabase, actor) {
|
async execute(args, companyId, userId, supabase, actor) {
|
||||||
// Decoded once here; the staged params carry the decoded text so
|
// Decoded once here; the staged params carry the decoded text so
|
||||||
// commitImportSie re-parses exactly what was previewed.
|
// commitImportSie re-parses exactly what was previewed.
|
||||||
const fileContent = await decodeSieToolContent(args)
|
const fileContent = await resolveSieToolContent(args, companyId, userId)
|
||||||
const filename = args.filename as string
|
const filename = args.filename as string
|
||||||
const mappings = args.mappings as unknown[] | undefined
|
const mappings = args.mappings as unknown[] | undefined
|
||||||
|
|
||||||
if (!fileContent || !filename || !Array.isArray(mappings)) {
|
if (!fileContent || !filename || !Array.isArray(mappings)) {
|
||||||
throw new Error('file_content (or file_content_base64), filename, and mappings are required')
|
throw new Error('file content (upload_id, file_content_base64 or file_content), filename, and mappings are required')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse + validate at stage time so the approver sees real content (which
|
// Parse + validate at stage time so the approver sees real content (which
|
||||||
|
|||||||
@@ -16,6 +16,17 @@ continue straight into import or categorization. Staged writes still go
|
|||||||
through their normal approval, but that approval IS the conversation, not an
|
through their normal approval, but that approval IS the conversation, not an
|
||||||
extra pause around it.
|
extra pause around it.
|
||||||
|
|
||||||
|
**Brevity rule: this is onboarding, not a course.** Keep every reply under
|
||||||
|
~8 short lines. At most ONE warning per step, one line, and only when it
|
||||||
|
changes what the user should do right now. No legal essays, no deadline
|
||||||
|
tables, no cross-checks the user did not ask about (bolagsstämma dates,
|
||||||
|
EU-moms edge cases, K-regelverk). The user can always ask for depth; the
|
||||||
|
flow must never make them scroll past it.
|
||||||
|
|
||||||
|
**Memory first.** Before asking the opening questions, check what you
|
||||||
|
already know about the user (memory, earlier conversation): orgnr, company
|
||||||
|
name, bank, previous system. Ask only for what is genuinely missing.
|
||||||
|
|
||||||
## When to use
|
## When to use
|
||||||
|
|
||||||
- "Sätt upp bokföring för mitt AB / min enskilda firma"
|
- "Sätt upp bokföring för mitt AB / min enskilda firma"
|
||||||
@@ -94,19 +105,28 @@ reaches far enough back anyway.
|
|||||||
Exportera data → SIE, **Björn Lundén / Briox / Wint** under Export.
|
Exportera data → SIE, **Björn Lundén / Briox / Wint** under Export.
|
||||||
Every Swedish system exports SIE4 (.se/.sie); ask them to attach the
|
Every Swedish system exports SIE4 (.se/.sie); ask them to attach the
|
||||||
file here in the chat.
|
file here in the chat.
|
||||||
2. When the file arrives, call \`gnubok_sie_preflight\` with its content
|
2. When the file arrives, get its BYTES to the server without retyping
|
||||||
(\`file_content\` as read, or \`file_content_base64\` when exact bytes are
|
them. Preferred (and REQUIRED for anything beyond a small file): call
|
||||||
available: that preserves åäö in CP437 exports). Summarize the scan:
|
\`gnubok_create_sie_upload\`, PUT the raw file bytes to the returned
|
||||||
source system, fiscal years, verifikat count, balance status, org-number
|
\`upload_url\` (from your code sandbox when you have one), compute the
|
||||||
match, warnings. This is the "does it look correct" moment: surface
|
file's sha256, then call \`gnubok_sie_preflight\` with \`upload_id\` +
|
||||||
problems BEFORE anything is written.
|
\`sha256\` + the same \`filename\`. Small files may go inline
|
||||||
3. On their go-ahead: \`gnubok_import_sie\` with the same file and the
|
(\`file_content_base64\` + \`sha256\` preferred over plain
|
||||||
preflight's \`mappings\`. It stages for approval; after commit verify with
|
\`file_content\`). NEVER reproduce a large file token by token: the
|
||||||
\`gnubok_get_trial_balance\`.
|
tools refuse oversized inline content because a mid-verifikat
|
||||||
|
truncation imports silently incomplete bookkeeping.
|
||||||
|
3. Summarize the preflight in a few lines: source system, fiscal years,
|
||||||
|
verifikat count, balance status, org-number match, the one warning that
|
||||||
|
matters. On the user's go-ahead: \`gnubok_import_sie\` with the same
|
||||||
|
source (\`upload_id\` or content) and the preflight's \`mappings\`. It
|
||||||
|
stages for approval; after commit verify with
|
||||||
|
\`gnubok_get_trial_balance\`, and explain any skipped voucher numbers
|
||||||
|
with \`gnubok_explain_voucher_gap\` (BFNAR 2013:2; an unexplained gap
|
||||||
|
blocks year-end).
|
||||||
4. Multiple fiscal years = multiple files: import oldest first so IB/UB
|
4. Multiple fiscal years = multiple files: import oldest first so IB/UB
|
||||||
chains. If the file is very large for chat, the web wizard at
|
chains. The web wizard at \`/import?mode=sie\` is the fallback when no
|
||||||
\`/import?mode=sie\` is the fallback; Fortnox users can also run the full
|
upload path works; Fortnox users can also run the full API migration
|
||||||
API migration (invoices, customers, documents) at
|
(invoices, customers, documents) at
|
||||||
\`/import?mode=migration&provider=fortnox\`.
|
\`/import?mode=migration&provider=fortnox\`.
|
||||||
|
|
||||||
## Step 4: connect bank and Skatteverket (together, no pause)
|
## Step 4: connect bank and Skatteverket (together, no pause)
|
||||||
@@ -136,6 +156,7 @@ message instead of making them ask.
|
|||||||
|
|
||||||
- \`gnubok_lookup_company\`: registry facts + prefill from the orgnr; call first
|
- \`gnubok_lookup_company\`: registry facts + prefill from the orgnr; call first
|
||||||
- \`gnubok_create_company\`: preview (no confirm) then create (confirm=true)
|
- \`gnubok_create_company\`: preview (no confirm) then create (confirm=true)
|
||||||
|
- \`gnubok_create_sie_upload\`: byte-exact upload URL for the SIE file
|
||||||
- \`gnubok_sie_preflight\`: scan a shared SIE file, nothing written
|
- \`gnubok_sie_preflight\`: scan a shared SIE file, nothing written
|
||||||
- \`gnubok_import_sie\`: staged import; use the preflight's mappings
|
- \`gnubok_import_sie\`: staged import; use the preflight's mappings
|
||||||
- \`gnubok_connect_bank\` / \`gnubok_connect_skatteverket\`: status + connect links
|
- \`gnubok_connect_bank\` / \`gnubok_connect_skatteverket\`: status + connect links
|
||||||
|
|||||||
@@ -340,6 +340,8 @@ export const TOOL_SCOPE_MAP: Record<string, ApiKeyScope> = {
|
|||||||
gnubok_export_sie: 'reports:read',
|
gnubok_export_sie: 'reports:read',
|
||||||
gnubok_audit_package: 'reports:read',
|
gnubok_audit_package: 'reports:read',
|
||||||
gnubok_import_sie: 'bookkeeping:write',
|
gnubok_import_sie: 'bookkeeping:write',
|
||||||
|
// Byte-exact SIE upload URL feeding gnubok_import_sie (same write intent).
|
||||||
|
gnubok_create_sie_upload: 'bookkeeping:write',
|
||||||
// Rot/rut begäran om utbetalning (records a payout request on generate)
|
// Rot/rut begäran om utbetalning (records a payout request on generate)
|
||||||
gnubok_generate_rot_rut_file: 'invoices:write',
|
gnubok_generate_rot_rut_file: 'invoices:write',
|
||||||
// Supplier CRUD
|
// Supplier CRUD
|
||||||
|
|||||||
Reference in New Issue
Block a user