Commit Graph

5 Commits

Author SHA1 Message Date
Mattsson a1cafe495f fix(invoice-inbox): read whole PDFs (last-page slice + truncation retry) (#2014)
* 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>
2026-08-28 18:57:02 +02:00
Jakob Wennberg 3edbf0a2e3 fix(agent): chat console keeps its thread across turns and reloads (#1859)
Three user-reported failures in the assistant panel, one root cause each:

1. "The chat asks what I'm referring to" when continuing a thread. The
   single-call console (general.help, AskConsole -> /api/agent/ask) was
   stateless since the 08-20 model-agnostic cutover: conversationId was only
   the tool actor id, so every turn was answered blind, reload or not.
   The provider-agnostic GenerateTextRequest gains an optional `history`
   (real message turns before the prompt, in both the Anthropic-family and
   the OpenAI-compatible adapter; absent/empty leaves the request
   byte-identical to the single-turn call). The route loads the thread's
   earlier turns server-side (loadChatHistory: text only, hidden and tool
   rows dropped, alternation repaired, newest 16 rows / 10k chars) before
   writing the new question, and hands them to the model.

2. A full page reload (the deploy prompt's "Ladda om") closed the docked
   panel and dropped the thread from view. The panel now remembers its open
   thread per tab in sessionStorage (lib/agent-panel/session-restore) and
   the provider reopens it on mount; the sheet loads it exactly like a pick
   from "Tidigare konversationer". Close and "Ny konversation" forget it; a
   thread that no longer opens is dropped instead of retried on every reload.

3. "Can't type any more" once the update banner shows. DeployReloadPrompt's
   full-width wrapper sits at z-[60] after the panel in DOM order and
   swallowed clicks on the panel's composer; only the card takes input now.


Claude-Session: https://claude.ai/code/session_01VjoXN3xdNZrHZeYA6qMi3g

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 16:37:30 +02:00
Jakob Wennberg 6aecbdc9b7 fix(agent): surface empty assistant answers instead of silent stops (#1830)
The support chat showed 'Tänker' and then went quiet with no answer and
no error. Root cause: the 2026-08-21 RIP-3 cutover moved general.help to
the single-call POST /api/agent/ask, whose 1500-token default cap made
stop_reason max_tokens routine on tool-loop turns. The empty answer then
passed unlogged through the service, the route answered 200 with an
empty string, and the console appended an invisible empty bubble.

Fixes, single-call path:
- ask-service: default maxTokens 1500 -> 5400 (the streaming chat's
  reply ceiling); an empty final answer now logs model + usage and
  throws the typed EmptyModelAnswerError instead of passing through.
- /api/agent/ask: maxDuration 300, logger, empty answer maps to 502
  with 'Assistenten gav inget svar. Försök igen.'; an empty assistant
  turn is never persisted (the question stays, so retry works).
- AskConsole: a 200 with an empty answer shows the error box instead of
  appending an invisible bubble.
- anthropic-family: serialized tool results are bounded at 40000 chars
  (mirrors run-turn) so one big read cannot eat the output budget; the
  step-exhausted fallback keeps tools declared with tool_choice none,
  because replaying tool_use/tool_result without tools is an API 400.

Fixes, streaming path (same silent class):
- /api/agent/invoke: maxDuration 300 so deep thinking turns are not
  killed mid-stream at the platform default cap.
- run-turn: stop_reason max_tokens with no visible text emits an error
  event, not a bare turn_complete.
- AgentChat: an NDJSON stream that ends without turn_complete or error
  (and was not aborted) shows 'Anslutningen bröts innan svaret blev
  klart. Försök igen.'

The lib/ai request-shape tests were updated deliberately for the
fallback change; general.help stays on the single-call runtime
(founder decision, not reverted).


Claude-Session: https://claude.ai/code/session_01SyDuePXxUFowaPBKpAv8SF

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 13:19:15 +02:00
Jakob Wennberg ff4425d10e feat(agent): let the single-call assistant read the ledger via read-only MCP tools (#1767)
The /chat assistant (audit Option A / rip) shipped in #1759 reading only the
company name + entity type, so it answered "jag har ingen bokföringsdata" to
every figures question ("vad är min största utgiftspost?"). It now behaves like
an MCP client: it answers over a bounded, READ-only tool loop across the same
MCP read tools the old streaming assistant had, plus an always-on company
snapshot as the backstop.

Provider-agnostic by construction, so it still runs on a local model:
- lib/ai generateText gains optional `tools` + `maxSteps`. The OpenAI-compatible
  service forwards them to the Vercel AI SDK (stopWhen: stepCountIs), which runs
  the loop; the Anthropic-family service hand-rolls a small loop against
  messages.create. Kept on the raw Anthropic SDK: no new deps, and the no-tools
  path is byte-identical, so hosted extraction/composer/etc. are unchanged.
- lib/agent/ask/ledger-tools.ts: the read slice of general.help's whitelist
  (income statement, VAT, ledgers, query_journal, reskontror, lists…) from
  agentToolRegistry, dispatched with the agent_chat actor run-turn uses. Write/
  staging + memory-write tools are excluded; readOnlyHint/destructiveHint are
  re-checked. Empty in a core-only build → snapshot-only, graceful.
- lib/agent/ask/snapshot.ts: a compact company_settings + deadlines block so a
  model that can't/won't call tools still answers status questions. Never carries
  figures (those come from the live tools).
- ask-service attaches tools + snapshot when a userId is present and uses a
  tool-aware system prompt; the route calls ensureInitialized() so the registry
  is populated and threads userId/conversationId through.

Works on Bedrock and on any local model with function-calling (Qwen). Tests:
the anthropic hand-rolled loop (tool call → result → answer, is_error handling,
step-budget forced answer), openai tool forwarding, the read-only adapter
filter, the snapshot format, and the ask-service wiring. 457 agent+ai tests
green, lint/guards clean.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-20 21:18:21 +02:00
Jakob Wennberg c7a75d069d feat(ai): job-shaped AI service with OpenAI-compatible backend, extraction-first; stop extracting every inbox document twice (#1740)
* feat(ai): job-shaped AI service with OpenAI-compatible backend, extraction-first; stop extracting every inbox document twice

Sovereign plan WS1 PR1 (#1406 Tier 2, extraction-first, aligned with the
AI surface audit).

lib/ai grows a job-shaped service (generateText / generateStructured /
extractFromDocument; no streaming members yet, see plan rule R3):
- services/anthropic-family delegates to the existing createAiClient()
  and sends the exact request literals the inbox extractor sent before
  (request-shape tests deep-equal them), so hosted Bedrock stays
  byte-identical.
- services/openai-compatible talks to any chat-completions endpoint
  (BYO Swedish provider) via Vercel AI SDK 6.x, exact-pinned and
  guarded: images as parts, PDFs rasterized with poppler (AI_PDF_MODE)
  or sent natively, AI_VISION / AI_STRICT_JSON declared, honest skips
  (ai_no_vision, pdf_rasterizer_missing) instead of fake failures.
- config.ts: AI_PROVIDER/AI_BASE_URL/AI_API_KEY/AI_MODEL and per-tier
  AI_*_MODEL with the legacy BEDROCK_* names kept as the same overrides;
  getAiStatus() is the single source of truth for "is AI wired up".
- provider.ts: openai-compatible in the auto-detect chain (after Bedrock
  and the direct API); createAiClient() refuses it loudly.

Document extraction moves onto the service and gets the audit's fixes:
- Inbox documents were extracted TWICE (pipeline A ran inside
  uploadDocument() before the inbox row existed, so its dedupe branch
  never fired; 3 707 + 1 666 calls / 30 d). The inbox now declares
  extractionOwner on the upload, the extension yields, and the inbox
  mirrors its single outcome onto document_attachments from every
  writer (sync, deferred, attach, retry, MCP).
- Every "no extraction will ever happen" outcome is stamped
  (skipped:no_ai_entitlement / ai_unconfigured / system_generated /
  ...); the status route maps the quiet ones to 'disabled' on the first
  poll instead of a 30 s client timeout. Prod showed 309 of the 327
  never-extracted uploads were the paywall working silently.
- Self-generated documents (our own invoice PDFs, payout files) are no
  longer OCR'd.
- Agent invoke answers 503 ai_unconfigured when the deployment has no
  assistant backend, distinct from the paywall.

Guard: new direct-ai-client antipattern check (shrink-only allowlist of
the pre-abstraction SDK callers) plus exact pins for @anthropic-ai/sdk,
ai and @ai-sdk/openai-compatible.

Verified: 15 958 unit tests green, guards, lint ratchet, typecheck, and a
live smoke against hosted Bedrock through the new service (ping, streamed
tool turn, thinking+cache, PDF extraction).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(ai): make AI_API_KEY optional for OpenAI-compatible endpoints (keyless local model servers)

A local model server (llama.cpp's server, Ollama /v1, LM Studio, vLLM)
usually has no auth. Before, the OpenAI-compatible backend required both
AI_BASE_URL and AI_API_KEY to count as configured, so running Accounted on a
local model meant setting a meaningless placeholder key.

- resolveAiProvider / hasAiCredentials: a base URL alone is now enough.
- services/openai-compatible: only send Authorization: Bearer when AI_API_KEY
  is set, so a keyless server is never handed an empty bearer; a hosted
  provider that needs a key still sets it.
- Docs (SELF-HOSTING Option 3: local-model example, key marked optional),
  DECISIONS.

Verified: with no AI_API_KEY, just AI_BASE_URL + AI_MODEL, getAiStatus()
reports configured=true / provider=openai-compatible (live). lib/ai suite
71 green; tsc, guards, lint clean. Bedrock/Anthropic logic unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 19:39:08 +02:00