/** * OpenAI: otazka nad nahranym souborem. * * Sluzba: https://api.openai.com/v1 * Endpoint: POST /responses * * Proc jiny endpoint nez `openai.chat`: soubor jako vstup umi az Responses API. * Chat Completions by prijalo jen text, takze by se obsah musel do dotazu * vlepit rucne - a u PDF nebo tabulky to nejde. * * Soubor se nejdriv nahraje krokem `openai.upload-file`, ktery vrati `fileId`. * Rozdeleni na dva kroky je zamer: jednoho souboru se casto pta vic dotazu * a nahravat ho pokazde znovu by stalo cas i penize. */ export const manifest = { id: 'openai.ask-about-file', name: 'Zeptat se na soubor', description: 'Položí otázku nad souborem nahraným do OpenAI. Zvládne dokument i obrázek, ' + 'takže se hodí na vytěžení faktury, smlouvy nebo fotky.', inputs: [ { id: 'fileId', label: 'ID souboru', type: 'string', required: true, hint: 'Vrací ho krok Nahrát soubor jako fileId.', }, { id: 'kind', label: 'Druh souboru', type: 'string', required: false, default: 'document', options: [ { value: 'document', label: 'Dokument (PDF, text, tabulka)' }, { value: 'image', label: 'Obrázek' }, ], hint: 'Obrázek se modelu předává jinak než dokument. Musí být nahraný ' + 's účelem Obrázek k analýze.', }, { id: 'prompt', label: 'Otázka', type: 'string', required: true, multiline: true, hint: 'Například: Vypiš číslo faktury, datum splatnosti a celkovou částku.', }, { id: 'model', label: 'Model', type: 'string', required: false, default: 'gpt-4o-mini', hint: 'Musí to být model, který umí číst soubory a obrázky.', }, { id: 'instructions', label: 'Instrukce pro model', type: 'string', required: false, multiline: true, }, { id: 'maxTokens', label: 'Strop na délku odpovědi', type: 'number', required: false, hint: 'V tokenech. Bez vyplnění rozhoduje model.', }, ], outputs: [ { id: 'text', label: 'Odpověď', type: 'string', required: true }, { id: 'model', label: 'Použitý model', type: 'string', required: true }, { id: 'inputTokens', label: 'Tokeny na vstupu', type: 'number', required: false }, { id: 'outputTokens', label: 'Tokeny na výstupu', type: 'number', required: false }, { id: 'totalTokens', label: 'Tokeny celkem', type: 'number', required: false }, ], // Cteni dokumentu trva dele nez bezny dotaz. timeoutMs: 90000, }; /** * Odpoved Responses API je strom, ne jedno pole. * * Prochazi se cely: model muze pred odpovedi vratit i jine polozky (napriklad * zaznam o uvaze) a brat naslepo prvni prvek by u nich vratilo prazdno. */ function answerText(output) { if (!Array.isArray(output)) return null; const parts = []; for (const item of output) { const content = item && item.content; if (!Array.isArray(content)) continue; for (const part of content) { if (part && part.type === 'output_text' && typeof part.text === 'string') { parts.push(part.text); } } } return parts.length > 0 ? parts.join('\n') : null; } export async function run(inputs, ctx) { const { pick, text, num, need } = ctx.util; const attachment = inputs.kind === 'image' ? { type: 'input_image', file_id: inputs.fileId } : { type: 'input_file', file_id: inputs.fileId }; const body = { model: inputs.model, input: [ { role: 'user', content: [attachment, { type: 'input_text', text: inputs.prompt }], }, ], }; if (inputs.instructions) body.instructions = inputs.instructions; if (inputs.maxTokens !== null) body.max_output_tokens = inputs.maxTokens; const { body: answer } = await ctx.http.post('/responses', body); if (text(pick(answer, 'status')) === 'incomplete') { ctx.log('Odpověď je neúplná, model narazil na strop délky.'); } const usage = pick(answer, 'usage') ?? {}; return { text: need(answerText(pick(answer, 'output')), 'odpověď modelu'), model: need(text(pick(answer, 'model')), 'název modelu'), inputTokens: num(pick(usage, 'input_tokens')), outputTokens: num(pick(usage, 'output_tokens')), totalTokens: num(pick(usage, 'total_tokens')), }; }