4ecc888416
Sovereign plan WS1 PR2, stacked on the extraction-first service (#1740). - Dockerfile (runner stage): `apk add --no-cache poppler-utils`, the one system package beyond the base image (~4 MB plus shared libs, pdftoppm 25.12 on node:22-alpine). pdftoppm renders the first pages of a PDF for AI backends with no native PDF input (an OpenAI-compatible Swedish endpoint); page images land in /tmp, which docker-compose.yml already mounts as tmpfs under the read-only root. Hosted (Bedrock) never calls it; the cron image is untouched. - scripts/smoke-ai-provider.ts: the self-hoster's "is AI wired up" command. Prints provider, models per tier, PDF mode (+ whether pdftoppm is present), vision/strict-JSON; then one text generation per tier model, one schema-shaped answer and, given a file, the exact document-extraction path an upload takes. Skips are reported as failures with the fix. Reads .env.local then .env. - docs/SELF-HOSTING.md: verifying section rewritten around the new script (smoke-ai.ts stays for the assistant's Anthropic-only parameter probes); rasterizer/tmpfs notes; .env.example gains AI_PDF_RASTERIZER_BIN; DECISIONS entry. Verified: live against hosted Bedrock (text per tier, structured, PDF extraction) and against a local OpenAI-compatible mock with AI_PROVIDER=openai-compatible (the mock received Bearer auth, per-tier model ids and one image_url part per rasterized page; extraction parsed the fenced JSON answer). poppler-utils probed on node:22-alpine. Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
216 lines
8.4 KiB
TypeScript
Executable File
216 lines
8.4 KiB
TypeScript
Executable File
#!/usr/bin/env npx tsx
|
|
/**
|
|
* Smoke test for the configured AI backend through the job-shaped service in
|
|
* lib/ai: the self-hoster's "is AI wired up?" command. Works the same against
|
|
* AWS Bedrock, the direct Anthropic API, and any OpenAI-compatible endpoint
|
|
* (a Swedish inference provider on a sovereign self-host), because it only
|
|
* ever talks to getAiService().
|
|
*
|
|
* Steps:
|
|
* 0. Print the resolved status: provider, models per tier, PDF mode, vision,
|
|
* strict JSON, and (for rasterized PDFs) whether pdftoppm is installed.
|
|
* Exits 1 with the reason when nothing is configured.
|
|
* 1. A tiny text generation on each distinct tier model.
|
|
* 2. A schema-shaped answer (generateStructured).
|
|
* 3. Document extraction end to end, when given a file (PDF, JPEG, PNG,
|
|
* WebP, GIF or HTML): the exact path an uploaded receipt takes, so a
|
|
* missing rasterizer, a text-only model or a rejected request surfaces
|
|
* here rather than as an empty inbox row.
|
|
*
|
|
* The chat loop (the in-app assistant) still speaks the Anthropic SDK
|
|
* directly; its parameter set (adaptive thinking, effort, prompt cache, tools)
|
|
* is probed by scripts/smoke-ai.ts on the Anthropic family only.
|
|
*
|
|
* Usage:
|
|
* npx tsx scripts/smoke-ai-provider.ts
|
|
* npx tsx scripts/smoke-ai-provider.ts ./some-receipt.pdf
|
|
*
|
|
* Environment: reads .env.local then .env (the Docker compose env file), so
|
|
* run it from the checkout next to the env file your deployment uses.
|
|
*/
|
|
|
|
import { config } from 'dotenv'
|
|
config({ path: '.env.local' })
|
|
config({ path: '.env' })
|
|
|
|
import { execFile } from 'node:child_process'
|
|
import { readFile } from 'node:fs/promises'
|
|
import { basename, extname } from 'node:path'
|
|
import { promisify } from 'node:util'
|
|
|
|
type AiModule = typeof import('../lib/ai')
|
|
type ExtractionModule = typeof import('../extensions/general/invoice-inbox/lib/extract-invoice-fields')
|
|
|
|
let failures = 0
|
|
|
|
function fail(step: string, err: unknown): void {
|
|
const message = err instanceof Error ? err.message : String(err)
|
|
console.error(` x ${step}: ${message}`)
|
|
failures++
|
|
}
|
|
|
|
const MIME_BY_EXT: Record<string, string> = {
|
|
'.pdf': 'application/pdf',
|
|
'.jpg': 'image/jpeg',
|
|
'.jpeg': 'image/jpeg',
|
|
'.png': 'image/png',
|
|
'.webp': 'image/webp',
|
|
'.gif': 'image/gif',
|
|
'.html': 'text/html',
|
|
'.htm': 'text/html',
|
|
}
|
|
|
|
async function probeRasterizer(): Promise<string> {
|
|
const bin = process.env.AI_PDF_RASTERIZER_BIN ?? 'pdftoppm'
|
|
try {
|
|
const { stdout, stderr } = await promisify(execFile)(bin, ['-v'])
|
|
const firstLine = `${stdout}${stderr}`.split('\n').find((l) => l.trim().length > 0) ?? 'found'
|
|
return `found (${firstLine.trim()})`
|
|
} catch (err) {
|
|
const code = (err as { code?: string } | null)?.code
|
|
return code === 'ENOENT'
|
|
? `MISSING (${bin} not on PATH: PDFs will be skipped with pdf_rasterizer_missing; install poppler-utils or set AI_PDF_MODE=native if the endpoint accepts PDF parts)`
|
|
: `error (${err instanceof Error ? err.message : String(err)})`
|
|
}
|
|
}
|
|
|
|
async function main(): Promise<void> {
|
|
const ai: AiModule = await import('../lib/ai')
|
|
const status = ai.getAiStatus()
|
|
|
|
console.log(`Provider: ${status.provider}`)
|
|
if (status.provider === 'openai-compatible') {
|
|
let host = '(AI_BASE_URL unset)'
|
|
try {
|
|
host = new URL(process.env.AI_BASE_URL ?? '').host
|
|
} catch {
|
|
// printed as unset
|
|
}
|
|
console.log(`Endpoint: ${host}`)
|
|
}
|
|
console.log(`Configured: ${status.configured ? 'yes' : `NO (${status.reason})`}`)
|
|
console.log(`Models: assistant=${status.models.assistant ?? '-'} heavy=${status.models.heavy ?? '-'} extraction=${status.models.extraction ?? '-'}`)
|
|
console.log(`PDF mode: ${status.pdfMode}${status.pdfMode === 'rasterize' ? ` pdftoppm: ${await probeRasterizer()}` : ''}`)
|
|
console.log(`Capabilities: vision=${status.capabilities.imageInput} pdfNative=${status.capabilities.pdfNative} strictJson=${status.capabilities.strictJsonSchema}`)
|
|
console.log(`Assistant: ${status.assistantAvailable ? 'available' : 'not available on this backend (extraction and single-call AI still run)'}`)
|
|
console.log()
|
|
|
|
if (!status.configured) {
|
|
console.error(
|
|
status.reason === 'no_credentials'
|
|
? 'No AI credentials found. Set AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY (Bedrock), ANTHROPIC_API_KEY (direct API), or AI_BASE_URL + AI_API_KEY (OpenAI-compatible endpoint).'
|
|
: 'No model id configured. An OpenAI-compatible endpoint has no default: set AI_MODEL (and optionally AI_EXTRACTION_MODEL for a vision model).'
|
|
)
|
|
process.exit(1)
|
|
}
|
|
|
|
const service = ai.getAiService()
|
|
|
|
// 1. Text generation per distinct tier model.
|
|
console.log('1. Text generation, one call per distinct tier model')
|
|
const seen = new Set<string>()
|
|
for (const tier of ['assistant', 'heavy', 'extraction'] as const) {
|
|
const model = service.modelFor(tier)
|
|
if (seen.has(model)) continue
|
|
seen.add(model)
|
|
const start = Date.now()
|
|
try {
|
|
const result = await service.generateText({
|
|
tier,
|
|
prompt: 'Säg "hej" på svenska, ett ord.',
|
|
maxTokens: 32,
|
|
})
|
|
console.log(` ok ${tier} (${model}): ${Date.now() - start}ms: "${result.text.slice(0, 40)}" tokens in/out=${result.usage.inputTokens ?? '?'}/${result.usage.outputTokens ?? '?'}`)
|
|
} catch (err) {
|
|
fail(`${tier} (${model})`, err)
|
|
}
|
|
}
|
|
|
|
// 2. Schema-shaped answer.
|
|
console.log('2. Structured output')
|
|
{
|
|
const start = Date.now()
|
|
try {
|
|
const result = await service.generateStructured({
|
|
tier: 'assistant',
|
|
prompt: 'Return a Swedish one-word greeting and the ISO 639-1 code of its language.',
|
|
maxTokens: 200,
|
|
schema: {
|
|
name: 'greeting',
|
|
description: 'a greeting and its language code',
|
|
jsonSchema: {
|
|
type: 'object',
|
|
additionalProperties: false,
|
|
properties: { greeting: { type: 'string' }, language: { type: 'string' } },
|
|
required: ['greeting', 'language'],
|
|
},
|
|
},
|
|
})
|
|
const value = result.value as { greeting?: unknown; language?: unknown }
|
|
if (typeof value?.greeting !== 'string' || typeof value?.language !== 'string') {
|
|
throw new Error(`unexpected shape: ${JSON.stringify(result.value).slice(0, 120)}`)
|
|
}
|
|
console.log(` ok structured: ${Date.now() - start}ms: ${JSON.stringify(value)}`)
|
|
} catch (err) {
|
|
fail('structured', err)
|
|
}
|
|
}
|
|
|
|
// 3. Document extraction.
|
|
const file = process.argv[2]
|
|
if (file) {
|
|
console.log('3. Document extraction')
|
|
const mimeType = MIME_BY_EXT[extname(file).toLowerCase()]
|
|
if (!mimeType) {
|
|
fail('extraction', new Error(`unsupported extension "${extname(file)}": use pdf, jpg, png, webp, gif or html`))
|
|
} else {
|
|
const start = Date.now()
|
|
try {
|
|
const extraction: ExtractionModule = await import(
|
|
'../extensions/general/invoice-inbox/lib/extract-invoice-fields'
|
|
)
|
|
const buffer = await readFile(file)
|
|
const result = await extraction.extractInvoiceFields({
|
|
buffer,
|
|
mimeType,
|
|
fileName: basename(file),
|
|
})
|
|
if (result.skipped) {
|
|
throw new Error(
|
|
`skipped (${result.skipped}): no model call was made. ` +
|
|
(result.skipped === 'ai_no_vision'
|
|
? 'The configured model is declared text-only (AI_VISION=false); pick a vision model for AI_EXTRACTION_MODEL.'
|
|
: result.skipped === 'pdf_rasterizer_missing'
|
|
? 'Install poppler-utils (pdftoppm) or set AI_PDF_MODE=native.'
|
|
: '')
|
|
)
|
|
}
|
|
if (!result.rawText) {
|
|
throw new Error('the model answered but nothing parseable came back (see the warning above)')
|
|
}
|
|
const d = result.data
|
|
console.log(
|
|
` ok extraction (${result.model}): ${Date.now() - start}ms: supplier="${d.supplier.name ?? '-'}", ` +
|
|
`date=${d.invoice.invoiceDate ?? '-'}, total=${d.totals.total ?? '-'} ${d.invoice.currency}, kind=${d.documentKind ?? '-'}`
|
|
)
|
|
} catch (err) {
|
|
fail('extraction', err)
|
|
}
|
|
}
|
|
} else {
|
|
console.log('3. Document extraction: skipped (pass a file path to run it)')
|
|
}
|
|
|
|
console.log()
|
|
if (failures > 0) {
|
|
console.error(`${failures} step(s) failed.`)
|
|
process.exit(1)
|
|
}
|
|
console.log('All green.')
|
|
}
|
|
|
|
main().catch((err) => {
|
|
console.error(err instanceof Error ? err.stack ?? err.message : String(err))
|
|
process.exit(1)
|
|
})
|