mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-24 07:53:09 +00:00
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:
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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(() => {});
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user