feat(llm-parse): read a photo, as a booking or as a receipt in Costs

Whether the AI Parsing model reads images is an admin setting now, and it
lands with the two features that send a photo, so it has something to
control.

The setting is `vision: 'auto' | 'on' | 'off'` on the addon config. On
Automatic a local Ollama is asked what the model can do (`/api/show`), and a
cloud provider means no; Yes and No are applied as set. The old `multimodal`
flag is not read: the admin screens wrote it false on every save, so a
stored false meant nothing. The per-user toggle keeps its on/off meaning and
is read by the extraction now. The lookup is server-side only: capped read,
validated body, never throws, cached per server and model.
GET /api/llm/capabilities answers it for the signed-in caller, and both
photo pickers below ask it.

The booking import takes JPG, PNG and WEBP when the model reads images.
The photo goes as an image to every provider (image_url, an Anthropic image
block, Ollama's native chat with `images`), capped at 1600px, and skips KDE
Itinerary. A scanned PDF, which used to reach the model as empty text, has
its first two pages drawn by pdf-parse and sent the same way. The rest is
the import as it was: background job, review, the file attached to the
booking, the linked cost. On a real qwen3.5:4b a train ticket came back as
bare clock times until the router gave the model every type's field
meanings, and its price as an object inside a string, which read as 4900;
both are handled.

Costs gets Scan receipt: a dialog laid out like the booking import's takes
one photo, read on the import job queue, then reviewed in the existing
expense editor pre-filled with merchant, total, currency, day and lines,
the photo waiting in Receipts & Invoices to be uploaded on save. No table,
no migration, no category guess, no document-type detection.
This commit is contained in:
RemEx
2026-09-24 17:12:54 +00:00
committed by Maurice
parent 706c6c3959
commit b1c05bca83
183 changed files with 3098 additions and 218 deletions
+2 -1
View File
@@ -57,6 +57,7 @@ import { StorageModule } from './storage/storage.module';
import { BackupModule } from './backup/backup.module';
import { BookingImportModule } from './booking-import/booking-import.module';
import { ReservationImportModule } from './reservation-import/reservation-import.module';
import { ReceiptScanModule } from './receipt-scan/receipt-scan.module';
import { LlmParseModule } from './llm-parse/llm-parse.module';
import { AuthModule } from './auth/auth.module';
import { OidcModule } from './oidc/oidc.module';
@@ -82,7 +83,7 @@ import { RealtimeGatewayModule } from './realtime/realtime-gateway.module';
* migrated.
*/
@Module({
imports: [AppConfigModule, DatabaseModule, RealtimeModule, RealtimeGatewayModule, SchedulingModule, McpModule.forRoot({ accessPolicy: trekMcpAccessPolicy, validateAccess: trekMcpValidateAccess }), HealthModule, PlatformModule, McpTransportModule, WeatherModule, PublicApiModule, HelpModule, AirportsModule, ConfigModule, SystemNoticesModule, GeoModule, MapsModule, PlaceEnrichmentModule, PlaceShadowModule, RouteUsageModule, CategoriesModule, TagsModule, NotificationsModule, AtlasModule, VacayModule, SchoolHolidaysModule, PackingModule, TodoModule, BudgetModule, ReservationsModule, DaysModule, DayNotesModule, AccommodationsModule, AssignmentsModule, PlacesModule, RoadtripModule, TripsModule, CollabModule, FilesModule, DocSyncModule, PhotosModule, MemoriesModule, AirtrailModule, DawarichModule, JourneyModule, CollectionsModule, ShareModule, TripInviteModule, TransitModule, FeedsModule, SettingsModule, StorageModule, BackupModule, AuthModule, OidcModule, OauthModule, AdminModule, AddonsModule, AuditModule, PermissionsModule, PluginsModule, BookingImportModule, ReservationImportModule, LlmParseModule, ManagedExtModule],
imports: [AppConfigModule, DatabaseModule, RealtimeModule, RealtimeGatewayModule, SchedulingModule, McpModule.forRoot({ accessPolicy: trekMcpAccessPolicy, validateAccess: trekMcpValidateAccess }), HealthModule, PlatformModule, McpTransportModule, WeatherModule, PublicApiModule, HelpModule, AirportsModule, ConfigModule, SystemNoticesModule, GeoModule, MapsModule, PlaceEnrichmentModule, PlaceShadowModule, RouteUsageModule, CategoriesModule, TagsModule, NotificationsModule, AtlasModule, VacayModule, SchoolHolidaysModule, PackingModule, TodoModule, BudgetModule, ReservationsModule, DaysModule, DayNotesModule, AccommodationsModule, AssignmentsModule, PlacesModule, RoadtripModule, TripsModule, CollabModule, FilesModule, DocSyncModule, PhotosModule, MemoriesModule, AirtrailModule, DawarichModule, JourneyModule, CollectionsModule, ShareModule, TripInviteModule, TransitModule, FeedsModule, SettingsModule, StorageModule, BackupModule, AuthModule, OidcModule, OauthModule, AdminModule, AddonsModule, AuditModule, PermissionsModule, PluginsModule, BookingImportModule, ReservationImportModule, ReceiptScanModule, LlmParseModule, ManagedExtModule],
providers: [
// Default-deny: a route is authenticated unless it carries @Public() or
// @OptionalAuth(), or declares its own @UseGuards chain. Protection used to
@@ -4,6 +4,7 @@ import { PermissionsService } from '../permissions/permissions.service';
import { ReservationsService } from '../reservations/reservations.service';
import { PlacesService } from '../places/places.service';
import { BudgetService } from '../budget/budget.service';
import { imageMimeType } from '../llm-parse/image-input';
import { AddonsService } from '../addons/addons.service';
import { ADDON_IDS } from '../../addons';
import { MapsService } from '../maps/maps.service';
@@ -56,6 +57,11 @@ export class BookingImportService {
return this.llmParse.isAvailable(userId);
}
/** Whether a photo this user imports can be read (see LlmParseService.readsImages). */
readsImages(userId: number): Promise<boolean> {
return this.llmParse.readsImages(userId);
}
/**
* Parse uploaded files and return a preview list. Does NOT persist anything.
* Runs kitinerary first; depending on `mode`, falls back to the LLM:
@@ -155,8 +161,10 @@ export class BookingImportService {
let kiItems: KiReservation[] = [];
let aiUsed = false;
// Stage 1: kitinerary (skipped entirely when forcing AI).
if (mode !== 'force-ai' && kitineraryAvailable) {
// Stage 1: kitinerary (skipped entirely when forcing AI, and for a photo,
// which only a model can read).
const photo = imageMimeType(file.originalname) !== null;
if (mode !== 'force-ai' && kitineraryAvailable && !photo) {
try {
kiItems = await this.extractor.extract(file.buffer, file.originalname);
} catch (err) {
@@ -2,7 +2,8 @@ import { Injectable } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import { RealtimeService } from '../realtime/realtime.service';
import { BookingImportService } from './booking-import.service';
import type { BookingImportMode, BookingImportPreviewResponse, TrekWsPayload } from '@trek/shared';
import { LlmParseService } from '../llm-parse/llm-parse.service';
import type { BookingImportMode, BookingImportPreviewResponse, ReceiptScanResult, TrekWsPayload } from '@trek/shared';
type JobStatus = 'running' | 'done' | 'error';
@@ -13,7 +14,7 @@ interface ImportJob {
status: JobStatus;
done: number;
total: number;
result?: BookingImportPreviewResponse;
result?: BookingImportPreviewResponse | ReceiptScanResult;
error?: string;
createdAt: number;
}
@@ -28,7 +29,9 @@ const JOB_TTL_MS = 10 * 60_000;
* user's sockets via `broadcastToUser` (which reaches them on ANY page, not just the
* trip room). This is what lets the upload modal close at once and a background widget
* track the work while the user keeps navigating. The actual parsing is the same
* `BookingImportService.preview` the synchronous endpoint uses.
* `BookingImportService.preview` the synchronous endpoint uses. A receipt scanned
* from the Costs tab rides the same queue, with `LlmParseService.readReceipt` as
* its work: it is the same slow model on the same server.
*/
@Injectable()
export class ImportJobsService {
@@ -36,17 +39,44 @@ export class ImportJobsService {
/** Tail of each user's job chain — parses run one at a time per user, not all at once. */
private readonly chains = new Map<number, Promise<void>>();
constructor(private readonly bookingImport: BookingImportService, private readonly realtime: RealtimeService) {}
constructor(
private readonly bookingImport: BookingImportService,
private readonly realtime: RealtimeService,
private readonly llmParse: LlmParseService,
) {}
/** Create a job and queue it behind the user's other parses; returns the job id at once. */
start(tripId: string, files: Express.Multer.File[], mode: BookingImportMode, userId: number): string {
return this.enqueue(tripId, userId, files.length, (job) =>
this.bookingImport.preview(files, mode, job.userId, (done, total, fileName) => {
job.done = done;
this.push(job, 'import:progress', { status: 'running', done, total, fileName });
}),
);
}
/** Queue the read of one receipt photo, behind the same user's other parses. */
startReceipt(tripId: string, file: Express.Multer.File, userId: number): string {
return this.enqueue(tripId, userId, 1, async (job) => {
const result = await this.llmParse.readReceipt({ buffer: file.buffer, originalName: file.originalname }, job.userId);
job.done = 1;
return result;
});
}
private enqueue(
tripId: string,
userId: number,
total: number,
work: (job: ImportJob) => Promise<BookingImportPreviewResponse | ReceiptScanResult>,
): string {
const id = randomUUID();
const job: ImportJob = { id, tripId, userId, status: 'running', done: 0, total: files.length, createdAt: Date.now() };
const job: ImportJob = { id, tripId, userId, status: 'running', done: 0, total, createdAt: Date.now() };
this.jobs.set(id, job);
// Chain onto the user's previous parse so they run sequentially (one CPU-heavy
// inference at a time), while the request returns immediately.
const prev = this.chains.get(userId) ?? Promise.resolve();
const next = prev.then(() => this.run(job, files, mode)).catch(() => {});
const next = prev.then(() => this.run(job, work)).catch(() => {});
this.chains.set(userId, next);
void next.finally(() => {
if (this.chains.get(userId) === next) this.chains.delete(userId);
@@ -59,13 +89,10 @@ export class ImportJobsService {
return job && job.userId === userId ? job : undefined;
}
private async run(job: ImportJob, files: Express.Multer.File[], mode: BookingImportMode): Promise<void> {
private async run(job: ImportJob, work: (job: ImportJob) => Promise<BookingImportPreviewResponse | ReceiptScanResult>): Promise<void> {
this.push(job, 'import:progress', { status: 'running', done: 0, total: job.total });
try {
const result = await this.bookingImport.preview(files, mode, job.userId, (done, total, fileName) => {
job.done = done;
this.push(job, 'import:progress', { status: 'running', done, total, fileName });
});
const result = await work(job);
job.status = 'done';
job.result = result;
this.push(job, 'import:done', { result });
@@ -6,13 +6,14 @@ import { UnreadableLlmResponse } from './openai-compatible.client';
const MAX_TOKENS = 8192;
const ANTHROPIC_VERSION = '2023-06-01';
const TOOL_NAME = 'emit_reservations';
/**
* Anthropic Messages API client. Structured output via forced tool-use: a single
* `emit_reservations` tool whose `input_schema` is the reservations schema, with
* `tool_choice` forcing it — the documented, reliable way to get structured JSON.
* PDFs go as native base64 `document` blocks (Anthropic reads scanned PDFs).
* `emit_<rootKey>` tool (`emit_reservations` by default) whose `input_schema` is
* the caller's schema, with `tool_choice` forcing it — the documented, reliable
* way to get structured JSON.
* PDFs go as native base64 `document` blocks (Anthropic reads scanned PDFs), photos
* as `image` blocks.
* Raw fetch (no SDK) to match the codebase's HTTP style.
*/
export class AnthropicClient implements LlmExtractionClient {
@@ -24,16 +25,21 @@ export class AnthropicClient implements LlmExtractionClient {
const base = (input.baseUrl ?? 'https://api.anthropic.com').replace(/(?<!\/)\/+$/, '');
const url = `${base}/v1/messages`;
const rootKey = input.rootKey ?? 'reservations';
const toolName = `emit_${rootKey}`;
const userText = input.userText ?? USER_TEXT;
const content: unknown[] = [];
if (input.file) {
for (const file of input.file ? [input.file, ...(input.pageImages ?? [])] : []) {
content.push({
type: 'document',
source: { type: 'base64', media_type: input.file.mimeType, data: input.file.data.toString('base64') },
// A photo is an image block; the document block only takes a PDF.
type: file.mimeType.startsWith('image/') ? 'image' : 'document',
source: { type: 'base64', media_type: file.mimeType, data: file.data.toString('base64') },
});
}
content.push({
type: 'text',
text: input.text ? `${USER_TEXT}\n\n${input.text}` : USER_TEXT,
text: input.text ? `${userText}\n\n${input.text}` : userText,
});
const body = {
@@ -42,12 +48,12 @@ export class AnthropicClient implements LlmExtractionClient {
system: input.prompt,
tools: [
{
name: TOOL_NAME,
description: 'Return the travel reservations extracted from the document.',
name: toolName,
description: `Return the ${rootKey} extracted from the document.`,
input_schema: input.jsonSchema,
},
],
tool_choice: { type: 'tool', name: TOOL_NAME },
tool_choice: { type: 'tool', name: toolName },
messages: [{ role: 'user', content }],
};
@@ -78,7 +84,7 @@ export class AnthropicClient implements LlmExtractionClient {
const data = (await res.json()) as {
stop_reason?: string;
content?: { type: string; name?: string; input?: { reservations?: unknown } }[];
content?: { type: string; name?: string; input?: Record<string, unknown> }[];
};
if (data.stop_reason === 'refusal') {
@@ -95,9 +101,9 @@ export class AnthropicClient implements LlmExtractionClient {
);
}
const toolUse = data.content?.find(b => b.type === 'tool_use' && b.name === TOOL_NAME);
if (!toolUse) throw new UnreadableLlmResponse(`the model answered without calling ${TOOL_NAME}`);
return toReservationList(toolUse.input?.reservations);
const toolUse = data.content?.find(b => b.type === 'tool_use' && b.name === toolName);
if (!toolUse) throw new UnreadableLlmResponse(`the model answered without calling ${toolName}`);
return toReservationList(toolUse.input?.[rootKey], rootKey);
}
}
@@ -110,7 +110,7 @@ function clean(value: unknown): unknown {
* treating the right-most separator as the decimal point. Returns null when there
* is no parseable amount.
*/
function parseAmount(raw: unknown): number | null {
export function parseAmount(raw: unknown): number | null {
if (typeof raw === 'number') return Number.isFinite(raw) ? raw : null;
if (typeof raw !== 'string') return null;
let s = raw.replace(/[^\d.,]/g, '');
@@ -84,18 +84,21 @@ export class OpenAiCompatibleClient implements LlmExtractionClient {
const base = (input.baseUrl ?? 'https://api.openai.com/v1').replace(/(?<!\/)\/+$/, '');
const url = `${base}/chat/completions`;
const nuextract = isNuExtractModel(input.model);
const rootKey = input.rootKey ?? 'reservations';
const userText = input.userText ?? USER_TEXT;
const userContent: unknown[] = nuextract
? [{ type: 'text', text: buildNuExtractUserText(input.text ?? '') }]
: [{ type: 'text', text: input.text ? `${USER_TEXT}\n\n${input.text}` : USER_TEXT }];
: [{ type: 'text', text: input.text ? `${userText}\n\n${input.text}` : userText }];
// Only genuine images go natively (as image_url) — OpenAI-compatible servers
// (notably Ollama) reject `file`/PDF content parts. PDFs reach this client as
// pre-extracted text (see llm-parse.service.ts), never as bytes.
if (!nuextract && input.file && input.file.mimeType.startsWith('image/')) {
const b64 = input.file.data.toString('base64');
// pre-extracted text (see llm-parse.service.ts), or a scan as its pages drawn
// as images, never as PDF bytes.
for (const file of !nuextract && input.file ? [input.file, ...(input.pageImages ?? [])] : []) {
if (!file.mimeType.startsWith('image/')) continue;
userContent.push({
type: 'image_url',
image_url: { url: `data:${input.file.mimeType};base64,${b64}` },
image_url: { url: `data:${file.mimeType};base64,${file.data.toString('base64')}` },
});
}
@@ -130,7 +133,7 @@ export class OpenAiCompatibleClient implements LlmExtractionClient {
...baseBody,
response_format: shape.jsonObject
? { type: 'json_object' as const }
: { type: 'json_schema' as const, json_schema: { name: 'reservations', schema: input.jsonSchema, strict: false } },
: { type: 'json_schema' as const, json_schema: { name: rootKey, schema: input.jsonSchema, strict: false } },
};
};
@@ -185,7 +188,7 @@ export class OpenAiCompatibleClient implements LlmExtractionClient {
choices?: { message?: { content?: string } }[];
};
const content = data.choices?.[0]?.message?.content;
return nuextract ? parseNuExtract(content) : parseReservations(content);
return nuextract ? parseNuExtract(content) : parseReservations(content, rootKey);
}
private async send(url: string, body: unknown, apiKey?: string): Promise<Response> {
@@ -216,9 +219,9 @@ function parseNuExtract(content: string | undefined | null): Record<string, unkn
const USER_TEXT = 'Extract every travel reservation from the following document as schema.org JSON-LD.';
/** Tolerant parse: strip code fences, JSON(5).parse, pull `reservations`. */
function parseReservations(content: string | undefined | null): Record<string, unknown>[] {
return toReservationList(readAnswer(content));
/** Tolerant parse: strip code fences, JSON(5).parse, pull the `rootKey` array. */
function parseReservations(content: string | undefined | null, rootKey: string): Record<string, unknown>[] {
return toReservationList(readAnswer(content), rootKey);
}
/**
+67
View File
@@ -0,0 +1,67 @@
import { extname } from 'node:path';
import { Jimp } from 'jimp';
import { PDFParse } from 'pdf-parse';
import { LLM_PHOTO_EXTENSIONS } from '@trek/shared';
/** A photographed document on its way to a vision model, by the extensions shared/ lists. */
const IMAGE_MIME_BY_EXT: Record<(typeof LLM_PHOTO_EXTENSIONS)[number], string> = {
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.png': 'image/png',
'.webp': 'image/webp',
};
export const IMAGE_EXTENSIONS: readonly string[] = LLM_PHOTO_EXTENSIONS;
/** The image MIME type for a file name, or null when it is not a photo TREK sends. */
export function imageMimeType(fileName: string): string | null {
return (IMAGE_MIME_BY_EXT as Record<string, string>)[extname(fileName).toLowerCase()] ?? null;
}
/**
* Long edge in pixels. A phone photo is around twelve megapixels, which a model
* pays for in prompt tokens without reading the print any better: the same
* receipt on a local qwen3.5:4b took 252s whole and 37-124s at this size.
*/
export const IMAGE_MAX_EDGE = 1600;
const IMAGE_JPEG_QUALITY = 82;
/**
* The image, no larger than the model needs. Returns the bytes unchanged when
* they are already small enough, or when they cannot be decoded (Jimp has no
* WebP): a photo that might be read beats one refused over a failed resize.
*/
export async function capImage(data: Buffer, mimeType: string): Promise<{ data: Buffer; mimeType: string }> {
try {
const image = await Jimp.read(data);
if (Math.max(image.width, image.height) <= IMAGE_MAX_EDGE) return { data, mimeType };
if (image.width >= image.height) image.resize({ w: IMAGE_MAX_EDGE });
else image.resize({ h: IMAGE_MAX_EDGE });
const resized = await image.getBuffer('image/jpeg', { quality: IMAGE_JPEG_QUALITY });
return { data: Buffer.from(resized), mimeType: 'image/jpeg' };
} catch {
return { data, mimeType };
}
}
/**
* Pages of a PDF read the only way a scan can be: as pictures. The booking
* details sit on the first pages, and every page costs the model a photo's
* worth of prompt, so only the first two are drawn.
*/
export const PDF_PAGES_AS_IMAGES = 2;
/** The first pages of a PDF drawn as images, capped like a photo; empty when none could be drawn. */
export async function renderPdfPages(data: Buffer): Promise<{ data: Buffer; mimeType: string }[]> {
const parser = new PDFParse({ data: new Uint8Array(data) });
try {
const shot = await parser.getScreenshot({ first: PDF_PAGES_AS_IMAGES, desiredWidth: IMAGE_MAX_EDGE, imageBuffer: true, imageDataUrl: false });
const pages: { data: Buffer; mimeType: string }[] = [];
for (const page of shot.pages) {
if (page.data?.length) pages.push(await capImage(Buffer.from(page.data), 'image/png'));
}
return pages;
} finally {
await parser.destroy().catch(() => {});
}
}
+6 -3
View File
@@ -60,13 +60,16 @@ export function parseLenientJson(content: string | undefined | null): unknown {
* would have been accepted inside an array is accepted alone rather than thrown
* away without a word (#2375). It is still not guessing: an object without that
* key stays refused.
*
* `rootKey` names the wrapper for a caller that asks for another list than
* reservations (the receipt read asks for `receipts`).
*/
export function toReservationList(value: unknown): Record<string, unknown>[] {
export function toReservationList(value: unknown, rootKey = 'reservations'): Record<string, unknown>[] {
const list = (v: unknown): Record<string, unknown>[] | null => {
if (Array.isArray(v)) return v as Record<string, unknown>[];
if (v && typeof v === 'object') {
const o = v as { reservations?: unknown; '@type'?: unknown };
if (Array.isArray(o.reservations)) return o.reservations as Record<string, unknown>[];
const o = v as Record<string, unknown>;
if (Array.isArray(o[rootKey])) return o[rootKey] as Record<string, unknown>[];
if (typeof o['@type'] === 'string') return [v as Record<string, unknown>];
}
return null;
@@ -0,0 +1,22 @@
import { Controller, Get, UseGuards } from '@nestjs/common';
import type { LlmCapabilitiesResponse } from '@trek/shared';
import type { User } from '../../types';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import { LlmParseService } from './llm-parse.service';
/**
* What the caller's AI model can be handed. Per user, unlike the public
* /api/health/features: whose model applies depends on who is asking. The
* photo pickers read it to decide whether a photo is offered at all.
*/
@Controller('api/llm')
@UseGuards(JwtAuthGuard)
export class LlmCapabilitiesController {
constructor(private readonly llmParse: LlmParseService) {}
@Get('capabilities')
async capabilities(@CurrentUser() user: User): Promise<LlmCapabilitiesResponse> {
return { images: await this.llmParse.readsImages(user.id) };
}
}
@@ -1,5 +1,6 @@
import { ADDON_IDS } from '../../addons';
import { AddonsService } from '../addons/addons.service';
import { asLlmVision } from '@trek/shared';
import { decryptLlmApiKey, LLM_PROVIDERS, type LlmProvider, type ResolvedLlmConfig } from './llm-config';
import { DatabaseService } from '../database/database.service';
import { SettingsService } from '../settings/settings.service';
@@ -53,7 +54,7 @@ export class LlmConfigResolver {
model,
baseUrl: typeof cfg.baseUrl === 'string' && cfg.baseUrl.trim() ? cfg.baseUrl.trim() : undefined,
apiKey: decryptLlmApiKey(cfg.apiKey),
multimodal: cfg.multimodal === true,
vision: asLlmVision(cfg.vision),
};
}
@@ -88,7 +89,9 @@ export class LlmConfigResolver {
model,
baseUrl,
apiKey,
multimodal: settings.llm_multimodal === true,
// The personal switch has only ever been on or off, and was saved as the
// person set it, so it is read as exactly that.
vision: settings.llm_multimodal === true ? 'on' : 'off',
};
}
}
+8 -2
View File
@@ -1,3 +1,4 @@
import { asLlmVision, type LlmVision } from '@trek/shared';
import { maybe_encrypt_api_key, decrypt_api_key } from '../common/crypto/apiKeyCrypto';
/**
@@ -21,7 +22,7 @@ export interface ResolvedLlmConfig {
model: string;
baseUrl?: string;
apiKey?: string;
multimodal: boolean;
vision: LlmVision;
}
/** Shape of the admin instance config stored in `addons.config` (apiKey encrypted). */
@@ -30,7 +31,11 @@ export interface LlmAddonConfig {
model?: string;
baseUrl?: string;
apiKey?: string;
multimodal?: boolean;
/**
* Replaces `multimodal`, which the admin screens wrote as `false` on every
* save whatever the model: a stored `false` means nothing, so it is not read.
*/
vision?: LlmVision;
}
export const LLM_PROVIDERS: LlmProvider[] = ['local', 'openai', 'anthropic'];
@@ -46,6 +51,7 @@ export function prepareLlmAddonConfigForWrite(
existingStored: Record<string, unknown> | undefined,
): Record<string, unknown> {
const out: Record<string, unknown> = { ...incoming };
if ('vision' in incoming) out.vision = asLlmVision(incoming.vision);
const key = incoming.apiKey;
if (key === undefined || key === null || key === '' || key === MASKED_VALUE) {
// Keep the existing encrypted key untouched (mask echoed or no key supplied).
@@ -1,5 +1,17 @@
import { Injectable, HttpException } from '@nestjs/common';
import { z } from 'zod';
import { safeFetchLlm } from '../../utils/ssrfGuard';
import { readCappedJson } from '../../utils/cappedFetch';
/** `/api/show` carries the licence and the template: tens of KB, never megabytes. */
const MAX_SHOW_BYTES = 1024 * 1024;
const SHOW_TIMEOUT_MS = 5_000;
/** A model's capabilities only change when it is re-pulled; ten minutes of staleness is harmless. */
const CAPABILITIES_TTL_MS = 10 * 60_000;
/** An unreachable server is asked again soon, so a restart does not cost ten minutes. */
const CAPABILITIES_MISS_TTL_MS = 60_000;
const showResponseSchema = z.object({ capabilities: z.array(z.string()).optional() });
/**
* Admin helpers for managing a local OpenAI-compatible LLM server (Ollama).
@@ -11,6 +23,8 @@ import { safeFetchLlm } from '../../utils/ssrfGuard';
*/
@Injectable()
export class LlmLocalService {
private readonly capabilityCache = new Map<string, { capabilities: string[] | null; expires: number }>();
/** Derive the Ollama root from a configured base URL (strip a trailing /v1). */
ollamaRoot(baseUrl: string | undefined): string {
const raw = (baseUrl ?? 'http://localhost:11434').trim();
@@ -65,4 +79,42 @@ export class LlmLocalService {
if (!res.ok || !res.body) throw new HttpException({ error: `Pull failed (${res.status})` }, 502);
return res.body;
}
/**
* What the local server says the model can do (`['completion', 'vision', …]`),
* or null when it cannot say: a bad URL, a server that is down, a model it
* does not have, an answer that is not what Ollama sends. Never throws, since
* every caller treats "unknown" as "no", and remembers the answer per server
* and model so a page load does not become a request to the model server.
*/
async modelCapabilities(baseUrl: string | undefined, model: string): Promise<string[] | null> {
const now = Date.now();
const key = `${baseUrl ?? ''}\n${model}`;
const cached = this.capabilityCache.get(key);
if (cached && cached.expires > now) return cached.capabilities;
let capabilities: string[] | null = null;
try {
const root = this.ollamaRoot(baseUrl);
const res = await safeFetchLlm(`${root}/api/show`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ model }),
signal: AbortSignal.timeout(SHOW_TIMEOUT_MS),
});
if (res.ok) {
const parsed = showResponseSchema.safeParse(await readCappedJson<unknown>(res, MAX_SHOW_BYTES));
if (parsed.success) capabilities = parsed.data.capabilities ?? [];
} else {
void res.body?.cancel().catch(() => {});
}
} catch {
capabilities = null;
}
this.capabilityCache.set(key, {
capabilities,
expires: now + (capabilities ? CAPABILITIES_TTL_MS : CAPABILITIES_MISS_TTL_MS),
});
return capabilities;
}
}
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
import { LlmParseService } from './llm-parse.service';
import { LlmLocalService } from './llm-local.service';
import { LlmLocalController } from './llm-local.controller';
import { LlmCapabilitiesController } from './llm-capabilities.controller';
import { LlmConfigResolver } from './llm-config.resolver';
import { SettingsModule } from '../settings/settings.module';
import { AddonsModule } from '../addons/addons.module';
@@ -13,7 +14,7 @@ import { AppConfigModule } from '../app-config/app-config.module';
*/
@Module({
imports: [AppConfigModule, SettingsModule, AddonsModule],
controllers: [LlmLocalController],
controllers: [LlmLocalController, LlmCapabilitiesController],
providers: [LlmParseService, LlmLocalService, LlmConfigResolver],
exports: [LlmParseService, LlmConfigResolver],
})
+121 -14
View File
@@ -1,13 +1,25 @@
import type { KiReservation } from '../booking-import/kitinerary.types';
import { createLlmClient } from './llm-client.factory';
import { LlmConfigResolver } from './llm-config.resolver';
import type { ResolvedLlmConfig } from './llm-config';
import { LlmLocalService } from './llm-local.service';
import { capImage, imageMimeType, renderPdfPages } from './image-input';
import { extractEnforced } from './router/ollama-format.client';
import {
buildReceiptPrompt,
RECEIPT_JSON_SCHEMA,
RECEIPT_LIST_JSON_SCHEMA,
RECEIPT_ROOT_KEY,
RECEIPT_USER_TEXT,
toReceiptRead,
} from './receipt-read';
import { buildSystemPrompt, KI_RESERVATION_JSON_SCHEMA } from './llm-prompt';
import type { LlmExtractionInput } from './llm-provider.interface';
import { isPdf, extractText } from './text-extract';
import { routeExtraction, detectFlightNumbers, extractTotalPrice } from './router/extraction-router';
import { routeExtraction, routeImageExtraction, detectFlightNumbers, extractTotalPrice } from './router/extraction-router';
import { toIsoCurrency } from './currency-code';
import { Injectable } from '@nestjs/common';
import { kiReservationSchema } from '@trek/shared';
import { kiReservationSchema, type ReceiptScanResult } from '@trek/shared';
import { RuntimeEnvService } from '../app-config/runtime-env.service';
const MIME_BY_EXT: Record<string, string> = {
@@ -21,7 +33,8 @@ export interface LlmParseResult {
/**
* Orchestrates the LLM fallback: resolve config → pick client → build input
* (native bytes vs extracted text by the `multimodal` flag) → call provider →
* (a photo as an image when the model reads one, a PDF natively for Anthropic,
* extracted text otherwise) → call provider →
* validate the response → return schema.org `KiReservation[]` for the shared
* mapper. Never throws for content/provider reasons — degrades to `[]` + a
* warning, mirroring the kitinerary extractor's tolerance.
@@ -31,6 +44,7 @@ export class LlmParseService {
constructor(
private readonly llmConfig: LlmConfigResolver,
private readonly env: RuntimeEnvService,
private readonly local: LlmLocalService,
) {}
/** True when the addon is enabled AND a usable config resolves for this user. */
@@ -38,6 +52,77 @@ export class LlmParseService {
return this.llmConfig.resolve(userId) !== null;
}
/** Whether a photo this user uploads can be read: AI parsing is set up and its model takes images. */
async readsImages(userId: number): Promise<boolean> {
const config = this.llmConfig.resolve(userId);
return config ? this.visionFor(config) : false;
}
/**
* Read a photographed receipt for the Costs tab. Like parse(), it never throws
* for content or provider reasons: a refusal comes back as a null receipt and
* a warning naming the file.
*/
async readReceipt(file: { buffer: Buffer; originalName: string }, userId: number): Promise<ReceiptScanResult> {
const config = this.llmConfig.resolve(userId);
if (!config) return { receipt: null, warnings: ['AI parsing is not configured'] };
const imageType = imageMimeType(file.originalName);
if (!imageType) return { receipt: null, warnings: [`${file.originalName}: not a photo`] };
if (!(await this.visionFor(config))) {
return { receipt: null, warnings: [`${file.originalName}: the configured model does not read images`] };
}
const image = await capImage(file.buffer, imageType);
const prompt = buildReceiptPrompt();
let raw: unknown;
try {
if (config.provider === 'local') {
// Ollama's own chat API, as the booking router uses: `think: false` and
// `num_ctx` exist there and not on /v1, and a hybrid model that is left
// to reason spends the whole token budget on it and answers nothing.
raw = await extractEnforced({
baseUrl: config.baseUrl ?? 'http://localhost:11434/v1',
model: config.model,
apiKey: config.apiKey,
system: prompt,
user: RECEIPT_USER_TEXT,
schema: RECEIPT_JSON_SCHEMA,
images: [image.data.toString('base64')],
numCtx: 16384,
// A supermarket receipt is a long list of lines.
numPredict: 2048,
});
} else {
const list = await createLlmClient(config).extract({
prompt,
jsonSchema: RECEIPT_LIST_JSON_SCHEMA,
rootKey: RECEIPT_ROOT_KEY,
userText: RECEIPT_USER_TEXT,
model: config.model,
baseUrl: config.baseUrl,
apiKey: config.apiKey,
file: image,
});
raw = list[0];
}
} catch (err) {
console.error(`[llm-parse] Receipt read failed for "${file.originalName}" (provider=${config.provider}):`, err instanceof Error ? err.message : err);
return { receipt: null, warnings: [`${file.originalName}: AI parsing failed — ${err instanceof Error ? err.message : String(err)}`] };
}
const receipt = toReceiptRead(raw);
return receipt ? { receipt, warnings: [] } : { receipt: null, warnings: [`${file.originalName}: no receipt could be read`] };
}
private async visionFor(config: ResolvedLlmConfig): Promise<boolean> {
if (config.vision !== 'auto') return config.vision === 'on';
// Only a local Ollama can be asked. A cloud model is not assumed to read
// images: an admin who knows it does says so.
if (config.provider !== 'local') return false;
const capabilities = await this.local.modelCapabilities(config.baseUrl, config.model);
return capabilities?.includes('vision') ?? false;
}
async parse(file: { buffer: Buffer; originalName: string }, userId: number): Promise<LlmParseResult> {
const config = this.llmConfig.resolve(userId);
if (!config) return { kiItems: [], warnings: ['AI parsing is not configured'] };
@@ -51,11 +136,22 @@ export class LlmParseService {
apiKey: config.apiKey,
};
// Native PDF only for Anthropic (its document block reads text AND scans).
// OpenAI-compatible servers (incl. Ollama/NuExtract) can't ingest PDFs/`file`
// parts, so every other provider gets extracted text.
const imageType = imageMimeType(file.originalName);
if (imageType && !(await this.visionFor(config))) {
return { kiItems: [], warnings: [`${file.originalName}: the configured model does not read images`] };
}
// A photo goes as an image to every provider. Native PDF only for Anthropic
// (its document block reads text AND scans). OpenAI-compatible servers
// (incl. Ollama/NuExtract) can't ingest PDFs/`file` parts, so every other
// provider gets extracted text.
try {
if (config.provider === 'anthropic' && isPdf(file.originalName)) {
if (imageType) {
input.file = await capImage(file.buffer, imageType);
console.debug(
`[DEBUG] Photo (${input.file.data.length} bytes) sent to ${config.provider}: ${file.originalName}`,
);
} else if (config.provider === 'anthropic' && isPdf(file.originalName)) {
input.file = { mimeType: MIME_BY_EXT['.pdf'], data: file.buffer };
console.debug(
`[DEBUG] Extracted (native PDF, ${file.buffer.length} bytes) sent to ${config.provider}: ${file.originalName}`,
@@ -78,10 +174,18 @@ export class LlmParseService {
console.debug(`[DEBUG] Extracted text from ${file.originalName} (${input.text.length} chars):\n`, input.text);
}
if (!input.text.trim()) {
return {
kiItems: [],
warnings: [`${file.originalName}: no readable text found (a scanned PDF needs a cloud/vision provider)`],
};
// A scan has no text layer, but a model that reads images can read its pages.
const pages = isPdf(file.originalName) && (await this.visionFor(config)) ? await renderPdfPages(file.buffer) : [];
if (pages.length === 0) {
return {
kiItems: [],
warnings: [`${file.originalName}: no readable text found (a scanned PDF needs a model that reads images)`],
};
}
input.text = undefined;
input.file = pages[0];
input.pageImages = pages.slice(1);
console.debug(`[DEBUG] Scanned PDF sent to ${config.provider} as ${pages.length} page image(s): ${file.originalName}`);
}
}
} catch (err) {
@@ -96,13 +200,16 @@ export class LlmParseService {
// templates → decompose + grammar-enforced per-reservation extraction → validate
// + repair. Far more reliable on small CPU models than the single-shot path below
// (which stays for cloud providers, whose strong models handle one-shot well).
if (config.provider === 'local' && input.text) {
if (config.provider === 'local' && (input.text || input.file)) {
try {
const routed = await routeExtraction(input.text, {
const ctx = {
baseUrl: config.baseUrl ?? 'http://localhost:11434/v1',
model: config.model,
apiKey: config.apiKey,
});
};
const routed = input.file
? await routeImageExtraction([input.file, ...(input.pageImages ?? [])].map((f) => f.data), ctx)
: await routeExtraction(input.text ?? '', ctx);
return { kiItems: routed.kiItems, warnings: [...warnings, ...routed.warnings] };
} catch (err) {
console.error(`[llm-parse] AI parsing failed for "${file.originalName}" (provider=${config.provider}):`, err instanceof Error ? err.message : err);
@@ -1,4 +1,4 @@
/** A single binary file (e.g. a PDF) sent natively to a multimodal provider. */
/** A single binary file (a PDF, or a photo) sent natively to a provider that reads it. */
export interface LlmExtractionFile {
mimeType: string;
data: Buffer;
@@ -15,8 +15,17 @@ export interface LlmExtractionInput {
apiKey?: string;
/** Pre-extracted text (text-like files, or text-only-model mode). */
text?: string;
/** Native binary (PDF) for multimodal providers. */
/** Native binary: a PDF for Anthropic, a photo for a model that reads images. */
file?: LlmExtractionFile;
/** The following pages of a scanned PDF, as images after `file`, which holds the first. */
pageImages?: LlmExtractionFile[];
/**
* The key the answer's array sits under, `jsonSchema` being `{ [rootKey]: [...] }`.
* Defaults to `reservations`; the receipt read asks for `receipts`.
*/
rootKey?: string;
/** The user-turn instruction. Defaults to the booking import's. */
userText?: string;
}
/**
+108
View File
@@ -0,0 +1,108 @@
import { receiptReadSchema, type ReceiptLine, type ReceiptRead } from '@trek/shared';
import { toIsoCurrency } from './currency-code';
import { parseAmount } from './clients/nuextract';
/**
* Reading a photographed receipt for the Costs tab: the prompt, the schema the
* answer is held to, and the step that turns the answer into a `ReceiptRead`.
* Pure, so every piece is testable without a model.
*
* The field list is flat and short on purpose. A till roll is printed small and
* photographed under bad light, and a small local model fills a handful of named
* fields far more reliably than the nested schema.org shape bookings use.
*/
/** Key the cloud clients read the answer's array under (`{ receipts: [...] }`). */
export const RECEIPT_ROOT_KEY = 'receipts';
export const RECEIPT_USER_TEXT = 'Read the receipt in the attached photo.';
/** One receipt, flat. Ollama compiles it to a grammar, so the answer has this shape or none. */
export const RECEIPT_JSON_SCHEMA = {
type: 'object',
properties: {
merchant: { type: 'string' },
date: { type: 'string' },
total: { type: 'number' },
currency: { type: 'string' },
items: {
type: 'array',
items: {
type: 'object',
properties: { name: { type: 'string' }, price: { type: 'number' } },
required: ['name', 'price'],
},
},
},
// Every field is required. Ollama's grammar lets a small model skip an
// optional key, and it does: qwen3.5:4b answered the total and the lines of a
// receipt printing both its name and its date, and nothing else. A field the
// receipt does not show comes back empty, which toReceiptRead reads as absent.
required: ['merchant', 'date', 'total', 'currency', 'items'],
} as const;
/** The same receipt wrapped in the list the cloud clients' structured output returns. */
export const RECEIPT_LIST_JSON_SCHEMA = {
type: 'object',
properties: { [RECEIPT_ROOT_KEY]: { type: 'array', items: RECEIPT_JSON_SCHEMA } },
required: [RECEIPT_ROOT_KEY],
} as const;
export function buildReceiptPrompt(today: Date = new Date()): string {
const todayIso = today.toISOString().slice(0, 10);
return [
'You read a photographed receipt, bill or invoice: often a paper till roll, possibly crumpled, skewed or in a foreign language.',
'Fill "total" with the grand total actually paid, after tax and tip: the final TOTAL or AMOUNT DUE line, not a subtotal.',
'Fill "currency" with its ISO 4217 code, from the symbol and the country when the code is not printed.',
'Fill "merchant" with the business name printed at the top, and "date" as YYYY-MM-DD.',
// A till roll prints "26-08-23" and means one of three dates. The merchant's
// country settles day/month order; nothing on the paper settles which pair is
// the year, and a receipt is scanned soon after it is paid.
`Today is ${todayIso}. When a date could be read several ways, take the reading nearest to today and never one in the future.`,
'Fill "items" with the purchased lines as printed, each with its line total as "price". Leave out subtotal, tax, discount, tip and total lines.',
'Never invent a value: when the receipt does not show a field, answer an empty string for it (0 for "total", [] for "items"). Numbers are plain numbers: 12.5, not "12,50 €".',
].join('\n');
}
function toText(value: unknown): string | null {
return typeof value === 'string' && value.trim() ? value.trim() : null;
}
/** A real calendar date as YYYY-MM-DD, or null. */
function toDate(value: unknown): string | null {
const s = toText(value);
if (!s || !/^\d{4}-\d{2}-\d{2}$/.test(s)) return null;
const d = new Date(`${s}T00:00:00Z`);
return !Number.isNaN(d.getTime()) && d.toISOString().slice(0, 10) === s ? s : null;
}
/**
* The model's answer as a `ReceiptRead`, or null when it holds nothing a person
* could use: no total, no merchant and no line. Each field is kept only when it
* reads as what it claims to be, so a garbled one is left for the person to fill
* rather than saved wrong.
*/
export function toReceiptRead(raw: unknown): ReceiptRead | null {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
const node = raw as Record<string, unknown>;
const total = parseAmount(node.total);
const items: ReceiptLine[] = [];
if (Array.isArray(node.items)) {
for (const line of node.items) {
if (!line || typeof line !== 'object') continue;
const name = toText((line as Record<string, unknown>).name);
const price = parseAmount((line as Record<string, unknown>).price);
if (name && price !== null) items.push({ name, price });
}
}
const read = {
merchant: toText(node.merchant),
date: toDate(node.date),
total: total !== null && total > 0 ? total : null,
currency: toIsoCurrency(node.currency) ?? null,
items,
};
if (read.total === null && read.merchant === null && items.length === 0) return null;
const parsed = receiptReadSchema.safeParse(read);
return parsed.success ? parsed.data : null;
}
@@ -23,6 +23,7 @@ import type { KiReservation } from '../../booking-import/kitinerary.types';
import { nuExtractToKiReservations } from '../clients/nuextract';
import { FLAT_SCHEMA_BY_TYPE, FLAT_TYPES, FLIGHTS_ARRAY_SCHEMA, UNION_SINGLE_SCHEMA, type FlatType, type FlatLike } from './flat-schemas';
import { extractEnforced } from './ollama-format.client';
import { parseLenientJson } from '../lenient-json';
export interface RouterContext {
baseUrl: string;
@@ -45,6 +46,24 @@ const TYPE_HINT: Record<FlatType, string> = {
event: 'event/attraction. name = the event/ticket, address = the venue, start_time/end_time = full ISO, price/currency = ticket price.',
};
/**
* The union prompt for a photo. Text has keywords that pick a type-specific
* schema and hint before the call; a photo has none, so the model gets every
* type's field meanings at once. Without them qwen3.5:4b read a train ticket
* as bare clock times with no stations, and its price as a JSON object inside
* the string.
*/
const PHOTO_UNION_HINT = [
'Pick the correct "type", then fill its fields:',
'- flight: vehicle_number = flight number, from_code/to_code = IATA codes.',
'- train, bus, ferry: from_name/to_name = stations, stops or ports, vehicle_number = train or line number.',
'- car: operator = rental company, from_name = pick-up location, to_name = return location.',
'- hotel: name, address, checkin_time/checkout_time.',
'- restaurant, event: name, address = the venue, start_time (and end_time).',
"Every time is full ISO 'YYYY-MM-DDTHH:MM:00' (a transport uses departure_time/arrival_time).",
'price = the total paid as a plain number like 49.00, currency = its ISO 4217 code.',
].join('\n');
/**
* Keyword → reservation type, so an obvious document skips the costlier union/strong path.
*
@@ -213,21 +232,23 @@ async function extractFlights(text: string, ctx: RouterContext): Promise<FlatLik
/** One enforced call for a single reservation — a type-specific schema when the type is
* obvious from keywords, else a union schema the model fills with the type it picks. */
async function extractSingle(text: string, ctx: RouterContext): Promise<FlatLike> {
async function extractSingle(text: string, ctx: RouterContext, images?: Buffer[]): Promise<FlatLike> {
const known = detectType(text);
const call = (schema: Record<string, unknown>, hint: string) =>
extractEnforced({
baseUrl: ctx.baseUrl, model: ctx.model, apiKey: ctx.apiKey,
system: `Extract the single reservation from the document into the flat fields. ${hint} Omit any field that is truly absent.`,
user: `Document:\n${text}`,
user: images?.length ? 'The document is the attached photo.' : `Document:\n${text}`,
schema,
// A photo costs far more prompt tokens than the text of the same page.
...(images?.length ? { images: images.map((image) => image.toString('base64')), numCtx: 16384 } : {}),
});
if (known) {
const out = (await call(FLAT_SCHEMA_BY_TYPE[known], `It is a ${TYPE_HINT[known]}`)) ?? {};
return fixArrivalDate(normalizeDates({ ...out, type: known }));
}
const out = (await call(UNION_SINGLE_SCHEMA, 'Pick the correct "type".')) ?? {};
const out = (await call(UNION_SINGLE_SCHEMA, images?.length ? PHOTO_UNION_HINT : 'Pick the correct "type".')) ?? {};
// "I could not tell" used to be written down as "hotel", and a hotel is a
// booking, so a ferry voucher imported from the Transport tab opened the
// booking form with no transport type to pick and stayed 'other' (#2076).
@@ -298,3 +319,38 @@ export async function routeExtraction(text: string, ctx: RouterContext): Promise
const kiItems = nuExtractToKiReservations(flats as unknown as Record<string, unknown>[]) as unknown as KiReservation[];
return { kiItems, warnings };
}
/**
* The router for a photographed document: one enforced call with the photo
* attached, or the pages of a scanned PDF, one image each. There is no text to
* spot a flight number or a vendor in, so the model picks the type, and the
* booking-wide fields come from its answer alone.
*/
export async function routeImageExtraction(images: Buffer[], ctx: RouterContext): Promise<{ kiItems: KiReservation[]; warnings: string[] }> {
let flat: FlatLike;
try {
flat = unwrapPrice(await extractSingle('', ctx, images));
} catch (err) {
return { kiItems: [], warnings: [`AI parsing failed — ${err instanceof Error ? err.message : String(err)}`] };
}
const kiItems = nuExtractToKiReservations([flat] as unknown as Record<string, unknown>[]) as unknown as KiReservation[];
return { kiItems, warnings: [] };
}
/**
* The price as the amount alone. With no document text to take the total from,
* the model's `price` is all there is, and qwen3.5:4b writes it as an object
* inside the string (`{"amount": 49.00, "currency": "EUR"}`), which the amount
* parser then reads as 4900.
*/
function unwrapPrice(flat: FlatLike): FlatLike {
if (typeof flat.price !== 'string' || !flat.price.trim().startsWith('{')) return flat;
const inner = parseLenientJson(flat.price);
if (!inner || typeof inner !== 'object' || Array.isArray(inner)) return flat;
const { amount, currency } = inner as { amount?: unknown; currency?: unknown };
const out: FlatLike = { ...flat };
if (typeof amount === 'number' || typeof amount === 'string') out.price = String(amount);
else delete out.price;
if (!out.currency && typeof currency === 'string') out.currency = currency;
return out;
}
@@ -27,6 +27,8 @@ export interface EnforcedExtractInput {
numPredict?: number;
/** Context window. 8192 fits a typical multi-section booking; raise for long itineraries. */
numCtx?: number;
/** Base64 images attached to the user turn, for a vision model reading a photo. */
images?: string[];
}
/** Resolve the native API base from a config base URL that may end in `/v1`. */
@@ -59,7 +61,7 @@ export async function extractEnforced(input: EnforcedExtractInput): Promise<Reco
options: { temperature: 0, num_predict: input.numPredict ?? 512, num_ctx: input.numCtx ?? 8192 },
messages: [
{ role: 'system', content: input.system },
{ role: 'user', content: input.user },
{ role: 'user', content: input.user, ...(input.images?.length ? { images: input.images } : {}) },
],
};
@@ -0,0 +1,68 @@
import { Controller, HttpException, Param, Post, UploadedFile, UseGuards, UseInterceptors } from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { memoryStorage } from 'multer';
import type { ReceiptScanStartResponse } from '@trek/shared';
import type { User } from '../../types';
import { ADDON_IDS } from '../../addons';
import { AddonsService } from '../addons/addons.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import { DatabaseService } from '../database/database.service';
import { PermissionsService } from '../permissions/permissions.service';
import { ImportJobsService } from '../booking-import/import-jobs.service';
import { LlmParseService } from '../llm-parse/llm-parse.service';
import { imageMimeType } from '../llm-parse/image-input';
const MAX_FILE_BYTES = 10 * 1024 * 1024;
/**
* Scan a receipt photo into an expense draft.
*
* It only reads: the job answers a `ReceiptRead` the client pre-fills the
* expense editor with, and the expense (and the photo, attached to it) is saved
* by that editor's own path once the person has checked it. The job is a
* booking-import job like any other, so the background tasks widget follows it
* and `GET …/reservations/import/jobs/:jobId` answers for it.
*
* Trip access, the addon and the right are checked in the handler, not by
* guards: a guard answers before multer has read the body, and a response sent
* while the client is still uploading reaches it as ECONNRESET, not as the 404
* or 403 (see files.controller.ts). The answers are the guards' own.
*
* There is no MCP twin, for the reason the booking-file import has none: the
* route takes an uploaded photo, and a tool caller has no file to hand it.
*/
@Controller('api/trips/:tripId/budget/receipt-scan')
@UseGuards(JwtAuthGuard)
export class ReceiptScanController {
constructor(
private readonly importJobs: ImportJobsService,
private readonly llmParse: LlmParseService,
private readonly db: DatabaseService,
private readonly permissions: PermissionsService,
private readonly addons: AddonsService,
) {}
@Post()
@UseInterceptors(FileInterceptor('file', { storage: memoryStorage(), limits: { fileSize: MAX_FILE_BYTES, files: 1 }, defParamCharset: 'utf8' }))
async scan(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@UploadedFile() file: Express.Multer.File | undefined,
): Promise<ReceiptScanStartResponse> {
const trip = this.db.canAccessTrip(tripId, user.id);
if (!trip) throw new HttpException({ error: 'Trip not found' }, 404);
if (!this.addons.isAddonEnabled(ADDON_IDS.BUDGET)) throw new HttpException({ error: 'Costs addon is not enabled' }, 404);
if (!this.permissions.checkPermission('budget_edit', user.role, trip.user_id, user.id, trip.user_id !== user.id)) {
throw new HttpException({ error: 'No permission' }, 403);
}
if (!file) throw new HttpException({ error: 'No file uploaded' }, 400);
if (!imageMimeType(file.originalname)) {
throw new HttpException({ error: `Unsupported file type: ${file.originalname}. Accepted: JPG, PNG, WEBP` }, 400);
}
if (!(await this.llmParse.readsImages(user.id))) {
throw new HttpException({ error: 'The configured AI model does not read photos' }, 400);
}
return { jobId: this.importJobs.startReceipt(tripId, file, user.id) };
}
}
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { ReceiptScanController } from './receipt-scan.controller';
import { BookingImportModule } from '../booking-import/booking-import.module';
import { LlmParseModule } from '../llm-parse/llm-parse.module';
import { AddonsModule } from '../addons/addons.module';
import { PermissionsModule } from '../permissions/permissions.module';
/** The Costs tab's receipt scan: an HTTP surface over the import job queue. */
@Module({
imports: [BookingImportModule, LlmParseModule, AddonsModule, PermissionsModule],
controllers: [ReceiptScanController],
})
export class ReceiptScanModule {}
@@ -27,8 +27,9 @@ import type { AirtrailImportResult } from '@trek/shared';
import { bookingImportModeSchema } from '@trek/shared';
import type { BookingImportPreviewItem, BookingImportPreviewResponse, BookingImportConfirmResponse, BookingImportMode } from '@trek/shared';
import { BookingImportConfirmDto, BookingImportPreviewDto } from './reservation-import.dto';
import { IMAGE_EXTENSIONS, imageMimeType } from '../llm-parse/image-input';
const ACCEPTED_EXTS = new Set(['.eml', '.pdf', '.pkpass', '.html', '.htm', '.txt']);
const ACCEPTED_EXTS = new Set(['.eml', '.pdf', '.pkpass', '.html', '.htm', '.txt', ...IMAGE_EXTENSIONS]);
const MAX_FILE_BYTES = 10 * 1024 * 1024;
const MAX_FILES = 5;
@@ -99,7 +100,7 @@ export class ReservationImportController {
/** Shared validation for both the sync and async import endpoints; returns the parsed mode. */
private validateImport(tripId: string, user: User, files: Express.Multer.File[] | undefined, rawMode?: string): BookingImportMode {
private async validateImport(tripId: string, user: User, files: Express.Multer.File[] | undefined, rawMode?: string): Promise<BookingImportMode> {
const modeResult = bookingImportModeSchema.safeParse(rawMode ?? 'no-ai');
if (!modeResult.success) throw new HttpException({ error: 'Invalid mode' }, 400);
@@ -118,12 +119,19 @@ export class ReservationImportController {
throw new HttpException({ error: `Unsupported file type: ${f.originalname}. Accepted: EML, PDF, PKPass, HTML, TXT` }, 400);
}
}
// A photo has no text layer and no structure: only a model that reads images
// can do anything with it, so it is refused up front rather than coming back
// as an empty preview.
if (files.some((f) => imageMimeType(f.originalname)) && (mode === 'no-ai' || !(await this.bookingImport.readsImages(user.id)))) {
throw new HttpException({ error: 'The configured AI model does not read photos' }, 400);
}
return mode;
}
/**
* POST /api/trips/:tripId/reservations/import/booking
* Accepts up to 5 booking confirmation files (EML, PDF, PKPass, HTML, TXT).
* Accepts up to 5 booking confirmation files (EML, PDF, PKPass, HTML, TXT, and
* photos when the AI model reads images).
* Returns a preview list without persisting anything.
*/
@RequirePermission('reservation_edit')
@@ -135,7 +143,7 @@ export class ReservationImportController {
@UploadedFiles() files: Express.Multer.File[] | undefined,
@Body() body: BookingImportPreviewDto,
): Promise<BookingImportPreviewResponse> {
const mode = this.validateImport(tripId, user, files, body?.mode);
const mode = await this.validateImport(tripId, user, files, body?.mode);
return this.bookingImport.preview(files!, mode, user.id);
}
@@ -155,7 +163,7 @@ export class ReservationImportController {
@UploadedFiles() files: Express.Multer.File[] | undefined,
@Body() body: BookingImportPreviewDto,
): Promise<{ jobId: string }> {
const mode = this.validateImport(tripId, user, files, body?.mode);
const mode = await this.validateImport(tripId, user, files, body?.mode);
const jobId = this.importJobs.start(tripId, files!, mode, user.id);
return { jobId };
}