a1cafe495f
* fix(invoice-inbox): read whole PDFs (last-page slice + truncation retry) PDF extraction read only part of well-structured PDFs, two confirmed mechanisms (21-day prod window: 49 sliced docs, 29 silent empties): - The auto-extract page budget was 3 (Bedrock-latency legacy, issue #553) and the slice kept only the first pages, so multi-page invoices lost the final page where totals, OCR and 'Att betala' sit. The budget is now 8 on pdf-native backends (Claude reads PDFs directly); the slice always keeps the last page. Rasterizing self-host backends keep the old budget of 3. - A max_tokens-truncated model answer was parsed as-is, failed, and became an all-null extraction with no trace. extractFromDocument now reports stop_reason max_tokens / finish_reason length as truncated; the extractor retries once at double AI_EXTRACTION_MAX_TOKENS and logs ai_extraction_truncated either way. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012xosyW53HUa9JoFiDayhSk * fix(invoice-inbox): sweep cutoff covers the slower two-call extraction Skeptic finding on #2014: the crash-recovery sweep flipped 'processing' rows to an empty skeleton after 2 minutes, but a deferred extraction can now legitimately run 3-5 minutes (8 native pages plus one truncation retry at a doubled token cap), so the sweep stole the row and the CAS discarded the worker's real result. Cutoff raised to 10 minutes. Also: pages_partial_note made period-agnostic (old rows were extracted from first-pages-only slices, so naming the last page was retroactively wrong for them), and two stale first-pages-only comments updated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012xosyW53HUa9JoFiDayhSk * fix(invoice-inbox): keep the first extraction response when the retry throws CodeRabbit finding on #2014: a throttled/failed retry call bubbled to the outer catch before rawText was assigned, discarding a first response whose text may parse fine despite the truncation flag. The retry is now caught locally (logged as ai_extraction_retry_failed) and the first result flows on. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012xosyW53HUa9JoFiDayhSk --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
195 lines
7.0 KiB
TypeScript
195 lines
7.0 KiB
TypeScript
// Job-shaped AI service interface.
|
|
//
|
|
// Call sites describe WHAT they need (a text answer, a schema-shaped object,
|
|
// the fields read out of a document) rather than HOW a particular backend is
|
|
// spoken to. The Anthropic-family service (Bedrock and the direct API) keeps
|
|
// hosted byte-identical by delegating to the existing client factory in
|
|
// lib/ai/provider.ts; the OpenAI-compatible service talks to any endpoint
|
|
// that implements the chat-completions API (the Swedish inference providers a
|
|
// sovereign self-host points at) through the Vercel AI SDK.
|
|
//
|
|
// Streaming members (the chat loop) are deliberately absent until the chat
|
|
// runtime decision is taken: see the Sovereign plan, alignment rule R3.
|
|
|
|
export type AiProviderKind = 'bedrock' | 'anthropic' | 'openai-compatible'
|
|
|
|
/**
|
|
* Model tiers. `assistant` is the conversational/default tier, `heavy` the
|
|
* deep-reasoning tier (supplier-invoice review, VAT review, bokslut), and
|
|
* `extraction` the document-reading tier (a vision model on OpenAI-compatible
|
|
* endpoints; Claude reads PDFs natively).
|
|
*/
|
|
export type AiTier = 'assistant' | 'heavy' | 'extraction'
|
|
|
|
export interface AiCapabilities {
|
|
/** PDF bytes can be sent as a native document part, no rasterization. */
|
|
pdfNative: boolean
|
|
/** Images (and therefore scanned receipts) can be read at all. */
|
|
imageInput: boolean
|
|
toolUse: boolean
|
|
/** The backend can be forced to answer with one named tool. */
|
|
forcedToolChoice: boolean
|
|
/** The backend enforces a JSON schema on the output (response_format). */
|
|
strictJsonSchema: boolean
|
|
}
|
|
|
|
export type AiImageMediaType = 'image/jpeg' | 'image/png' | 'image/webp' | 'image/gif'
|
|
|
|
export type AiDocumentInput =
|
|
| { kind: 'pdf'; data: Buffer; fileName?: string }
|
|
| { kind: 'image'; data: Buffer; mediaType: AiImageMediaType }
|
|
/** Plain text already extracted by the caller (HTML mail invoices). Works on every model, vision or not. */
|
|
| { kind: 'text'; text: string }
|
|
|
|
export interface AiUsage {
|
|
inputTokens: number | null
|
|
outputTokens: number | null
|
|
cacheCreationInputTokens: number | null
|
|
cacheReadInputTokens: number | null
|
|
}
|
|
|
|
/**
|
|
* A tool the model may call during a bounded generateText loop.
|
|
*
|
|
* Provider-agnostic by construction: the OpenAI-compatible service maps it to
|
|
* a Vercel AI SDK `tool()` and lets the SDK drive the loop; the Anthropic-
|
|
* family service maps it to a Messages `tool` block and runs the loop by hand.
|
|
* `execute` runs the tool and returns any JSON-serialisable value, which is
|
|
* fed back to the model as the tool result. Only ever pass READ-only tools:
|
|
* this path has no approval-card surface for staged writes.
|
|
*/
|
|
export interface AiToolDef {
|
|
name: string
|
|
description: string
|
|
/** JSON Schema (draft-07 subset) for the tool's arguments. */
|
|
jsonSchema: Record<string, unknown>
|
|
execute: (args: Record<string, unknown>) => Promise<unknown>
|
|
}
|
|
|
|
/**
|
|
* One earlier turn of a conversation, text only. Sent to the model as a real
|
|
* message turn (not inlined into the prompt), so the answer can refer back
|
|
* to what was said without the caller re-describing it.
|
|
*/
|
|
export interface AiChatTurn {
|
|
role: 'user' | 'assistant'
|
|
text: string
|
|
}
|
|
|
|
export interface GenerateTextRequest {
|
|
tier: AiTier
|
|
system?: string
|
|
prompt: string
|
|
maxTokens: number
|
|
/**
|
|
* Earlier turns of the same conversation, oldest first, placed before
|
|
* `prompt` (which stays the final user message). Callers must hand over a
|
|
* clean alternation that starts with a user turn (see
|
|
* lib/agent/ask/persist.ts loadChatHistory); the services do not repair it.
|
|
* Absent or empty leaves the request exactly as a single-turn call.
|
|
*/
|
|
history?: AiChatTurn[]
|
|
/**
|
|
* Read-only tools the model may call to gather data before answering. When
|
|
* present AND the backend supports tool use (capabilities.toolUse), the
|
|
* service runs a bounded tool loop of up to `maxSteps` model turns; a
|
|
* backend without tool support ignores them and answers from the prompt
|
|
* alone (so a snapshot in the prompt is the fallback grounding).
|
|
*/
|
|
tools?: AiToolDef[]
|
|
/** Max model turns in the tool loop (default 4). Ignored when `tools` is absent. */
|
|
maxSteps?: number
|
|
}
|
|
|
|
export interface GenerateTextResult {
|
|
text: string
|
|
model: string
|
|
usage: AiUsage
|
|
}
|
|
|
|
export interface GenerateStructuredRequest {
|
|
tier: AiTier
|
|
system?: string
|
|
prompt: string
|
|
maxTokens: number
|
|
schema: {
|
|
name: string
|
|
description?: string
|
|
/** JSON Schema (draft-07 subset) for the expected object. Hand-maintained by the caller. */
|
|
jsonSchema: Record<string, unknown>
|
|
}
|
|
}
|
|
|
|
export interface GenerateStructuredResult {
|
|
/** The model's object, NOT validated: callers run their own Zod parse. */
|
|
value: unknown
|
|
model: string
|
|
usage: AiUsage
|
|
}
|
|
|
|
export interface ExtractFromDocumentRequest {
|
|
document: AiDocumentInput
|
|
/** Byte-stable system prompt. The Anthropic-family service marks it as a prompt-cache breakpoint. */
|
|
system: string
|
|
/** Trailing user instruction placed after the document part(s). */
|
|
instruction: string
|
|
maxTokens: number
|
|
/**
|
|
* Optional JSON schema for the answer. Used only when strict JSON mode is
|
|
* on AND the backend supports it; otherwise the model answers in prose and
|
|
* the caller's JSON extraction + Zod parse do the work (works everywhere).
|
|
*/
|
|
jsonSchema?: Record<string, unknown>
|
|
}
|
|
|
|
export type ExtractionSkipReason =
|
|
| 'ai_unconfigured'
|
|
| 'ai_no_vision'
|
|
| 'pdf_rasterizer_missing'
|
|
| 'pdf_rasterize_failed'
|
|
|
|
export type ExtractFromDocumentResult =
|
|
| {
|
|
ok: true
|
|
text: string
|
|
model: string
|
|
usage: AiUsage
|
|
pagesRasterized?: number
|
|
/**
|
|
* The model stopped because it hit maxTokens, so `text` is cut mid-answer
|
|
* (Anthropic stop_reason 'max_tokens', OpenAI finish_reason 'length').
|
|
* Callers that parse the text should retry with a higher cap instead of
|
|
* treating the truncated output as a failed parse.
|
|
*/
|
|
truncated?: boolean
|
|
}
|
|
| { ok: false; skipped: ExtractionSkipReason }
|
|
|
|
export interface AiService {
|
|
readonly provider: AiProviderKind
|
|
readonly capabilities: AiCapabilities
|
|
/** Provider-form model id for a tier (Bedrock inference-profile prefix applied, etc.). */
|
|
modelFor(tier: AiTier): string
|
|
generateText(req: GenerateTextRequest): Promise<GenerateTextResult>
|
|
generateStructured(req: GenerateStructuredRequest): Promise<GenerateStructuredResult>
|
|
extractFromDocument(req: ExtractFromDocumentRequest): Promise<ExtractFromDocumentResult>
|
|
}
|
|
|
|
export type AiPdfMode = 'native' | 'rasterize'
|
|
|
|
export interface AiStatus {
|
|
provider: AiProviderKind
|
|
/** Credentials AND (for OpenAI-compatible) a model id are present. */
|
|
configured: boolean
|
|
reason: 'ok' | 'no_credentials' | 'no_model'
|
|
capabilities: AiCapabilities
|
|
models: Record<AiTier, string | null>
|
|
pdfMode: AiPdfMode
|
|
/**
|
|
* Whether the in-app assistant (chat loop) can run. The loop still speaks
|
|
* the Anthropic messages surface directly, so it needs the Anthropic family
|
|
* until its streaming port lands; extraction and single-call jobs do not.
|
|
*/
|
|
assistantAvailable: boolean
|
|
}
|