mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 06:56:35 +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.
148 lines
6.5 KiB
TypeScript
148 lines
6.5 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
|
|
// prepare() has to answer per statement now: the resolver reads the addon row
|
|
// AND the caller's role, and a single shared stub would hand the role lookup the
|
|
// addon row (silently green tests for the #1772 gate).
|
|
const { dbMock } = vi.hoisted(() => {
|
|
const addonStmt = { get: vi.fn() };
|
|
const roleStmt = { get: vi.fn() };
|
|
const other = { get: vi.fn(), all: vi.fn(), run: vi.fn() };
|
|
return {
|
|
dbMock: {
|
|
prepare: vi.fn((sql: string) => {
|
|
if (sql.includes('FROM addons')) return addonStmt;
|
|
if (sql.includes('FROM users')) return roleStmt;
|
|
return other;
|
|
}),
|
|
_addon: addonStmt,
|
|
_role: roleStmt,
|
|
},
|
|
};
|
|
});
|
|
vi.mock('../../../../src/db/database', () => ({ db: dbMock, closeDb: () => {}, reinitialize: () => {} }));
|
|
|
|
const isAddonEnabled = vi.fn();
|
|
|
|
import { LlmConfigResolver } from '../../../../src/nest/llm-parse/llm-config.resolver';
|
|
import { DatabaseService } from '../../../../src/nest/database/database.service';
|
|
import type { SettingsService } from '../../../../src/nest/settings/settings.service';
|
|
import type { AddonsService } from '../../../../src/nest/addons/addons.service';
|
|
|
|
// The resolver injects SettingsService — a stub instance instead of the old
|
|
// legacy-module path mock (same behaviors as before the DI move). The
|
|
// DatabaseService rides the same prepare/get mock the module-level db used.
|
|
const getUserSettings = vi.fn(() => ({}) as Record<string, unknown>);
|
|
const getAdminUserDefaults = vi.fn(() => ({}) as Record<string, unknown>);
|
|
const getDecryptedUserSetting = vi.fn(() => null as string | null);
|
|
const settingsStub = { getUserSettings, getAdminUserDefaults, getDecryptedUserSetting } as unknown as SettingsService;
|
|
|
|
const addonsStub = { isAddonEnabled } as unknown as AddonsService;
|
|
const resolver = new LlmConfigResolver(settingsStub, new DatabaseService(dbMock as never), addonsStub);
|
|
|
|
function setInstanceConfig(config: unknown) {
|
|
dbMock._addon.get.mockReturnValue(config === undefined ? undefined : { config: JSON.stringify(config) });
|
|
}
|
|
|
|
function setRole(role: 'user' | 'admin' | undefined) {
|
|
dbMock._role.get.mockReturnValue(role === undefined ? undefined : { role });
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
isAddonEnabled.mockReturnValue(true);
|
|
setInstanceConfig(undefined);
|
|
setRole('user');
|
|
getUserSettings.mockReturnValue({});
|
|
getAdminUserDefaults.mockReturnValue({});
|
|
getDecryptedUserSetting.mockReturnValue(null);
|
|
});
|
|
|
|
describe('resolveLlmConfig', () => {
|
|
it('returns null when the addon is disabled', () => {
|
|
isAddonEnabled.mockReturnValue(false);
|
|
expect(resolver.resolve(1)).toBeNull();
|
|
});
|
|
|
|
it('uses instance config when present (and decrypts the key)', () => {
|
|
setInstanceConfig({ provider: 'anthropic', model: 'claude-opus-4-8', apiKey: 'sk-plain', vision: 'on' });
|
|
expect(resolver.resolve(1)).toEqual({
|
|
provider: 'anthropic',
|
|
model: 'claude-opus-4-8',
|
|
baseUrl: undefined,
|
|
apiKey: 'sk-plain',
|
|
vision: 'on',
|
|
});
|
|
});
|
|
|
|
it('reads the instance vision setting as auto when it is missing, unknown, or only the old multimodal flag', () => {
|
|
for (const extra of [{}, { vision: 'maybe' }, { multimodal: false }, { multimodal: true }]) {
|
|
setInstanceConfig({ provider: 'local', model: 'qwen3.5:4b', ...extra });
|
|
expect(resolver.resolve(1)?.vision).toBe('auto');
|
|
}
|
|
setInstanceConfig({ provider: 'local', model: 'qwen3.5:4b', vision: 'off' });
|
|
expect(resolver.resolve(1)?.vision).toBe('off');
|
|
});
|
|
|
|
it('instance config with a base URL still wins for a plain user (#1772 does not touch it)', () => {
|
|
setInstanceConfig({ provider: 'local', model: 'nuextract', baseUrl: 'http://ollama:11434' });
|
|
setRole('user');
|
|
expect(resolver.resolve(7)).toMatchObject({ provider: 'local', baseUrl: 'http://ollama:11434' });
|
|
});
|
|
|
|
it('falls back to per-user config when instance config is incomplete', () => {
|
|
setInstanceConfig({ provider: 'anthropic' }); // no model → not usable
|
|
getUserSettings.mockReturnValue({ llm_provider: 'anthropic', llm_model: 'claude-sonnet', llm_multimodal: true });
|
|
getDecryptedUserSetting.mockReturnValue('user-key');
|
|
expect(resolver.resolve(7)).toEqual({
|
|
provider: 'anthropic',
|
|
model: 'claude-sonnet',
|
|
baseUrl: undefined,
|
|
apiKey: 'user-key',
|
|
vision: 'on',
|
|
});
|
|
expect(getDecryptedUserSetting).toHaveBeenCalledWith(7, 'llm_api_key');
|
|
});
|
|
|
|
it('returns null when neither instance nor user config is usable', () => {
|
|
getUserSettings.mockReturnValue({ llm_provider: 'openai' }); // no model
|
|
expect(resolver.resolve(1)).toBeNull();
|
|
});
|
|
|
|
// #1772: the endpoint is instance configuration, so it may only come from an
|
|
// admin-controlled source, whoever is asking.
|
|
it('#1772: picking local personally gets no config at all (no silent reroute)', () => {
|
|
getUserSettings.mockReturnValue({ llm_provider: 'local', llm_model: 'nuextract', llm_base_url: 'http://192.168.1.5:11434' });
|
|
expect(resolver.resolve(7)).toBeNull();
|
|
});
|
|
|
|
it('#1772: a personal OpenAI config survives but loses its own base URL', () => {
|
|
getUserSettings.mockReturnValue({ llm_provider: 'openai', llm_model: 'gpt-4o-mini', llm_base_url: 'http://192.168.1.5:11434' });
|
|
getDecryptedUserSetting.mockReturnValue('sk-user');
|
|
expect(resolver.resolve(7)).toEqual({
|
|
provider: 'openai',
|
|
model: 'gpt-4o-mini',
|
|
baseUrl: undefined,
|
|
apiKey: 'sk-user',
|
|
vision: 'off',
|
|
});
|
|
});
|
|
|
|
it('#1772: an admin-set instance default endpoint applies to everyone', () => {
|
|
// getUserSettings merges the admin defaults in; getAdminUserDefaults is the
|
|
// admin-controlled layer the endpoint is allowed to come from.
|
|
getUserSettings.mockReturnValue({ llm_provider: 'local', llm_model: 'nuextract', llm_base_url: 'http://ollama.internal:11434' });
|
|
getAdminUserDefaults.mockReturnValue({ llm_provider: 'local', llm_base_url: 'http://ollama.internal:11434' });
|
|
expect(resolver.resolve(7)).toMatchObject({ provider: 'local', baseUrl: 'http://ollama.internal:11434' });
|
|
});
|
|
|
|
it('#1772: the caller\'s role does not change the answer, and no role is looked up', () => {
|
|
// An instance has one endpoint. An admin who parked one in their own row is
|
|
// in exactly the same position as anyone else, and the resolver no longer
|
|
// reads the users table at all.
|
|
setRole('admin');
|
|
getUserSettings.mockReturnValue({ llm_provider: 'local', llm_model: 'nuextract', llm_base_url: 'http://192.168.1.5:11434' });
|
|
expect(resolver.resolve(7)).toBeNull();
|
|
expect(dbMock._role.get).not.toHaveBeenCalled();
|
|
});
|
|
});
|