printhive/lib/aiRuns.js
dlawler489 848496a205 Sturdier AI replies: format example, more retries, and errors that show the reply
The Red panda kept failing with "The AI reply wasn't the JSON asked for", and
nothing showed what came back. Now:
- the prompt includes an example object with just the requested fields
  (DeepSeek's JSON mode can otherwise send back an empty reply)
- JSON is found inside code fences, notes, or braces after it
- a bad reply is asked again up to twice: with more room when it was cut off,
  and the last time without JSON mode; max_tokens starts at 1500
- the error says whether the reply was empty or cut off and quotes its start
- tokens spent on failed attempts count towards the run's cost

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 20:26:42 +10:00

281 lines
12 KiB
JavaScript

// AI runs: a queue of models handled a few at a time. Progress, tokens and cost
// live in the database, so a run can be watched, stopped and resumed, and a
// server restart pauses it instead of spending on its own.
const fs = require('fs');
const path = require('path');
const db = require('./db');
const store = require('./store');
const ai = require('./ai');
const fingerprint = require('./fingerprint');
const CONCURRENCY = 3;
const MAX_FAILURES_IN_A_ROW = 5;
const MAX_IMAGE_BYTES = 3 * 1024 * 1024;
const IMAGE_TYPES = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp', '.gif': 'image/gif' };
let resolveConfig = async () => ai.configFrom({});
let working = false;
let rerun = false;
const httpError = (message, status) => Object.assign(new Error(message), { status });
async function init(resolver) {
resolveConfig = resolver;
await db.query(
`UPDATE ai_runs SET status = 'paused', note = 'Paused when the server restarted. Resume to carry on.'
WHERE status = 'running'`);
}
// The model's thumbnail as a data URL, when there is one small enough to send.
function thumbnailDataUrl(model) {
if (!model.thumbnail_path) return null;
const abs = path.resolve(model.thumbnail_path);
const type = IMAGE_TYPES[path.extname(abs).toLowerCase()];
try {
if (!type || fs.statSync(abs).size > MAX_IMAGE_BYTES) return null;
return `data:${type};base64,${fs.readFileSync(abs).toString('base64')}`;
} catch {
return null;
}
}
// Names of the files inside a model's archives, as extra context.
async function entryNames(model) {
const { rows } = await db.query(
`SELECT e.path FROM archive_entries e JOIN files f ON f.id = e.file_id
WHERE f.model_id = $1 ORDER BY e.id LIMIT 60`, [model.id]);
return rows.map((r) => r.path.split('/').pop()).filter(Boolean);
}
// options: describe (tags + descriptions, default on), overwriteDescriptions,
// renameUnclear, suggestCollections, maxCost.
async function createRun(modelIds, options = {}) {
const config = await resolveConfig();
const problem = ai.missingConfig(config);
if (problem) throw httpError(problem, 400);
const tasks = {
describe: options.describe !== false,
renameUnclear: Boolean(options.renameUnclear),
suggestCollections: Boolean(options.suggestCollections),
};
if (!tasks.describe && !tasks.renameUnclear && !tasks.suggestCollections) {
throw httpError('Choose at least one thing for the AI to do', 400);
}
const ids = [...new Set((modelIds || []).map(Number).filter(Number.isInteger))];
const maxCost = Number(options.maxCost) > 0 ? Number(options.maxCost) : null;
const run = await db.transaction(async (client) => {
const { rows: [created] } = await client.query(
`INSERT INTO ai_runs (provider, model, options) VALUES ($1, $2, $3) RETURNING id`,
[config.label, config.model, {
...tasks,
overwriteDescriptions: tasks.describe && Boolean(options.overwriteDescriptions),
maxCost,
pricesKnown: config.prices.input != null || config.provider === 'openrouter',
}]);
const { rowCount } = await client.query(
`INSERT INTO ai_run_items (run_id, model_id)
SELECT $1, id FROM models WHERE id = ANY($2::int[]) AND deleted_at IS NULL`, [created.id, ids]);
if (!rowCount) throw httpError('No models to tag', 400);
await client.query('UPDATE ai_runs SET total = $2 WHERE id = $1', [created.id, rowCount]);
return created;
});
kick();
return getRun(run.id);
}
async function finish(runId, status, error = null) {
await db.query(
`UPDATE ai_runs SET status = $2, error = $3, finished_at = now() WHERE id = $1 AND status = 'running'`,
[runId, status, error]);
}
async function tagOne(runId, modelId, config, context) {
const { images, knownTags, collectionNames, tasks } = context;
const model = await store.getModel(modelId);
if (!model || model.deleted_at) {
await db.query(`UPDATE ai_run_items SET status = 'error', error = 'The model was deleted', updated_at = now()
WHERE run_id = $1 AND model_id = $2`, [runId, modelId]);
await db.query('UPDATE ai_runs SET failed = failed + 1 WHERE id = $1', [runId]);
return { ok: true };
}
try {
model.entryNames = await entryNames(model);
const result = await ai.describeModel(config, model, {
image: images ? thumbnailDataUrl(model) : null,
knownTags,
describe: tasks.describe,
suggestName: tasks.renameUnclear,
suggestCollections: tasks.suggestCollections,
collectionNames,
});
const name = tasks.renameUnclear ? result.name : null;
await store.applyAiResult(modelId, {
tags: result.tags,
description: result.description,
overwriteDescription: tasks.overwriteDescriptions,
name,
markTagged: tasks.describe,
});
const suggested = tasks.suggestCollections ? await store.suggestCollections(modelId, result.collections) : [];
await db.query(
`UPDATE ai_run_items SET status = 'done', tags = $3, error = NULL, renamed_from = $4, renamed_to = $5,
collections = $6, updated_at = now() WHERE run_id = $1 AND model_id = $2`,
[runId, modelId, tasks.describe ? result.tags : null, name ? model.name : null, name, suggested]);
await db.query(
`UPDATE ai_runs SET done = done + 1, prompt_tokens = prompt_tokens + $2,
completion_tokens = completion_tokens + $3, cost_usd = cost_usd + $4 WHERE id = $1`,
[runId, result.usage.prompt, result.usage.completion, result.cost || 0]);
return { ok: true, imageRejected: result.imageRejected, suggested: suggested.length > 0 };
} catch (err) {
// Failed attempts still cost tokens.
if (err.usage && (err.usage.prompt || err.usage.completion)) {
await db.query(
`UPDATE ai_runs SET prompt_tokens = prompt_tokens + $2, completion_tokens = completion_tokens + $3,
cost_usd = cost_usd + $4 WHERE id = $1`,
[runId, err.usage.prompt, err.usage.completion, err.cost || 0]);
}
const fatal = ai.fatalMessage(err);
if (fatal) return { ok: false, fatal }; // left pending, so resuming retries it
const message = err.message.slice(0, 300);
await db.query(`UPDATE ai_run_items SET status = 'error', error = $3, updated_at = now()
WHERE run_id = $1 AND model_id = $2`, [runId, modelId, message]);
await db.query('UPDATE ai_runs SET failed = failed + 1 WHERE id = $1', [runId]);
return { ok: false, error: message };
}
}
async function processRun(runId) {
const config = await resolveConfig();
const problem = ai.missingConfig(config);
if (problem) { await finish(runId, 'failed', problem); return; }
const knownTags = await store.topTags(150);
let images = config.sendImages;
let failuresInARow = 0;
let lastError = null;
let collectionNames = null;
for (;;) {
const { rows: [run] } = await db.query('SELECT status, options, cost_usd FROM ai_runs WHERE id = $1', [runId]);
if (!run || run.status !== 'running') return; // stopped, or deleted
const options = run.options || {};
// Runs from before these options existed only tagged and described.
const tasks = {
describe: options.describe !== false,
overwriteDescriptions: Boolean(options.overwriteDescriptions),
renameUnclear: Boolean(options.renameUnclear),
suggestCollections: Boolean(options.suggestCollections),
};
const { rows: items } = await db.query(
`SELECT model_id FROM ai_run_items WHERE run_id = $1 AND status = 'pending' ORDER BY model_id LIMIT $2`,
[runId, CONCURRENCY]);
if (!items.length) { await finish(runId, 'done'); return; }
// Checked between batches, so a run can go over by up to one batch.
if (options.maxCost && Number(run.cost_usd) >= options.maxCost) {
await finish(runId, 'stopped', `Stopped at your $${options.maxCost} spending limit`);
return;
}
// Existing collections and earlier ideas, so suggestions reuse the same names.
if (tasks.suggestCollections && collectionNames === null) collectionNames = await store.collectionNameHints();
const results = await Promise.all(items.map((item) =>
tagOne(runId, item.model_id, config, { images, knownTags, collectionNames: collectionNames || [], tasks })));
// New tags, descriptions and names change a model's search text, so refresh the index.
if (results.some((r) => r.ok)) fingerprint.kick();
if (results.some((r) => r.suggested)) collectionNames = null;
for (const r of results) {
if (r.imageRejected && images) {
images = false;
await db.query('UPDATE ai_runs SET note = $2 WHERE id = $1',
[runId, `${config.model} doesn't accept images, so models are judged from their names and files.`]);
}
if (r.fatal) { await finish(runId, 'failed', r.fatal); return; }
if (r.ok) failuresInARow = 0;
else { failuresInARow += 1; lastError = r.error; }
}
if (failuresInARow >= MAX_FAILURES_IN_A_ROW) {
await finish(runId, 'failed', `Stopped after ${failuresInARow} failures in a row. Last: ${lastError}`);
return;
}
}
}
// Works through running runs, one at a time. Safe to call often.
function kick() {
if (working) { rerun = true; return; }
working = true;
(async () => {
for (;;) {
const { rows: [run] } = await db.query(`SELECT id FROM ai_runs WHERE status = 'running' ORDER BY id LIMIT 1`);
if (!run) return;
try {
await processRun(run.id);
} catch (err) {
console.error(`AI run ${run.id} failed:`, err.message);
await finish(run.id, 'failed', err.message.slice(0, 300));
}
}
})()
.catch((err) => console.error('AI runs stopped:', err.message))
.finally(() => {
working = false;
if (rerun) { rerun = false; kick(); }
});
}
const shapeRun = (r) => r && ({
...r,
prompt_tokens: Number(r.prompt_tokens),
completion_tokens: Number(r.completion_tokens),
cost_usd: Number(r.cost_usd),
});
async function listRuns(limit = 15) {
const { rows } = await db.query('SELECT * FROM ai_runs ORDER BY id DESC LIMIT $1', [limit]);
return rows.map(shapeRun);
}
// A run with its latest results and errors, for watching it.
async function getRun(id) {
const { rows: [run] } = await db.query('SELECT * FROM ai_runs WHERE id = $1', [id]);
if (!run) return null;
const { rows: recent } = await db.query(
`SELECT i.model_id, m.name, i.status, i.tags, i.error, i.renamed_from, i.renamed_to, i.collections
FROM ai_run_items i LEFT JOIN models m ON m.id = i.model_id
WHERE i.run_id = $1 AND i.status <> 'pending' ORDER BY i.updated_at DESC LIMIT 12`, [id]);
return { ...shapeRun(run), recent };
}
async function stop(id) {
const { rowCount } = await db.query(
`UPDATE ai_runs SET status = 'stopped', finished_at = now(), note = NULL
WHERE id = $1 AND status IN ('running', 'paused')`, [id]);
return rowCount > 0;
}
// Carries on a paused, stopped or failed run, retrying models that errored.
async function resume(id) {
const resumed = await db.transaction(async (client) => {
const { rows: [run] } = await client.query(
`SELECT id FROM ai_runs WHERE id = $1 AND status IN ('paused', 'stopped', 'failed') FOR UPDATE`, [id]);
if (!run) return false;
const retried = await client.query(
`UPDATE ai_run_items SET status = 'pending', error = NULL WHERE run_id = $1 AND status = 'error'`, [id]);
const { rows: [left] } = await client.query(
`SELECT COUNT(*)::int AS n FROM ai_run_items WHERE run_id = $1 AND status = 'pending'`, [id]);
if (!left.n) return false;
await client.query(
`UPDATE ai_runs SET status = 'running', error = NULL, note = NULL, finished_at = NULL,
failed = GREATEST(failed - $2, 0) WHERE id = $1`, [id, retried.rowCount]);
return true;
});
if (resumed) kick();
return resumed;
}
async function hasActiveRun() {
const { rows } = await db.query(`SELECT 1 FROM ai_runs WHERE status = 'running' LIMIT 1`);
return rows.length > 0;
}
module.exports = { init, createRun, listRuns, getRun, stop, resume, kick, hasActiveRun, thumbnailDataUrl };