* 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>
285 lines
11 KiB
TypeScript
285 lines
11 KiB
TypeScript
import type Anthropic from '@anthropic-ai/sdk'
|
|
import { createAiClient, toProviderModelId, type AiClient } from '../provider'
|
|
import type { ResolvedAiConfig } from '../config'
|
|
import { capabilitiesFor } from '../config'
|
|
import type {
|
|
AiChatTurn,
|
|
AiDocumentInput,
|
|
AiService,
|
|
AiTier,
|
|
AiToolDef,
|
|
AiUsage,
|
|
ExtractFromDocumentRequest,
|
|
ExtractFromDocumentResult,
|
|
GenerateStructuredRequest,
|
|
GenerateStructuredResult,
|
|
GenerateTextRequest,
|
|
GenerateTextResult,
|
|
} from '../types'
|
|
|
|
const DEFAULT_MAX_STEPS = 4
|
|
|
|
// Bound a serialized tool result before it enters the model context. Mirrors
|
|
// boundToolResultText in lib/agent/chat/run-turn.ts: same 40k-char ceiling
|
|
// (~10k tokens, well under the ~25k-token practical ceiling for a single tool
|
|
// return). Unbounded, one large read (full OCR text, a big ledger report) is
|
|
// re-sent on every later loop turn and can eat the whole max_tokens budget
|
|
// before the model produces any visible text.
|
|
const MAX_TOOL_RESULT_CHARS = 40_000
|
|
|
|
function boundToolResult(raw: string): string {
|
|
if (raw.length <= MAX_TOOL_RESULT_CHARS) return raw
|
|
const head = raw.slice(0, MAX_TOOL_RESULT_CHARS)
|
|
return `${head}\n\n[avkortat: resultatet var ${raw.length} tecken, visar de första ${MAX_TOOL_RESULT_CHARS}. Be om en smalare sökning (limit, datumintervall, specifikt id eller fält) för att se mer.]`
|
|
}
|
|
|
|
const EMPTY_USAGE: AiUsage = {
|
|
inputTokens: 0,
|
|
outputTokens: 0,
|
|
cacheCreationInputTokens: 0,
|
|
cacheReadInputTokens: 0,
|
|
}
|
|
|
|
/**
|
|
* Earlier turns as real message turns, then the prompt as the final user
|
|
* message. With no history this is exactly the single-message array every
|
|
* non-chat caller sent before (hosted stays byte-identical for them).
|
|
*/
|
|
function messagesFor(
|
|
prompt: string,
|
|
history: AiChatTurn[] | undefined,
|
|
): Anthropic.Messages.MessageParam[] {
|
|
const prior: Anthropic.Messages.MessageParam[] = (history ?? []).map((t) => ({
|
|
role: t.role,
|
|
content: t.text,
|
|
}))
|
|
return [...prior, { role: 'user', content: prompt }]
|
|
}
|
|
|
|
/** Sum usage across the turns of a tool loop so the reported cost is truthful. */
|
|
function addUsage(a: AiUsage, b: AiUsage): AiUsage {
|
|
const s = (x: number | null, y: number | null) => (x ?? 0) + (y ?? 0)
|
|
return {
|
|
inputTokens: s(a.inputTokens, b.inputTokens),
|
|
outputTokens: s(a.outputTokens, b.outputTokens),
|
|
cacheCreationInputTokens: s(a.cacheCreationInputTokens, b.cacheCreationInputTokens),
|
|
cacheReadInputTokens: s(a.cacheReadInputTokens, b.cacheReadInputTokens),
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The Anthropic family (AWS Bedrock and the direct API) behind the job-shaped
|
|
* interface. Delegates to the existing client factory and builds the EXACT
|
|
* request literals the call sites built before the abstraction existed, so
|
|
* hosted stays byte-identical on the wire: the request-shape tests in
|
|
* lib/ai/__tests__ deep-equal those literals.
|
|
*/
|
|
|
|
type MessageCreateParams = Anthropic.Messages.MessageCreateParamsNonStreaming
|
|
|
|
function usageOf(resp: { usage?: unknown }): AiUsage {
|
|
const usage = (resp.usage ?? {}) as {
|
|
input_tokens?: number
|
|
output_tokens?: number
|
|
cache_creation_input_tokens?: number | null
|
|
cache_read_input_tokens?: number | null
|
|
}
|
|
return {
|
|
inputTokens: usage.input_tokens ?? null,
|
|
outputTokens: usage.output_tokens ?? null,
|
|
cacheCreationInputTokens: usage.cache_creation_input_tokens ?? null,
|
|
cacheReadInputTokens: usage.cache_read_input_tokens ?? null,
|
|
}
|
|
}
|
|
|
|
function textOf(resp: { content: Array<{ type: string; text?: string }> }): string {
|
|
return resp.content
|
|
.flatMap((b) => (b.type === 'text' && typeof b.text === 'string' ? [b.text] : []))
|
|
.join('')
|
|
.trim()
|
|
}
|
|
|
|
/** User content blocks for one document + trailing instruction. Same literals the inbox extractor sent. */
|
|
export function buildAnthropicDocumentContent(
|
|
document: AiDocumentInput,
|
|
instruction: string
|
|
): Anthropic.Messages.ContentBlockParam[] {
|
|
const tail = { type: 'text' as const, text: instruction }
|
|
if (document.kind === 'text') {
|
|
return [{ type: 'text' as const, text: document.text }, tail]
|
|
}
|
|
const base64 = document.data.toString('base64')
|
|
if (document.kind === 'pdf') {
|
|
return [
|
|
{
|
|
type: 'document' as const,
|
|
source: { type: 'base64' as const, media_type: 'application/pdf' as const, data: base64 },
|
|
},
|
|
tail,
|
|
]
|
|
}
|
|
return [
|
|
{
|
|
type: 'image' as const,
|
|
source: { type: 'base64' as const, media_type: document.mediaType, data: base64 },
|
|
},
|
|
tail,
|
|
]
|
|
}
|
|
|
|
export function createAnthropicFamilyService(cfg: ResolvedAiConfig): AiService {
|
|
let client: AiClient | null = null
|
|
const getClient = (): AiClient => {
|
|
if (!client) client = createAiClient()
|
|
return client
|
|
}
|
|
const modelFor = (tier: AiTier): string =>
|
|
// The Anthropic family always has a model (Claude default), so the null
|
|
// branch is unreachable; keep the fallback so the type stays honest.
|
|
toProviderModelId(cfg.models[tier] ?? 'claude-sonnet-5', cfg.provider)
|
|
|
|
return {
|
|
provider: cfg.provider,
|
|
capabilities: capabilitiesFor(cfg),
|
|
modelFor,
|
|
|
|
async generateText(req: GenerateTextRequest): Promise<GenerateTextResult> {
|
|
const model = modelFor(req.tier)
|
|
|
|
// Fast path (unchanged: keeps hosted byte-identical for every non-tool
|
|
// caller, which is all of them today): one turn, no tools.
|
|
if (!req.tools || req.tools.length === 0) {
|
|
const params: MessageCreateParams = {
|
|
model,
|
|
max_tokens: req.maxTokens,
|
|
...(req.system ? { system: req.system } : {}),
|
|
messages: messagesFor(req.prompt, req.history),
|
|
}
|
|
const resp = await getClient().messages.create(params)
|
|
return { text: textOf(resp), model, usage: usageOf(resp) }
|
|
}
|
|
|
|
// Bounded read-only tool loop. The same dispatch shape run-turn.ts uses,
|
|
// minus streaming and staging: the model asks for a report, we run it,
|
|
// feed the JSON back, repeat up to maxSteps model turns, then answer.
|
|
const tools: Anthropic.Messages.Tool[] = req.tools.map((t) => ({
|
|
name: t.name,
|
|
description: t.description,
|
|
input_schema: t.jsonSchema as Anthropic.Messages.Tool['input_schema'],
|
|
}))
|
|
const byName = new Map<string, AiToolDef>(req.tools.map((t) => [t.name, t]))
|
|
const messages: Anthropic.Messages.MessageParam[] = messagesFor(req.prompt, req.history)
|
|
const maxSteps = req.maxSteps ?? DEFAULT_MAX_STEPS
|
|
let usage = EMPTY_USAGE
|
|
|
|
for (let step = 0; step < maxSteps; step++) {
|
|
const resp = await getClient().messages.create({
|
|
model,
|
|
max_tokens: req.maxTokens,
|
|
...(req.system ? { system: req.system } : {}),
|
|
tools,
|
|
messages,
|
|
})
|
|
usage = addUsage(usage, usageOf(resp))
|
|
if (resp.stop_reason !== 'tool_use') {
|
|
return { text: textOf(resp), model, usage }
|
|
}
|
|
|
|
messages.push({ role: 'assistant', content: resp.content })
|
|
const results: Anthropic.Messages.ContentBlockParam[] = []
|
|
for (const block of resp.content) {
|
|
if (block.type !== 'tool_use') continue
|
|
const def = byName.get(block.name)
|
|
let content: string
|
|
let isError = false
|
|
try {
|
|
if (!def) {
|
|
isError = true
|
|
content = JSON.stringify({ error: `Verktyget ${block.name} är inte tillgängligt.` })
|
|
} else {
|
|
const out = await def.execute((block.input ?? {}) as Record<string, unknown>)
|
|
content = JSON.stringify(out ?? null)
|
|
}
|
|
} catch (err) {
|
|
isError = true
|
|
content = JSON.stringify({ error: err instanceof Error ? err.message : 'Tool failed' })
|
|
}
|
|
results.push({
|
|
type: 'tool_result',
|
|
tool_use_id: block.id,
|
|
content: boundToolResult(content),
|
|
is_error: isError,
|
|
})
|
|
}
|
|
messages.push({ role: 'user', content: results })
|
|
}
|
|
|
|
// Spent the step budget without a final answer: force one more turn that
|
|
// cannot call tools. The tools param MUST stay in the request: the
|
|
// transcript above holds tool_use/tool_result blocks, and the Messages
|
|
// API rejects a request that replays those without declaring the tools
|
|
// they refer to. tool_choice none is the sanctioned "answer in text" knob.
|
|
const final = await getClient().messages.create({
|
|
model,
|
|
max_tokens: req.maxTokens,
|
|
...(req.system ? { system: req.system } : {}),
|
|
tools,
|
|
tool_choice: { type: 'none' },
|
|
messages,
|
|
})
|
|
return { text: textOf(final), model, usage: addUsage(usage, usageOf(final)) }
|
|
},
|
|
|
|
async generateStructured(req: GenerateStructuredRequest): Promise<GenerateStructuredResult> {
|
|
const model = modelFor(req.tier)
|
|
// Forced tool choice is the reliable way to get schema-shaped output
|
|
// from Claude (mirrors receipt-hunt's adjudicate/mail-intelligence).
|
|
const params: MessageCreateParams = {
|
|
model,
|
|
max_tokens: req.maxTokens,
|
|
...(req.system ? { system: req.system } : {}),
|
|
tools: [
|
|
{
|
|
name: req.schema.name,
|
|
...(req.schema.description ? { description: req.schema.description } : {}),
|
|
input_schema: req.schema.jsonSchema as Anthropic.Messages.Tool['input_schema'],
|
|
},
|
|
],
|
|
tool_choice: { type: 'tool', name: req.schema.name },
|
|
messages: [{ role: 'user', content: req.prompt }],
|
|
}
|
|
const resp = await getClient().messages.create(params)
|
|
const toolUse = resp.content.find(
|
|
(b): b is Anthropic.Messages.ToolUseBlock => b.type === 'tool_use' && b.name === req.schema.name
|
|
)
|
|
if (!toolUse) {
|
|
throw new Error(`Model answered without the forced tool "${req.schema.name}"`)
|
|
}
|
|
return { value: toolUse.input, model, usage: usageOf(resp) }
|
|
},
|
|
|
|
async extractFromDocument(req: ExtractFromDocumentRequest): Promise<ExtractFromDocumentResult> {
|
|
if (!cfg.configured) return { ok: false, skipped: 'ai_unconfigured' }
|
|
const model = modelFor('extraction')
|
|
// The system prompt is byte-stable per deploy and a few KB: marking it
|
|
// ephemeral lets the backend reuse the prompt cache on rapid sequential
|
|
// extractions (a user uploading a stack of receipts within minutes).
|
|
// Bedrock honours the short default TTL; the direct API the same.
|
|
const params: MessageCreateParams = {
|
|
model,
|
|
max_tokens: req.maxTokens,
|
|
system: [{ type: 'text', text: req.system, cache_control: { type: 'ephemeral' } }],
|
|
messages: [{ role: 'user', content: buildAnthropicDocumentContent(req.document, req.instruction) }],
|
|
}
|
|
const resp = await getClient().messages.create(params)
|
|
return {
|
|
ok: true,
|
|
text: textOf(resp),
|
|
model,
|
|
usage: usageOf(resp),
|
|
...(resp.stop_reason === 'max_tokens' ? { truncated: true } : {}),
|
|
}
|
|
},
|
|
}
|
|
}
|