diff --git a/documentation/21-realne-sluzby.md b/documentation/21-realne-sluzby.md index 85088f2..6026ff5 100644 --- a/documentation/21-realne-sluzby.md +++ b/documentation/21-realne-sluzby.md @@ -18,23 +18,24 @@ aplikaci nemaji. ## Ktera sluzba stoji na cem -| Sluzba katalogu | Aplikace (`appId`) | Overeni (`verifyPath`) | -| ------------------ | ----------------------------- | ---------------------------------------------- | -| iDoklad | `idoklad` | `/account/agenda` | -| RAYNET CRM | `raynet` | `/company?limit=1` | -| CSOB (PSD2) | `csob` | `/accounts?size=1` | -| SAP Business One | `sap-bo` | `/api/system/info` | -| PPL CPL | `pplcplapi` | `/customer` | -| Microsoft 365 | `microsoft-365-service` | `/status` | -| Google Workspace | `google-service` | `/google/drive/files` | -| Google Analytics 4 | `analytics` | `/ga/admin/accountSummaries` | -| Search Console | `analytics` | `/gsc/sites` | -| Google Ads | `analytics` | `/googleads/customers:listAccessibleCustomers` | -| Sklik | `analytics` | `/sklik/limits` | -| Meta Ads | `meta` | `/ads/me/adaccounts` | -| Prepis hovoru | `audio-transcription` | nema, overi se jen dostupnost | -| E-mail | SMTP server firmy | prihlaseni na server, nic se neodesila | -| OpenAI | mimo nas, `api.openai.com/v1` | `/models` | +| Sluzba katalogu | Aplikace (`appId`) | Overeni (`verifyPath`) | +| ------------------- | ----------------------------- | ---------------------------------------------- | +| iDoklad | `idoklad` | `/account/agenda` | +| RAYNET CRM | `raynet` | `/company?limit=1` | +| CSOB (PSD2) | `csob` | `/accounts?size=1` | +| SAP Business One | `sap-bo` | `/api/system/info` | +| PPL CPL | `pplcplapi` | `/customer` | +| Microsoft 365 | `microsoft-365-service` | `/status` | +| Google Workspace | `google-service` | `/google/drive/files` | +| Google Analytics 4 | `analytics` | `/ga/admin/accountSummaries` | +| Search Console | `analytics` | `/gsc/sites` | +| Google Ads | `analytics` | `/googleads/customers:listAccessibleCustomers` | +| Sklik | `analytics` | `/sklik/limits` | +| Meta Ads | `meta` | `/ads/me/adaccounts` | +| Prepis hovoru | `audio-transcription` | nema, overi se jen dostupnost | +| E-mail | SMTP server firmy | prihlaseni na server, nic se neodesila | +| OpenAI | mimo nas, `api.openai.com/v1` | `/models` | +| AI zpracovani textu | mimo nas, `api.openai.com/v1` | `/models` | **Ctyri sluzby na jedne aplikaci.** GA4, Search Console, Google Ads a Sklik bezi v `analytics`, ale kazda ma **jine pristupove udaje** a jine ceny za @@ -47,9 +48,23 @@ overene nejsou - test, ktery projde i se spatnym klicem, by lhal. ## Sluzby, ktere aplikaci zatim nemaji -E-shop, WhatsApp, Facebook Messenger, Instagram, SMS, Slack, Voicebot -a AI zpracovani textu jsou v katalogu jako popis toho, co chceme umet. Konektor -u nich zalozit jde, ale overeni skonci chybou, protoze na te adrese nic nebezi. +E-shop, WhatsApp, Facebook Messenger, Instagram, SMS a Slack jsou v katalogu +jako popis toho, co chceme umet. Konektor u nich zalozit jde, ale nic za nim +neni. + +**Pozor: overeni u nich projde.** Proxy vraci na neznamou aplikaci `200` +s prazdnym telem, takze `GET /apps//health` vypada jako zdrava sluzba +i u aplikace, ktera neexistuje: + +```bash +curl -o /dev/null -w "%{http_code}" https://services.csbot.cz/apps/tohle-neexistuje-xyz/health +# 200 +``` + +Sluzba bez `verifyPath` se overuje prave pres `/health`, takze konektor rekne +"Sluzba odpovida" i tam, kde nic nebezi. Neni to chyba overovani, je to chyba +predpokladu: 2xx od proxy neznamena, ze za ni neco je. Napravit to znamena +vyzadovat, aby `/health` neco odpovedel, ne jen vratil kod. Meta Ads je **neco jineho nez Facebook Messenger a Instagram**: aplikace `meta` je nad Marketing API, tedy reklamy, a je jen pro cteni. Zpravy ze stranky @@ -130,9 +145,26 @@ server-to-server nehodi. App secret je povinny tam, kde ma aplikace zapnute Deepgram i OpenAI klic. Obe sluzby bezi paralelne a treti volani jejich vysledky slucuje, takze bez obou klicu to nefunguje. -### OpenAI +### OpenAI a AI zpracovani textu -Jen API klic. Zadava se **holy**, slovo `Bearer` dopise portal - viz nize. +Obe stoji na temze API a obe chteji jen API klic. Zadava se **holy**, slovo +`Bearer` dopise portal - viz nize. + +Rozdil je v tom, na co se ptaji. **OpenAI** posila volny dotaz a vraci volny +text; hodi se, kdyz clovek vi, co chce napsat. **AI zpracovani textu** ma +hotove ulohy s pevnym vystupem: + +| Operace | Vraci | +| -------------------- | ----------------------------------------------- | +| Zaradit do kategorie | `category`, `confidence` a jestli je ze seznamu | +| Vytahnout udaje | objekt s poli, ktera si popsal uzivatel | +| Vygenerovat text | text plus pocet slov | + +Pevny vystup je ten duvod, proc to neni jedna sluzba: na `category` +a `confidence` jde ve strome navazat podminkou, na volny text ne. + +Model se u teto sluzby bere z konektoru (`ctx.config.model`), aby se nemusel +vyplnovat u kazdeho kroku znovu. ## OpenAI: sluzba, ktera nebezi u nas @@ -328,6 +360,9 @@ sluzeb: OpenAI zvladne stovky megabajtu, nas zaznam behu ne. | `openai.ask-about-file` | otazka nad souborem pres Responses API | | `openai.transcribe-audio` | prepis zvuku | | `openai.list-models` | co ucet umi | +| `ai-text.classify` | zarazeni s jistotou, hlida vycet kategorii | +| `ai-text.extract` | vystup podle poli, ktera popsal uzivatel | +| `ai-text.generate` | text s omezenim delky, tonu a jazyka | E-mail v tabulce neni schvalne: neni to skript, ale vnitrni krok, viz vyse. diff --git a/documentation/99-zmeny.md b/documentation/99-zmeny.md index c1269c4..2429243 100644 --- a/documentation/99-zmeny.md +++ b/documentation/99-zmeny.md @@ -2,6 +2,36 @@ Nejnovejsi nahore. +## 2026-08-28 - AI zpracovani textu funguje + +Sluzba byla v katalogu bez pristupovych udaju a s `appId: 'ai-text'`, tedy +mirila na aplikaci, ktera neexistuje. Konektor nemel co vyplnit a krok nemel +co vykonat. + +### Zmeneno + +- **Sluzba ma API klic a skutecnou adresu.** Stoji na `api.openai.com/v1` + stejne jako OpenAI, overeni je `GET /models`. Model se bere z konektoru + (`ctx.config.model`), aby se nevyplnoval u kazdeho kroku znovu. + +### Pridano + +- **Tri skripty**: `ai-text.classify`, `ai-text.extract`, `ai-text.generate`. + +Proc to neni jedna sluzba s OpenAI: tam se posila volny dotaz a vraci volny +text. Tady je uloha pevna a **vystup taky** - `category` a `confidence`, +objekt s popsanymi poli, text s poctem slov. Prave na to jde ve strome navazat +podminkou, na volny text ne. + +### Zjisteno u toho + +- **Overeni konektoru projde i u sluzby, ktera neexistuje.** Proxy vraci na + neznamou aplikaci `200` s prazdnym telem, a sluzba bez `verifyPath` se + overuje prave pres `/health`. Konektor tedy rekne "Sluzba odpovida" i tam, + kde nic nebezi. Tyka se to E-shopu, WhatsAppu, Facebooku, Instagramu, SMS + a Slacku. Zapsane v [21-realne-sluzby.md](21-realne-sluzby.md), zatim + neopravene. + ## 2026-08-26 - jazyky, skutecny snimek v heru a opravene titulky ### Pridano diff --git a/scripts/ai-text.classify.js b/scripts/ai-text.classify.js new file mode 100644 index 0000000..eb1f7af --- /dev/null +++ b/scripts/ai-text.classify.js @@ -0,0 +1,106 @@ +/** + * AI zpracovani textu: zarazeni do kategorie. + * + * Sluzba: https://api.openai.com/v1 (nebezi u nas, adresa je v katalogu) + * Endpoint: POST /chat/completions + * + * Rozdil proti sluzbe OpenAI: tam se posila volny dotaz a vraci se volny text. + * Tady je uloha pevna a **vystup taky** - `category` a `confidence`. Prave na + * to se da ve strome navazat podminkou, coz u volneho textu nejde. + * + * Model se bere z konektoru (`ctx.config.model`), aby se nemusel vyplnovat + * u kazdeho kroku znovu. + */ + +export const manifest = { + id: 'ai-text.classify', + name: 'Zařadit do kategorie', + description: + 'Rozhodne, do které z uvedených kategorií text patří. Vrací kategorii ' + + 'a jistotu, takže se za krokem dá větvit podle toho, jak si je model jistý.', + + inputs: [ + { + id: 'categories', + label: 'Seznam kategorií', + type: 'string', + required: true, + hint: 'Oddělte čárkou, například: porucha, dotaz, reklamace.', + }, + { + id: 'text', + label: 'Text', + type: 'string', + required: true, + multiline: true, + hint: 'Co se má zařadit. Typicky předmět a tělo ticketu.', + }, + { + id: 'instructions', + label: 'Doplňující pravidla', + type: 'string', + required: false, + multiline: true, + hint: 'Například: reklamací se rozumí jen stížnost na už dodané zboží.', + }, + ], + + outputs: [ + { id: 'category', label: 'Kategorie', type: 'string', required: true }, + { id: 'confidence', label: 'Jistota', type: 'number', required: true }, + { id: 'matched', label: 'Kategorie ze seznamu', type: 'boolean', required: true }, + ], + + timeoutMs: 45000, +}; + +export async function run(inputs, ctx) { + const { pick, first, text, num, need } = ctx.util; + + const categories = inputs.categories + .split(',') + .map((item) => item.trim()) + .filter(Boolean); + if (categories.length < 2) ctx.fail('Uveďte aspoň dvě kategorie, jinak není z čeho vybírat.'); + + const system = + 'Jsi klasifikátor. Vrátíš POUZE platný JSON ve tvaru ' + + '{"category": "...", "confidence": 0.0}. Pole category musí být přesně jedna ' + + `z těchto hodnot: ${categories.join(', ')}. Confidence je číslo od 0 do 1.` + + (inputs.instructions ? ` Další pravidla: ${inputs.instructions}` : ''); + + const { body } = await ctx.http.post('/chat/completions', { + model: ctx.config.model || 'gpt-4o-mini', + messages: [ + { role: 'system', content: system }, + { role: 'user', content: inputs.text }, + ], + response_format: { type: 'json_object' }, + }); + + const content = text(pick(pick(first(pick(body, 'choices')), 'message'), 'content')); + if (!content) ctx.fail('Model nevrátil odpověď.', body); + + let parsed; + try { + parsed = JSON.parse(content); + } catch { + ctx.fail(`Model nevrátil platný JSON: ${content}`); + } + + const category = need(text(pick(parsed, 'category')), 'kategorii'); + + /* + * Model obcas vrati kategorii, ktera v seznamu neni, i kdyz ji ma zakazanou. + * Neni to duvod krok shodit - vysledek se vrati a `matched` rekne, ze je + * mimo seznam. Strom se pak muze rozhodnout, jestli mu verit. + */ + const matched = categories.some((item) => item.toLowerCase() === category.toLowerCase()); + if (!matched) ctx.log(`Model vrátil "${category}", což v seznamu není.`); + + return { + category, + confidence: num(pick(parsed, 'confidence'), 0) ?? 0, + matched, + }; +} diff --git a/scripts/ai-text.extract.js b/scripts/ai-text.extract.js new file mode 100644 index 0000000..48a52e9 --- /dev/null +++ b/scripts/ai-text.extract.js @@ -0,0 +1,114 @@ +/** + * AI zpracovani textu: vytazeni udaju. + * + * Sluzba: https://api.openai.com/v1 + * Endpoint: POST /chat/completions + * + * Vzor **vystupu, ktery si urcuje uzivatel**. Pole se popisou v kroku stromu + * a model je vrati jako objekt. Manifest je predem znat nemuze, proto se vraci + * jeden parametr typu `object` a vedle nej priznak, co se opravdu naslo. + * + * Nenalezeny udaj je `null`, ne vymysleny. Model, ktery hada, je horsi nez + * model, ktery rekne "nevim" - vymyslene IC se poznat neda. + */ + +export const manifest = { + id: 'ai-text.extract', + name: 'Vytáhnout údaje', + description: + 'Z volného textu získá strukturovaná data podle polí, která popíšete. ' + + 'Co v textu není, vrátí prázdné, nedomýšlí si.', + + inputs: [ + { + id: 'fields', + label: 'Pole k vytažení', + type: 'string', + required: true, + multiline: true, + hint: + 'Jedno pole na řádek ve tvaru nazev: popis. Například:\n' + + 'cisloObjednavky: číslo objednávky, obvykle ve tvaru OBJ-1234\n' + + 'castka: celková částka v korunách jako číslo', + }, + { + id: 'text', + label: 'Text', + type: 'string', + required: true, + multiline: true, + }, + ], + + outputs: [ + { id: 'data', label: 'Vytažené údaje', type: 'object', required: true }, + { id: 'found', label: 'Kolik polí se našlo', type: 'number', required: true }, + { id: 'missing', label: 'Co se nenašlo', type: 'string', required: false }, + { id: 'complete', label: 'Našlo se všechno', type: 'boolean', required: true }, + ], + + timeoutMs: 45000, +}; + +export async function run(inputs, ctx) { + const { pick, first, text } = ctx.util; + + const fields = inputs.fields + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => { + const at = line.indexOf(':'); + return at === -1 + ? { name: line, description: '' } + : { name: line.slice(0, at).trim(), description: line.slice(at + 1).trim() }; + }) + .filter((field) => field.name !== ''); + + if (fields.length === 0) ctx.fail('Nepopsali jste žádné pole, takže není co vytáhnout.'); + + const wanted = fields.map((f) => `- ${f.name}${f.description ? `: ${f.description}` : ''}`); + + const system = + 'Vytáhni z textu uvedená pole a vrať POUZE platný JSON objekt s přesně těmito klíči:\n' + + `${wanted.join('\n')}\n` + + 'Co v textu není, vrať jako null. Nic si nedomýšlej a nic nedopočítávej.'; + + const { body } = await ctx.http.post('/chat/completions', { + model: ctx.config.model || 'gpt-4o-mini', + messages: [ + { role: 'system', content: system }, + { role: 'user', content: inputs.text }, + ], + response_format: { type: 'json_object' }, + }); + + const content = text(pick(pick(first(pick(body, 'choices')), 'message'), 'content')); + if (!content) ctx.fail('Model nevrátil odpověď.', body); + + let parsed; + try { + parsed = JSON.parse(content); + } catch { + ctx.fail(`Model nevrátil platný JSON: ${content}`); + } + + // Vysledek se sklada podle **popsanych poli**, ne podle toho, co model poslal. + // Kdyby si pridal klic navic, strom by o nem nevedel a jen by mu zabiral misto. + const data = {}; + const missing = []; + for (const field of fields) { + const value = parsed[field.name]; + data[field.name] = value === undefined ? null : value; + if (value === undefined || value === null || value === '') missing.push(field.name); + } + + if (missing.length > 0) ctx.log(`Nenašlo se: ${missing.join(', ')}.`); + + return { + data, + found: fields.length - missing.length, + missing: missing.length > 0 ? missing.join(', ') : null, + complete: missing.length === 0, + }; +} diff --git a/scripts/ai-text.generate.js b/scripts/ai-text.generate.js new file mode 100644 index 0000000..3ff9c2a --- /dev/null +++ b/scripts/ai-text.generate.js @@ -0,0 +1,114 @@ +/** + * AI zpracovani textu: napsani textu podle instrukce. + * + * Sluzba: https://api.openai.com/v1 + * Endpoint: POST /chat/completions + * + * Typicky se z toho stava odpoved zakaznikovi nebo shrnuti do ticketu, takze + * krok umi omezit delku a jazyk. Bez toho model odpovi, jak se mu zachce, + * a odpoved se do e-mailu nebo do komentare nevejde. + */ + +export const manifest = { + id: 'ai-text.generate', + name: 'Vygenerovat text', + description: + 'Napíše text podle instrukce a vstupních dat. Hodí se na odpověď ' + + 'zákazníkovi, shrnutí do ticketu nebo popis incidentu.', + + inputs: [ + { + id: 'instruction', + label: 'Co má napsat', + type: 'string', + required: true, + multiline: true, + hint: 'Například: Napiš zdvořilou odpověď zákazníkovi, že reklamaci přebíráme.', + }, + { + id: 'input', + label: 'Vstupní data', + type: 'string', + required: false, + multiline: true, + hint: 'Text, ze kterého má vycházet. Typicky obsah ticketu.', + }, + { + id: 'tone', + label: 'Tón', + type: 'string', + required: false, + options: [ + { value: 'vecny', label: 'Věcný' }, + { value: 'vstricny', label: 'Vstřícný' }, + { value: 'strucny', label: 'Stručný' }, + { value: 'omluvny', label: 'Omluvný' }, + ], + }, + { + id: 'language', + label: 'Jazyk', + type: 'string', + required: false, + default: 'čeština', + hint: 'Bez vyplnění čeština.', + }, + { + id: 'maxWords', + label: 'Nejvýše kolik slov', + type: 'number', + required: false, + hint: 'Bez vyplnění rozhoduje model. Do e-mailu se hodí kolem 120.', + }, + ], + + outputs: [ + { id: 'text', label: 'Text', type: 'string', required: true }, + { id: 'words', label: 'Počet slov', type: 'number', required: true }, + ], + + timeoutMs: 60000, +}; + +const tones = { + vecny: 'Piš věcně a bez zdobení.', + vstricny: 'Piš vstřícně a lidsky, ale ne přehnaně.', + strucny: 'Piš co nejstručněji, ideálně pár vět.', + omluvny: 'Omluv se za potíže, ale neslibuj nic, co není v zadání.', +}; + +export async function run(inputs, ctx) { + const { pick, first, text, need } = ctx.util; + + const rules = [ + `Piš v jazyce: ${inputs.language}.`, + inputs.tone ? tones[inputs.tone] : '', + inputs.maxWords !== null ? `Vejdi se do ${inputs.maxWords} slov.` : '', + // Bez tohohle model rad pripoji "Jiste, tady je vas text:" a to jde + // rovnou zakaznikovi do e-mailu. + 'Vrať pouze výsledný text, žádný úvod ani komentář k zadání.', + ].filter(Boolean); + + const { body } = await ctx.http.post('/chat/completions', { + model: ctx.config.model || 'gpt-4o-mini', + messages: [ + { role: 'system', content: rules.join(' ') }, + { + role: 'user', + content: inputs.input + ? `${inputs.instruction}\n\nVstupní data:\n${inputs.input}` + : inputs.instruction, + }, + ], + }); + + const content = need( + text(pick(pick(first(pick(body, 'choices')), 'message'), 'content')), + 'vygenerovaný text', + ); + + return { + text: content, + words: content.split(/\s+/).filter(Boolean).length, + }; +} diff --git a/src/data/services.ts b/src/data/services.ts index 96f28ae..b6ada50 100644 --- a/src/data/services.ts +++ b/src/data/services.ts @@ -2314,9 +2314,47 @@ export const services: Service[] = [ icon: 'Sparkles', status: 'available', general: false, - appId: 'ai-text', + /* + * Nebezi u nas, stejne jako OpenAI. Rozdil proti sluzbe OpenAI je v tom, + * na co se pta: tam se posila volny dotaz, tady jsou hotove ulohy + * (zaradit, vytahnout, napsat) s pevnym vystupem, na ktery se da ve strome + * navazat podminkou. + */ + appId: null, + baseUrl: 'https://api.openai.com/v1', visibility: { mode: 'everyone', tenantIds: [], userIds: [] }, - credentials: [], + credentials: [ + { + id: 'apiKey', + label: 'API klíč', + target: 'header', + name: 'Authorization', + prefix: 'Bearer ', + required: true, + secret: true, + hint: 'platform.openai.com, sekce API keys. Vložte jen klíč, slovo Bearer doplní portál.', + }, + { + id: 'model', + label: 'Model', + target: 'config', + name: 'model', + required: false, + secret: false, + hint: 'Bez vyplnění gpt-4o-mini. Platí pro všechny operace této služby.', + }, + { + id: 'organization', + label: 'ID organizace', + target: 'header', + name: 'OpenAI-Organization', + required: false, + secret: false, + hint: 'Jen když má účet víc organizací a útrata se má počítat konkrétní z nich.', + }, + ], + // Seznam modelu: nejlevnejsi cteci volani, ktere vyzaduje platny klic. + verifyPath: '/models', triggers: [], actions: [ {