mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 07:26:30 +00:00
b1c05bca83
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.
95 lines
4.3 KiB
TypeScript
95 lines
4.3 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import { Jimp } from 'jimp';
|
|
import { capImage, imageMimeType, IMAGE_MAX_EDGE, renderPdfPages } from '../../../../src/nest/llm-parse/image-input';
|
|
|
|
async function png(w: number, h: number): Promise<Buffer> {
|
|
const image = new Jimp({ width: w, height: h, color: 0xffffffff });
|
|
return Buffer.from(await image.getBuffer('image/png'));
|
|
}
|
|
|
|
describe('imageMimeType', () => {
|
|
it('knows the photo formats a provider reads, whatever the case', () => {
|
|
expect(imageMimeType('a.jpg')).toBe('image/jpeg');
|
|
expect(imageMimeType('A.JPEG')).toBe('image/jpeg');
|
|
expect(imageMimeType('a.png')).toBe('image/png');
|
|
expect(imageMimeType('a.webp')).toBe('image/webp');
|
|
});
|
|
|
|
it('is null for everything else, HEIC included', () => {
|
|
for (const name of ['a.heic', 'a.pdf', 'a.txt', 'jpg', 'a.jpg.pdf']) expect(imageMimeType(name)).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('capImage', () => {
|
|
it('shrinks the long edge of a large photo to the cap, as a JPEG', async () => {
|
|
const out = await capImage(await png(IMAGE_MAX_EDGE * 2, 1000), 'image/png');
|
|
expect(out.mimeType).toBe('image/jpeg');
|
|
const back = await Jimp.read(out.data);
|
|
expect(back.width).toBe(IMAGE_MAX_EDGE);
|
|
expect(back.height).toBe(500);
|
|
});
|
|
|
|
it('caps a portrait photo by its height', async () => {
|
|
const back = await Jimp.read((await capImage(await png(800, IMAGE_MAX_EDGE * 2), 'image/png')).data);
|
|
expect(back.height).toBe(IMAGE_MAX_EDGE);
|
|
expect(back.width).toBe(400);
|
|
});
|
|
|
|
it('leaves a photo that is already small enough untouched', async () => {
|
|
const data = await png(400, 300);
|
|
expect(await capImage(data, 'image/png')).toEqual({ data, mimeType: 'image/png' });
|
|
});
|
|
|
|
it('passes bytes it cannot decode through unchanged', async () => {
|
|
const data = Buffer.from('RIFF....WEBP');
|
|
expect(await capImage(data, 'image/webp')).toEqual({ data, mimeType: 'image/webp' });
|
|
});
|
|
});
|
|
|
|
/** A PDF with no text layer: each page is one JPEG, as a scanner writes it. */
|
|
async function scannedPdf(pages: number): Promise<Buffer> {
|
|
const jpg = Buffer.from(await new Jimp({ width: 300, height: 400, color: 0xddddddff }).getBuffer('image/jpeg'));
|
|
const objects: Buffer[] = [];
|
|
const kids = Array.from({ length: pages }, (_, i) => `${3 + i * 3} 0 R`).join(' ');
|
|
objects.push(Buffer.from('<< /Type /Catalog /Pages 2 0 R >>'));
|
|
objects.push(Buffer.from(`<< /Type /Pages /Kids [${kids}] /Count ${pages} >>`));
|
|
for (let i = 0; i < pages; i++) {
|
|
const page = 3 + i * 3;
|
|
const content = 'q 300 0 0 400 0 0 cm /Im0 Do Q';
|
|
objects.push(Buffer.from(`<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 400] /Resources << /XObject << /Im0 ${page + 2} 0 R >> >> /Contents ${page + 1} 0 R >>`));
|
|
objects.push(Buffer.from(`<< /Length ${content.length} >>\nstream\n${content}\nendstream`));
|
|
objects.push(Buffer.concat([
|
|
Buffer.from(`<< /Type /XObject /Subtype /Image /Width 300 /Height 400 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode /Length ${jpg.length} >>\nstream\n`),
|
|
jpg,
|
|
Buffer.from('\nendstream'),
|
|
]));
|
|
}
|
|
const parts: Buffer[] = [Buffer.from('%PDF-1.4\n')];
|
|
const offsets: number[] = [];
|
|
let length = parts[0].length;
|
|
objects.forEach((body, i) => {
|
|
offsets.push(length);
|
|
const obj = Buffer.concat([Buffer.from(`${i + 1} 0 obj\n`), body, Buffer.from('\nendobj\n')]);
|
|
parts.push(obj);
|
|
length += obj.length;
|
|
});
|
|
const rows = offsets.map((o) => `${String(o).padStart(10, '0')} 00000 n \n`).join('');
|
|
parts.push(Buffer.from(`xref\n0 ${objects.length + 1}\n0000000000 65535 f \n${rows}trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${length}\n%%EOF\n`));
|
|
return Buffer.concat(parts);
|
|
}
|
|
|
|
describe('renderPdfPages', () => {
|
|
it('draws the first two pages of a scan as images no larger than a capped photo', async () => {
|
|
const pages = await renderPdfPages(await scannedPdf(3));
|
|
expect(pages).toHaveLength(2);
|
|
for (const page of pages) {
|
|
const image = await Jimp.read(page.data);
|
|
expect(Math.max(image.width, image.height)).toBeLessThanOrEqual(IMAGE_MAX_EDGE);
|
|
}
|
|
}, 30_000);
|
|
|
|
it('throws for bytes that are not a PDF, which the caller reports as unreadable', async () => {
|
|
await expect(renderPdfPages(Buffer.from('not a pdf'))).rejects.toThrow();
|
|
});
|
|
});
|