import { z } from "zod"; import type { AppConfig } from "../config.js"; import { groqChat, groqConfigured, type GroqChatOptions } from "./groq.js"; /** * Asystent korekty kadru (Shot Tuner) — Groq, model tekstowy. * * Art Director ujęcia: operator rozmawia po polsku o aktywnym kadrze; * LLM tłumaczy uwagi na roboczą nakładkę (positive/negative) JEDNEGO * zadania generacji i aktualizuje ją w kolejnych turach rozmowy. * Nakładka żyje wyłącznie w jobs.payload.ephemeral_overrides — baza * (products, product_facts, shot_refs) i receptury (recipes.ts) są * nienaruszalne; po akceptacji/odrzuceniu kadru kolejne generacje * startują z czystego szablonu bazowego. */ /** Domyślny model tekstowy tunera — nadpisywalny przez VILMAL_GROQ_TUNER_MODEL (cfg.groqTunerModel). */ export const SHOT_TUNER_MODEL = "openai/gpt-oss-120b"; /** LLM czasem zwraca listę fraz zamiast jednego stringa — sklejamy przecinkami. */ const strOrList = z .union([z.string(), z.array(z.string())]) .transform((v) => (Array.isArray(v) ? v.join(", ") : v)); /** Nakładka na jedno zadanie — nigdy nie trafia do konfiguracji mebla. */ export const ShotOverrides = z.object({ /** tokeny/frazy PO ANGIELSKU dopisywane na koniec promptu */ prompt_additions: strOrList.pipe(z.string().max(1500)).default(""), /** tokeny PO ANGIELSKU dokładane do klauzul zakazów (np. "blanket, throw, patchwork") */ negative_prompt_additions: strOrList.pipe(z.string().max(1500)).default(""), /** ID referencji pomocniczych do wykluczenia tylko z tego przebiegu */ disabled_ref_ids: z.array(z.string().max(64)).max(16).default([]), /** zwięzłe wyjaśnienie po polsku, co zmieniono i dlaczego */ explanation_pl: strOrList.pipe(z.string().max(1200)).default(""), }); export type ShotOverridesT = z.infer; /** Kontekst ujęcia dla LLM — fakty i lista referencji, które pojadą do providera. */ export interface ShotContext { productName: string; role: string; roleLabel: string; fabricName: string | null; fabricPrompt: string; legsNote: string; /** aktualny prompt bazowy receptury — LLM dopisuje korektę, nie przepisuje całości */ basePrompt: string; /** wersje mebla w tym ujęciu (od najnowszej) — kontekst dla asystenta */ versions?: Array<{ version: number; status: string; note: string | null }>; refs: Array<{ id: string; kind: "primary" | "fabric" | "legs" | "supporting"; label: string }>; } const SYSTEM_PROMPT = `Jesteś technicznym dyrektorem fotografii meblarskiej AI w studiu Vilmax. Operator zgłasza po polsku uwagi do nieudanego kadru wygenerowanego mebla. Twoja rola: przełożyć potoczne uwagi tapicerskie i meblarskie na konkretną, minimalną korektę JEDNEGO przebiegu generacji. Odpowiadasz WYŁĄCZNIE poprawnym JSON-em (bez markdown, bez komentarza) o polach: - "prompt_additions": krótkie klauzule PO ANGIELSKU dopisywane na koniec promptu (np. "camera at slightly lower hip-level angle", "seat free of any blanket"). Pisz tylko to, co wynika z uwag operatora. - "negative_prompt_additions": tokeny/frazy PO ANGIELSKU do zakazów (np. "blanket, throw, patchwork upholstery, extra pillow"). - "disabled_ref_ids": lista ID referencji POMOCNICZYCH do wykluczenia z tego przebiegu — tylko gdy uwagi wskazują, że dodatkowa referencja szkodzi (np. próbka tkaniny narzuca zły wzór). NIGDY nie wyłączaj referencji kind="primary" — to kotwica geometrii mebla. - "explanation_pl": 1-3 zdania po polsku — co zmieniasz i dlaczego. Konkretnie, bez superlatywów. Zasady: - Rozmowa jest iteracyjna: "biezacy_szkic_korekty" to dotychczasowa nakładka roboczego promptu. Zwracaj ZAWSZE pełną, zaktualizowaną nakładkę (zachowaj wcześniejsze, nadal aktualne klauzule, dodaj nowe, usuń te, które operator cofnął) — nie tylko różnicę. - "explanation_pl" to Twoja odpowiedź w rozmowie: co zmieniłeś w prompcie (positive/negative) i czego się spodziewać; gdy uwaga jest niejasna lub wymaga faktu, którego nie znasz (np. numer tkaniny), zapytaj zamiast zgadywać. - Nie zmieniaj tożsamości mebla: bryła, strona narożnika, liczba poduszek, stan mechanizmu, tkanina i nogi pochodzą z referencji — nie dopisuj wymyślonych cech. - Korekta ma być oszczędna: jedna uwaga → minimum skutecznych zmian. Nie wymieniaj oczywistości ani nie powtarzaj promptu bazowego. - "usunięcie" elementu realizuj przez negative_prompt_additions (zakaz), nie przez nakaz w prompt_additions.`; /** Tura rozmowy operatora z asystentem. */ export const ChatTurn = z.object({ role: z.enum(["user", "assistant"]), content: z.string().max(2000) }); export type ChatTurnT = z.infer; export async function tuneShotPrompt( cfg: AppConfig, context: ShotContext, userFeedback: string, session: { history?: ChatTurnT[]; draft?: ShotOverridesT | null } = {} ): Promise { if (!groqConfigured(cfg)) { throw new Error("Brak GROQ_API_KEY — asystent korekty nieaktywny."); } // gpt-oss to model rozumujący — reasoning_effort:"low" + zapas tokenów, // inaczej budżet zjada "myślenie" i Groq zwraca pustą odpowiedź. const opts: GroqChatOptions = { model: cfg.groqTunerModel || SHOT_TUNER_MODEL, json: true, maxTokens: 3000, temperature: 0.2, reasoningEffort: "low", messages: [ { role: "system", content: SYSTEM_PROMPT }, { role: "user", content: JSON.stringify({ produkt: context.productName, ujecie: { rola: context.role, etykieta: context.roleLabel }, tkanina: context.fabricName, klauzula_tkaniny: context.fabricPrompt || null, klauzula_nozek: context.legsNote || null, prompt_bazowy: context.basePrompt, referencje: context.refs, wersje_ujecia: context.versions ?? [], biezacy_szkic_korekty: session.draft ? { prompt_additions: session.draft.prompt_additions, negative_prompt_additions: session.draft.negative_prompt_additions, disabled_ref_ids: session.draft.disabled_ref_ids } : null, historia_rozmowy: (session.history ?? []).slice(-12), uwagi_operatora: userFeedback, }), }, ], }; // Jedna ponowna próba przy przejściowym błędzie Groq albo uszkodzonym JSON. const attempt = async () => ShotOverrides.parse(JSON.parse((await groqChat(cfg, opts)).text)); let parsed: ShotOverridesT; try { parsed = await attempt(); } catch { parsed = await attempt(); } // Sanityzacja: wyłączyć można tylko realne referencje tego ujęcia, // nigdy primary (kotwica geometrii) — nawet gdy model jej zażąda. const allowed = new Set(context.refs.filter((r) => r.kind !== "primary").map((r) => r.id)); parsed.disabled_ref_ids = parsed.disabled_ref_ids.filter((id) => allowed.has(id)); return parsed; }