printhive/test/ai.test.js
dlawler489 0f49fe8c1a AI tags and descriptions, search by meaning, and automatic tags
- AI tagging through any OpenAI-compatible API: DeepSeek by default, or
  OpenRouter, OpenAI, or a local server (Ollama, LM Studio). Settings → AI
  with presets, prices and "Save & test connection"; the key never leaves
  the server except to the provider.
- Runs over untagged models, all models, or one model from its page: live
  progress, tokens and cost, stop/resume (retrying failures), an optional
  spending limit, and paused rather than resumed after a restart. Adapts to
  models without JSON mode or image input; stops on a rejected key or no
  credit. Tags are only added; descriptions filled only where empty unless
  asked.
- Search by meaning: local bge-small-en-v1.5 embeddings (transformers.js),
  indexed in the background whenever a model's text changes; Words/Meaning
  switch on the search box. Stored as REAL[] in Postgres, no pgvector needed.
- Automatic tags: strict whole-word rules (part types, mechanisms, materials,
  techniques) over names, file names and archive contents, plus your own
  word -> tag rules; on/off (optionally removing its tags) and re-apply.
- Tags remember where they came from (user / rule / ai); edits keep that,
  rule and AI passes never touch your own tags, and the model page marks them.
- Docker: ships the embedding model and only Linux onnxruntime binaries.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 11:12:58 +10:00

111 lines
5.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// AI tagging client (lib/ai.js), against a fake OpenAI-compatible server.
const test = require('node:test');
const assert = require('node:assert/strict');
const http = require('http');
const ai = require('../lib/ai');
// Starts a server that answers each request with the next handler's result.
async function fakeProvider(handlers) {
const requests = [];
const server = http.createServer((req, res) => {
let body = '';
req.on('data', (c) => { body += c; });
req.on('end', () => {
const parsed = JSON.parse(body);
requests.push({ path: req.url, auth: req.headers.authorization, body: parsed });
const [status, payload] = handlers[Math.min(requests.length, handlers.length) - 1](parsed);
res.writeHead(status, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(payload));
});
});
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
const config = ai.configFrom({
ai_provider: 'custom', ai_base_url: `http://127.0.0.1:${server.address().port}/v1/`, ai_api_key: 'sk-test',
ai_model: 'test-model', ai_price_input: '0.3', ai_price_output: '1.2',
});
return { config, requests, close: () => new Promise((resolve) => server.close(resolve)) };
}
const reply = (content, usage = { prompt_tokens: 1000, completion_tokens: 100 }) =>
[200, { choices: [{ message: { content } }], usage }];
const MODEL = { name: 'Flexi Dragon', files: [{ originalName: 'dragon_PLA.3mf', bbox: { x: 120, y: 40, z: 30 } }], tags: ['toy'] };
test('presets fill in DeepSeek by default, and your own values win', () => {
const deepseek = ai.configFrom({ ai_api_key: 'k' });
assert.equal(deepseek.provider, 'deepseek');
assert.equal(deepseek.baseUrl, 'https://api.deepseek.com');
assert.equal(deepseek.model, 'deepseek-flash');
assert.equal(ai.missingConfig(deepseek), null);
assert.match(ai.missingConfig(ai.configFrom({})), /API key/);
const openrouter = ai.configFrom({ ai_provider: 'openrouter', ai_api_key: 'k' });
assert.match(ai.missingConfig(openrouter), /model/);
assert.equal(openrouter.prices.input, null);
// A local server needs no key.
assert.equal(ai.missingConfig(ai.configFrom({ ai_provider: 'custom', ai_base_url: 'http://localhost:11434/v1', ai_model: 'llava' })), null);
});
test('the prompt carries the model details and, when given, its image', () => {
const [system, user] = ai.buildMessages(MODEL, { image: 'data:image/png;base64,AAAA', knownTags: ['dragon', 'toy'] });
assert.equal(system.role, 'system');
const text = user.content[0].text;
assert.match(text, /Name: Flexi Dragon/);
assert.match(text, /Files: dragon_PLA\.3mf/);
assert.match(text, /Size: 120 × 40 × 30 mm/);
assert.match(text, /Tags already used in this library: dragon, toy/);
assert.deepEqual(user.content[1], { type: 'image_url', image_url: { url: 'data:image/png;base64,AAAA' } });
assert.equal(typeof ai.buildMessages(MODEL)[1].content, 'string');
});
test('replies are read leniently and tags tidied', () => {
const r = ai.parseReply('Sure!\n```json\n{"tags": ["#Dragon", "3D print", "Print_In Place", "dragon", "a", "b", "c", "d", "e", "f"], "description": " A flexible dragon toy. "}\n```');
assert.deepEqual(r.tags, ['dragon', 'print in place', 'a', 'b', 'c', 'd', 'e', 'f']);
assert.equal(r.description, 'A flexible dragon toy.');
assert.deepEqual(ai.parseReply('{"tags": "hook, wall mount", "description": ""}').tags, ['hook', 'wall mount']);
assert.throws(() => ai.parseReply('I cannot help with that'), /JSON/);
assert.throws(() => ai.parseReply('{"tags": [], "description": ""}'), /no tags/);
});
test('cost comes from the provider when reported, else from your prices', () => {
assert.equal(ai.costOf({ prompt_tokens: 1e6, completion_tokens: 1e6, cost: 0.0123 }, { input: 1, output: 1 }), 0.0123);
assert.equal(ai.costOf({ prompt_tokens: 2e6, completion_tokens: 1e6 }, { input: 0.3, output: 1.2 }), 1.8);
assert.equal(ai.costOf({ prompt_tokens: 10, completion_tokens: 10 }, { input: null, output: null }), null);
});
test('a model that refuses JSON mode and images is retried without them', async () => {
const provider = await fakeProvider([
() => [400, { error: { message: 'response_format is not supported by this model' } }],
() => [400, { error: { message: 'This model does not support image input' } }],
() => reply('{"tags": ["dragon", "articulated"], "description": "A flexi dragon."}'),
]);
try {
const result = await ai.describeModel(provider.config, MODEL, { image: 'data:image/png;base64,AAAA' });
assert.deepEqual(result.tags, ['dragon', 'articulated']);
assert.equal(result.imageRejected, true);
assert.equal(result.imageUsed, false);
assert.deepEqual(result.usage, { prompt: 1000, completion: 100 });
assert.ok(Math.abs(result.cost - 0.00042) < 1e-9);
assert.equal(provider.requests.length, 3);
assert.equal(provider.requests[0].path, '/v1/chat/completions');
assert.equal(provider.requests[0].auth, 'Bearer sk-test');
assert.ok(provider.requests[0].body.response_format);
assert.equal(provider.requests[1].body.response_format, undefined);
assert.equal(typeof provider.requests[2].body.messages[1].content, 'string'); // no image part
} finally {
await provider.close();
}
});
test('a rejected key is fatal for a run; a bad reply is not', async () => {
const provider = await fakeProvider([() => [401, { error: { message: 'Authentication Fails' } }]]);
try {
const err = await ai.describeModel(provider.config, MODEL).catch((e) => e);
assert.match(ai.fatalMessage(err), /API key was rejected/);
} finally {
await provider.close();
}
assert.equal(ai.fatalMessage(new Error('The AI reply wasn’t the JSON asked for')), null);
assert.match(ai.fatalMessage(new ai.AiError('nope', 402)), /out of credit/);
});