Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 48s
Forgejo Docker Build / Root app tests (push) Successful in 51s
Forgejo Android APK / Build signed APK (push) Successful in 2m16s
Forgejo Docker Build / Build Docker image (push) Successful in 20s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
The image job key was the request-body hash plus the figure's position — slide index for a deck, reply index for a document. Two generations from the same form produce the same body hash, so figure 4 of the second generation collided with figure 4 of the first on the unique (owner, workflow, idempotency_key). The constraint handed back the existing job, and the new deck displayed the old deck's artwork. The decks are not even the same length, so the reused picture could land on a slide about something else entirely. Keyed on what is being drawn now: the body hash stays, so submitting the identical request twice still dedupes rather than billing twice, and a hash of the prompt (plus layout and shape) is what makes two different pictures two different jobs. Same fix in deckBuild and resourceImages. Also split fileLog out of logger. logger requires the database at module load, so importing it to record a diagnostic pulls in a connection pool — wrong on its own terms, and it hung the whole test suite when imageTool started logging its refusals: a unit test that never touches a database inherited an open pool handle and never exited. logger.file now delegates to fileLog, so there is still one implementation of where a line goes and how it is redacted. With that in place, every image-tool refusal is recorded durably. There are five of them, they want five different fixes, and until now none of them left any trace once the container was replaced. Verified against a mutation: restoring the index-based key fails two of the four collision tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
59 lines
4.3 KiB
JavaScript
59 lines
4.3 KiB
JavaScript
const { service, args, requestKey, failure } = require('./generatedImages');
|
|
// Every refusal below ends the turn with no picture and, until now, no record of
|
|
// which refusal it was. There are five of them and they want five different
|
|
// fixes; console output dies with the container, so by the time anyone asks
|
|
// "why was there no image" the answer has already been deleted.
|
|
// fileLog, not logger: logger requires the database at module load, so importing
|
|
// it here just to record a refusal would pull a connection pool into every test
|
|
// that touches this module.
|
|
const fileLog = require('./fileLog');
|
|
|
|
function note(level, message, detail) {
|
|
fileLog.write(level, '[image-tool] ' + message, detail || {});
|
|
}
|
|
|
|
// Refuse loudly. The message the caller sees stays exactly as it was.
|
|
function refuse(status, message, detail) {
|
|
note('warn', message, detail);
|
|
return failure(status, message);
|
|
}
|
|
const tools = [{ type: 'function', function: { name: 'generate_image',
|
|
description: 'Generate one medical educational image. Use it when the user\'s latest message asks for a picture, or for a change to one you just made, and not otherwise. Supply a self-contained prompt grounded in the current clinical content. The server selects model, credentials and workflow. Return the educational answer separately; never put image markup or invented asset URLs in it.',
|
|
parameters: { type: 'object', additionalProperties: false, properties: {
|
|
prompt: { type: 'string', minLength: 1, maxLength: 32000 },
|
|
layout: { type: 'string', enum: ['auto', 'portrait', 'landscape', 'square'] }
|
|
}, required: ['prompt'] } } }];
|
|
async function dispatch(ai, { owner, workflow, body, messages, options, callAI, images, imageContext, imageModel }) {
|
|
if (!ai.toolCalls || !ai.toolCalls.length) return ai;
|
|
if (ai.toolCalls.length !== 1) throw refuse(400, 'Only one image tool invocation is permitted per request', { calls: ai.toolCalls.length });
|
|
const call = ai.toolCalls[0];
|
|
if (!call || call.type !== 'function' || call.function?.name !== 'generate_image' || typeof call.id !== 'string' || call.id.length > 200 ||
|
|
typeof call.function.arguments !== 'string' || call.function.arguments.length > 40000) throw refuse(400, 'Invalid image tool call', {
|
|
type: call && call.type, name: call && call.function && call.function.name,
|
|
argType: typeof (call && call.function && call.function.arguments) });
|
|
let input;
|
|
try { input = JSON.parse(call.function.arguments); } catch (_) { throw refuse(400, 'Image tool arguments must be valid JSON', { chars: call.function.arguments.length }); }
|
|
try { args(input); }
|
|
catch (e) { throw refuse(400, 'Image tool arguments were rejected', { reason: e && e.message }); }
|
|
if (!imageContext) throw refuse(400, 'Validated original image request and context are required for image tools', { workflow: workflow });
|
|
images = images || service();
|
|
const job = await images.enqueue(owner, workflow, input, 'tool:' + requestKey(body), true, imageContext, imageModel);
|
|
note('info', 'queued', { jobId: job.jobId, workflow: workflow, model: job.model,
|
|
hadText: Boolean(String(ai.content || '').trim()) });
|
|
let completed = ai;
|
|
if (!String(ai.content || '').trim()) {
|
|
// One FIRST-body continuation only. Existing body/citations are never sent for rewriting.
|
|
completed = await callAI(messages.concat([
|
|
{ role: 'assistant', content: null, tool_calls: [call] },
|
|
{ role: 'tool', tool_call_id: call.id, content: JSON.stringify({ jobId: job.jobId, status: job.status, instruction: 'Image job queued. Now return the first educational body in the originally requested format. Do not claim the image is complete or insert image URLs.' }) }
|
|
]), { ...options, tools, toolChoice: 'none', maxTokens: Math.min(options.maxTokens || 4000, 8000) });
|
|
if (completed.toolCalls?.length || !String(completed.content || '').trim()) {
|
|
// The job exists; only the words are missing. Recorded with the job id so
|
|
// the picture can be found rather than paid for and lost.
|
|
throw refuse(502, 'Image job queued but the model did not return educational content. The job is available in image history.',
|
|
{ jobId: job.jobId, toolCalls: (completed.toolCalls || []).length, chars: String(completed.content || '').length });
|
|
}
|
|
}
|
|
return { ...completed, imageJobs: [job], imageToolHandled: true };
|
|
}
|
|
module.exports = { tools, dispatch };
|