printhive/test/ai.test.js
dlawler489 9d7a978f03 AI collection suggestions, pick-what-to-do runs, and a retry for bad replies
- Runs can now do any mix of: tags & descriptions, clearer names, and
  collection suggestions. A run without tags doesn't mark models as tagged.
- Collection ideas reuse your collections and earlier ideas where they fit and
  wait on the AI page, grouped by name, until you create/add or dismiss them.
  Dismissed ideas aren't suggested for that model again; spelling variants
  group together under the capitalised spelling.
- A reply that isn't the JSON asked for is asked again once; both requests
  count towards the run's tokens and cost.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 17:52:41 +10:00

159 lines
8.2 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('a clearer name is suggested only when asked, and only when it changes', () => {
assert.match(ai.buildMessages(MODEL, { suggestName: true })[0].content, /"name"/);
assert.doesNotMatch(ai.buildMessages(MODEL)[0].content, /"name"/);
const reply = '{"name": "“Articulated Dragon Pair.3mf”", "tags": ["dragon"], "description": "Two dragons."}';
assert.equal(ai.parseReply(reply, { currentName: 'Dra g _v4' }).name, 'Articulated Dragon Pair');
assert.equal(ai.parseReply('{"name": "flexi dragon!", "tags": ["dragon"]}', { currentName: 'Flexi Dragon' }).name, null);
assert.equal(ai.parseReply(reply).name, null); // not asked for
});
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('collection ideas are asked for with the library’s collections, and tidied', () => {
const [system, user] = ai.buildMessages(MODEL, { describe: false, suggestCollections: true, collectionNames: ['Bookends', 'Halloween'] });
assert.match(system.content, /"collections"/);
assert.doesNotMatch(system.content, /"tags"/);
assert.match(user.content, /Existing collections in this library: Bookends, Halloween/);
assert.match(ai.buildMessages(MODEL, { suggestCollections: true })[1].content, /no collections yet/);
const reply = '{"collections": ["bookends", " Halloween Decor ", "Book-ends", "Dragons", "Toys"]}';
const r = ai.parseReply(reply, { describe: false, collections: true });
assert.deepEqual(r.collections, ['bookends', 'Halloween Decor', 'Dragons']);
assert.deepEqual(r.tags, []); // not asked for, so an answer without tags is fine
assert.deepEqual(ai.parseReply('{"tags": ["dragon"], "collections": ["Dragons"]}').collections, []);
});
test('a reply that isn’t JSON is asked again once, and both answers count towards cost', async () => {
const provider = await fakeProvider([
() => reply('Sure! Tags: dragon, toy'),
() => reply('{"tags": ["dragon"], "description": "A dragon."}'),
]);
try {
const result = await ai.describeModel(provider.config, MODEL);
assert.deepEqual(result.tags, ['dragon']);
assert.equal(result.replyRetried, true);
assert.deepEqual(result.usage, { prompt: 2000, completion: 200 });
assert.ok(Math.abs(result.cost - 0.00084) < 1e-9);
assert.equal(provider.requests.length, 2);
} finally {
await provider.close();
}
const stubborn = await fakeProvider([() => reply('no JSON here')]);
try {
await assert.rejects(ai.describeModel(stubborn.config, MODEL), /JSON/);
assert.equal(stubborn.requests.length, 2);
} finally {
await stubborn.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/);
});