import fs from "node:fs"; import path from "node:path"; import crypto from "node:crypto"; import { z } from "zod"; import { type Db, nowIso, tx } from "../db.js"; import type { AppConfig } from "../config.js"; import type { SessionUser } from "../auth.js"; import { newId } from "./ids.js"; import { audit } from "./audit.js"; import { getFact, getFacts, setFact } from "./facts.js"; import { approvedAssetForRole, getAsset, listAssets } from "./assets.js"; import { ensureVariantMatrix, listVariants, listShades, type FabricRow } from "./fabric.js"; import { contentHash, OFFER_ROLES, buildMediaReview } from "./mediaReview.js"; import { buildOfferContent, listContentRevisions, getContentRevision, saveContentRevision, previewContentRevision, ContentConflict, CONTENT_CHANNELS } from "./offerContent.js"; import { collectCardData, buildProductCard } from "./productCard.js"; import { buildRecolor, masterMaskState } from "./recolor.js"; import { latestVerdictsForProduct, reviewAsset, MACIUS_PROMPT_VERSION } from "./macius.js"; import { resolveShotRefs } from "./shotRefs.js"; import { enqueueJob, getJob, finishJob, deferJob } from "./jobs.js"; import { ORCHESTRATOR_VERSION, actionSchema, prepareOperationSchema, operationDecisionSchema, previewSchema, operationSchema, operationResultSchema, operationSummarySchema, inspectionSchema, type OrchestratorAction, type ActionRequest, type Operation, type OperationPreview, type OperationResult, type Recommendation, type ProductInspection, type DirectiveResult, } from "./orchestrationContracts.js"; export class OrchestratorError extends Error { constructor(message: string, public readonly statusCode = 409) { super(message); } } const labels = { set_fact: ["Zapisz korektę faktu", "fakty"], confirm_facts: ["Potwierdź wskazane fakty", "fakty"], bind_fabric: ["Utwórz brakujące warianty tkaniny", "warianty"], compose_offer: ["Przygotuj treści kanałowe", "oferta"], recolor_missing: ["Uzupełnij brakujące media", "warianty"], review_media: ["Zleć kontrolę Maciusia", "warianty"], render_card: ["Przygotuj nową kartę PDF", "dokumenty"], generate_shot: ["Przygotuj generację ujęcia", "galeria"], export_base: ["Sprawdź bramkę wysyłki do Base", "oferta"], } as const; export const actionRegistry = Object.entries(labels).map(([kind, [title, stage]]) => ({ kind: kind as OrchestratorAction["kind"], title, stage, mode: ["recolor_missing", "review_media", "render_card"].includes(kind) ? "job" : "transaction", external: ["generate_shot", "export_base"].includes(kind), })); const dependencySchema = z.object({ kind: z.enum(["product", "geometry", "fact", "facts", "variants", "fabric", "asset", "master", "slot", "source", "shot_refs", "content", "file_asset", "file_source"]), key: z.string(), sha256: z.string().regex(/^[a-f0-9]{64}$/), }); type Dependency = z.infer; const planDataSchema = z.object({ dependencies: z.array(dependencySchema), tasks: z.array(z.object({ variantId: z.string(), role: z.enum(OFFER_ROLES) })).default([]), entries: z.array(z.object({ id: z.string(), sha256: z.string() })).default([]), }); type PlanData = z.infer; type OperationRow = { id: string; product_id: string; action_json: string; preview_json: string; preview_sha256: string; dependencies_json: string; status: Operation["status"]; directive: string | null; request_sha256: string; created_by: string; created_at: string; expires_at: string; decided_by: string | null; decided_at: string | null; decision_note: string | null; job_id: string | null; result_json: string | null; error: string | null; }; function productRow(db: Db, productId: string) { const product = db.prepare("SELECT id, slug, name, kind, status FROM products WHERE id=?").get(productId) as { id: string; slug: string; name: string; kind: string; status: string } | undefined; if (!product) throw new OrchestratorError("Nie znaleziono produktu.", 404); return product; } function verifiedFile(db: Db, cfg: AppConfig, kind: "file_asset" | "file_source", id: string): string { const row = db.prepare(`SELECT path,sha256,bytes FROM ${kind === "file_asset" ? "assets" : "source_assets"} WHERE id=?`).get(id) as { path: string; sha256: string; bytes: number } | undefined; if (!row) throw new OrchestratorError("Brak pliku źródłowego."); try { const root = fs.realpathSync(kind === "file_asset" ? cfg.mediaDir : cfg.originalsDir); const file = fs.realpathSync(path.resolve(root, row.path)); if (!file.startsWith(root + path.sep)) throw new Error("outside"); const stat = fs.statSync(file); if (!stat.isFile() || stat.size !== row.bytes || stat.size > 100 * 1024 * 1024) throw new Error("size"); const sha256 = crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex"); if (sha256 !== row.sha256) throw new Error("sha"); return sha256; } catch { throw new OrchestratorError(`Plik ${id} jest niedostępny lub nie odpowiada SHA. Sprawdź materiały.`); } } function dependencyValue(db: Db, cfg: AppConfig, productId: string, kind: Dependency["kind"], key: string, ownJobId?: string): unknown { switch (kind) { case "product": return productRow(db, productId); case "geometry": return getFacts(db, productId).filter((f) => /^(dimensions|sleeping|cushions|legs|features|mechanism)\./.test(f.key)); case "fact": return getFact(db, productId, key); case "facts": return { product: productRow(db, productId), facts: getFacts(db, productId) }; case "variants": return listVariants(db, productId); case "fabric": return { fabric: db.prepare("SELECT * FROM fabrics WHERE id=?").get(key) ?? null, shades: listShades(db, key) }; case "asset": return getAsset(db, key); case "source": return db.prepare("SELECT * FROM source_assets WHERE id=?").get(key) ?? null; case "master": return approvedAssetForRole(db, productId, key as typeof OFFER_ROLES[number]); case "slot": { const [variantId, role] = key.split("|"); return db.prepare("SELECT id,sha256,status FROM assets WHERE product_id=? AND variant_id=? AND role=? AND (job_id IS NULL OR job_id<>?) ORDER BY id").all(productId, variantId!, role!, ownJobId ?? ""); } case "shot_refs": return resolveShotRefs(db, productId, key); case "content": return { draft: buildOfferContent(db, productId).draftSha256, revisions: listContentRevisions(db, productId).map((r) => ({ id: r.id, sha256: r.sha256, decision: r.decision })) }; case "file_asset": case "file_source": return verifiedFile(db, cfg, kind, key); } } function assertDependencies(db: Db, cfg: AppConfig, row: OperationRow): void { const plan = planDataSchema.parse(JSON.parse(row.dependencies_json)); for (const dep of plan.dependencies) { if (contentHash(dependencyValue(db, cfg, row.product_id, dep.kind, dep.key, row.job_id ?? undefined)) !== dep.sha256) { throw new OrchestratorError("Podgląd jest nieaktualny: zmieniły się dane, źródła lub akceptacje. Przygotuj nową operację."); } } } function toOperation(row: OperationRow): Operation { return operationSchema.parse({ id: row.id, productId: row.product_id, action: JSON.parse(row.action_json), preview: JSON.parse(row.preview_json), previewSha256: row.preview_sha256, status: row.status, createdBy: row.created_by, createdAt: row.created_at, expiresAt: row.expires_at, decidedBy: row.decided_by, decidedAt: row.decided_at, decisionNote: row.decision_note, jobId: row.job_id, result: row.result_json ? JSON.parse(row.result_json) : null, error: row.error, directive: row.directive, }); } function operationRow(db: Db, productId: string, id: string): OperationRow { const row = db.prepare("SELECT * FROM orchestrator_operations WHERE id=? AND product_id=?").get(id, productId) as OperationRow | undefined; if (!row) throw new OrchestratorError("Nie znaleziono operacji dla tego produktu.", 404); return row; } export function getOperation(db: Db, productId: string, id: string): Operation { return toOperation(operationRow(db, productId, id)); } export function listOperations(db: Db, productId: string, before?: string) { return (db.prepare("SELECT * FROM orchestrator_operations WHERE product_id=? AND (? IS NULL OR rowid < (SELECT rowid FROM orchestrator_operations WHERE id=? AND product_id=?)) ORDER BY rowid DESC LIMIT 30") .all(productId, before ?? null, before ?? null, productId) as OperationRow[]).map((row) => { const { action, preview, directive, ...summary } = toOperation(row); return operationSummarySchema.parse({ ...summary, title: preview.title, stage: preview.stage }); }); } function copyPlan(db: Db, productId: string, channel: Extract["channel"]) { const source = buildOfferContent(db, productId); const latest = listContentRevisions(db, productId)[0]; const base = latest?.current ? getContentRevision(db, productId, latest.id) : null; const channels = channel === "all" ? CONTENT_CHANNELS : [channel]; const edits = source.variants.flatMap((v) => channels.map((c) => ({ variantId: v.id, channel: c, content: v.channels[c]! }))); const { content } = previewContentRevision(db, productId, { expectedDraftSha256: source.draftSha256, ...(base ? { baseRevisionId: base.id, baseRevisionSha256: base.sha256 } : {}), edits, note: "Podgląd orkiestratora" }); const changes = content.variants.flatMap((v) => (base ? channels : CONTENT_CHANNELS).map((c) => ({ label: `${v.name} · ${c}`, before: base?.content.variants.find((b) => b.id === v.id)?.channels[c] ?? null, after: v.channels[c]! }))); return { source, base, edits, changes }; } function planOperation(db: Db, cfg: AppConfig, productId: string, action: OrchestratorAction): { preview: OperationPreview; data: PlanData } { const product = productRow(db, productId); const [title, stage] = labels[action.kind]; const preview: OperationPreview = { title, stage, summary: "Zmiana wyłącznie po Twojej decyzji. Poprzednie wersje pozostają w historii.", requiredRole: "operator", cost: { externalUsd: 0, description: "Bez płatnego API; lokalny zapis lub praca serwera." }, blockers: [], warnings: [], changes: [], items: [], effects: [] }; const data: PlanData = { dependencies: [], tasks: [], entries: [] }; const depend = (kind: Dependency["kind"], key = "") => { if (data.dependencies.some((d) => d.kind === kind && d.key === key)) return; try { data.dependencies.push({ kind, key, sha256: contentHash(dependencyValue(db, cfg, productId, kind, key)) }); } catch (err) { if (err instanceof OrchestratorError) preview.blockers.push(err.message); else throw err; } }; const fileAsset = (id: string) => { depend("asset", id); depend("file_asset", id); }; const fabric = (id: string) => { depend("fabric", id); return db.prepare("SELECT * FROM fabrics WHERE id=?").get(id) as unknown as FabricRow; }; const addItem = (id: string, label: string, sha256: string | null = null) => preview.items.push({ id, label, sha256 }); depend("product"); if (product.status !== "active") preview.blockers.push("Produkt jest zarchiwizowany."); switch (action.kind) { case "set_fact": { depend("fact", action.key); const before = getFact(db, productId, action.key); if (before?.status === "confirmed" || action.status === "confirmed") preview.requiredRole = "approver"; if (/^(dimensions|sleeping)\..*_cm$/.test(action.key) && (typeof action.value !== "number" || !Number.isFinite(action.value) || action.value <= 0 || action.value > 2000 || action.unit !== "cm")) preview.blockers.push("Wymiar musi być dodatnią liczbą do 2000 w jednostce cm."); if (action.key === "commerce.price_pln" && (typeof action.value !== "number" || action.value <= 0 || action.unit !== "PLN")) preview.blockers.push("Cena wymaga dodatniej liczby oraz jednostki PLN."); if (action.key === "sides.photographed" && !["L", "P"].includes(String(action.value))) preview.blockers.push("Strona fotografowana musi mieć wartość L albo P."); if (action.value === null || action.value === "") preview.blockers.push("Brak wartości nie jest faktem do potwierdzenia."); preview.changes.push({ label: action.key, before: before ? { value: z.json().parse(before.value), status: before.status, unit: before.unit, sourceKind: before.sourceKind, sourceRef: before.sourceRef, note: before.note } : null, after: { value: action.value, status: action.status, unit: action.unit, sourceKind: action.sourceKind, sourceRef: action.sourceRef, note: before?.note ?? null } }); preview.effects.push("Historia faktu zachowa poprzednią wartość i źródło.", "Treści i dokumenty zależne od tego faktu wymagają ponownej kontroli; nic nie zostanie automatycznie zaakceptowane."); if (action.key.startsWith("vendor.")) preview.warnings.push("Fakt dostawcy pozostaje w przestrzeni vendor — nie jest przenoszony do danych handlowych Vilmax."); break; } case "confirm_facts": { preview.requiredRole = "approver"; for (const key of action.keys) { depend("fact", key); const fact = getFact(db, productId, key); if (!fact || fact.value === null || fact.value === "" || !fact.sourceRef?.trim() || fact.status === "unknown") preview.blockers.push(`${key}: potrzebna wartość i dowód źródłowy.`); else preview.changes.push({ label: key, before: { value: z.json().parse(fact.value), status: fact.status, unit: fact.unit, sourceKind: fact.sourceKind, sourceRef: fact.sourceRef, note: fact.note }, after: { value: z.json().parse(fact.value), status: "confirmed", unit: fact.unit, sourceKind: fact.sourceKind, sourceRef: fact.sourceRef, note: fact.note } }); } preview.effects.push("Potwierdzenie dotyczy tylko pokazanych wartości; źródła nie zostają zmienione."); preview.warnings.push("Zweryfikuj dowody. Dane dostawcy nie są automatycznie danymi własnego produktu."); break; } case "bind_fabric": { depend("variants"); const found = db.prepare("SELECT id FROM fabrics WHERE code=?").get(action.fabricCode) as { id: string } | undefined; if (!found) { preview.blockers.push("Wybrana tkanina nie istnieje w katalogu."); break; } const f = fabric(found.id), shades = listShades(db, f.id), variants = listVariants(db, productId); if (!shades.length) preview.blockers.push("Kolekcja nie ma odcieni."); for (const side of action.sides) for (const shade of shades) if (!variants.some((v) => v.fabric_id === f.id && v.side === side && v.shade_code === shade.code)) addItem(`${side}/${shade.code}`, `${f.name} ${shade.code} · ${side}`); if (!preview.items.length) preview.blockers.push("Wybrana macierz jest już kompletna."); if (f.verification !== "confirmed") preview.warnings.push("Tkanina pozostaje niepotwierdzona; recolor będzie zablokowany."); preview.effects.push(`Utworzenie ${preview.items.length} brakujących wariantów. Istniejące warianty i akceptacje pozostają bez zmian.`); break; } case "compose_offer": { depend("content"); let plan: ReturnType; try { plan = copyPlan(db, productId, action.channel); } catch (err) { if (err instanceof ContentConflict || err instanceof z.ZodError) { preview.blockers.push(err instanceof ContentConflict ? err.message : "Treści przekraczają limity kanału lub zakresu. Popraw dane albo wybierz jeden kanał."); break; } throw err; } if (!plan.source.variants.length) preview.blockers.push("Najpierw wybierz tkaninę i utwórz warianty."); if (!getFacts(db, productId).some((f) => f.status === "confirmed" && f.sourceRef)) preview.blockers.push("Najpierw potwierdź dane produktu ze źródłem."); if (plan.edits.length > 170) preview.blockers.push("Zakres przekracza limit 170 treści na operację. Wybierz jeden kanał."); preview.changes = JSON.parse(JSON.stringify(plan.changes)) as OperationPreview["changes"]; preview.warnings.push(...new Set(plan.source.variants.flatMap((v) => v.blockers))); if (!plan.base && listContentRevisions(db, productId).length) preview.warnings.push("Poprzednia wersja jest nieaktualna. Nowy projekt powstaje z bieżących faktów; stare ręczne poprawki pozostają w historii."); preview.effects.push("Zapis nowej, niezatwierdzonej rewizji. Bez publikacji, wysyłki i automatycznego zatwierdzania.", plan.base ? "Ręczne treści pozostałych kanałów zostaną zachowane; pokazane kanały zastąpi projekt regułowy." : "Pierwsza rewizja zawiera bazowe projekty wszystkich kanałów; podgląd pokazuje wszystkie zapisywane treści."); break; } case "recolor_missing": { depend("variants"); depend("geometry"); depend("fact", "fabric.photographed_shade"); const all = listVariants(db, productId), variants = all.filter((v) => !action.variantIds || action.variantIds.includes(v.id)); if (action.variantIds?.some((id) => !all.some((v) => v.id === id))) preview.blockers.push("Wybrano wariant spoza produktu."); if (!variants.length) preview.blockers.push("Brak wariantów do przygotowania."); const side = getFact(db, productId, "sides.photographed"), anchor = getFact(db, productId, "fabric.photographed_shade"); if (variants.some((v) => v.side !== "S")) { depend("fact", "sides.photographed"); if (side?.status !== "confirmed" || !["L", "P"].includes(String(side.value))) preview.blockers.push("Potwierdź stronę fotografowanego mebla."); } if (anchor?.status !== "confirmed" || typeof anchor.value !== "string") preview.blockers.push("Potwierdź kod fotografowanego odcienia — nie wyznaczaj go z RGB."); if (new Set(all.map((v) => v.fabric_id)).size > 1) preview.blockers.push("Wiele kolekcji wymaga oddzielnych masterów materiału. Ten tor nie wykonuje texture-swap."); const assets = listAssets(db, productId); for (const role of [...new Set(action.roles)]) { const master = approvedAssetForRole(db, productId, role); depend("master", role); if (!master) { preview.warnings.push(`${role}: brak zatwierdzonego mastera — pominięto.`); continue; } if (masterMaskState(db, path.join(cfg.mediaDir, master.path), master.id) !== "confirmed") { preview.warnings.push(`${role}: maska mastera niezatwierdzona (Krok 3 → Studio Wariantów) — pominięto.`); continue; } for (const v of variants) { if (assets.some((a) => a.variant_id === v.id && a.role === role && ["candidate", "approved", "rejected"].includes(a.status))) continue; data.tasks.push({ variantId: v.id, role }); depend("slot", `${v.id}|${role}`); fileAsset(master.id); const f = fabric(v.fabric_id), shades = listShades(db, f.id); for (const code of [...new Set([v.shade_code, String(anchor?.value)])]) { const shade = shades.find((s) => s.code === code); if (f.verification !== "confirmed" || shade?.verification !== "confirmed" || !shade.official_image_path) preview.blockers.push(`${f.name} ${code}: wymagana potwierdzona kolekcja, odcień i oficjalna próbka.`); else { depend("source", shade.official_image_path); depend("file_source", shade.official_image_path); } } addItem(`${v.id}|${role}`, `${f.name} ${v.shade_code} / ${v.side} · ${role}`, master.sha256); } } if (!data.tasks.length) preview.blockers.push("Brak pustych miejsc z zatwierdzonym masterem. Kandydaci, zatwierdzone i odrzucone wersje nie są regenerowane automatycznie."); if (data.tasks.length > 272) preview.blockers.push("Maksymalnie 272 obrazy w operacji; zawęź role lub warianty."); preview.effects.push(`Zlecenie ${data.tasks.length} lokalnych recolorów do kolejki. Wyniki są kandydatami do oceny, nigdy automatycznie approved.`); if (variants.some((v) => v.side !== "S")) preview.warnings.push("Przeciwna strona używa lustra: człowiek sprawdza napisy, asymetrię i konstrukcję."); break; } case "review_media": { depend("facts"); depend("variants"); const review = buildMediaReview(db, cfg, productId); const selected = [...review.masters, ...review.slots.flatMap((s) => s.asset ? [s.asset] : [])]; const verdicts = latestVerdictsForProduct(db, productId); for (const role of OFFER_ROLES) { if (selected.some((a) => !a.variantId && a.role === role)) continue; const candidate = listAssets(db, productId, role).find((a) => !a.variant_id && a.status === "candidate"); if (candidate && (!verdicts[candidate.id] || verdicts[candidate.id]!.prompt_version !== MACIUS_PROMPT_VERSION)) { fileAsset(candidate.id); data.entries.push({ id: candidate.id, sha256: candidate.sha256 }); addItem(candidate.id, `${role} · kandydat v${candidate.version}`, candidate.sha256); } } for (const asset of selected) if (!asset.qa && !asset.blockers.length) { fileAsset(asset.id); if (asset.parentId) fileAsset(asset.parentId); data.entries.push({ id: asset.id, sha256: asset.sha256 }); addItem(asset.id, `${asset.role} · v${asset.version}`, asset.sha256); } for (const entry of data.entries) { const asset = getAsset(db, entry.id)!; const prov = JSON.parse(asset.provenance_json) as { primarySourceId?: string; swatches?: { anchor?: string; target?: string } }; for (const sourceId of [prov.primarySourceId, prov.swatches?.anchor, prov.swatches?.target]) if (sourceId) { depend("source", sourceId); depend("file_source", sourceId); } } for (const id of new Set(listVariants(db, productId).map((v) => v.fabric_id))) fabric(id); if (!data.entries.length) preview.blockers.push("Brak nieocenionych materiałów bez blokad integralności."); preview.effects.push("Lokalne werdykty Maciusia przypięte do ID/SHA. Bez zmian statusów i bez końcowej akceptacji."); preview.warnings.push("Reguły nie zastępują vision ani oględzin człowieka; ocena geometrii mastera pozostaje ograniczona."); break; } case "render_card": { depend("facts"); depend("variants"); const card = collectCardData(db, productId, cfg.mediaDir); preview.blockers.push(...card.missing); for (const key of ["dimensions.width_cm", "dimensions.depth_cm", "dimensions.height_with_cushions_cm"]) { const fact = getFact(db, productId, key); if (fact?.status !== "confirmed" || !fact.sourceRef || typeof fact.value !== "number" || fact.value <= 0) preview.blockers.push(`${key}: karta wymaga potwierdzonego dodatniego wymiaru ze źródłem.`); } for (const f of new Set(listVariants(db, productId).map((v) => v.fabric_id))) fabric(f); for (const role of [...OFFER_ROLES, "blueprint"] as const) depend("master", role); for (const img of [card.hero, card.blueprint, ...card.gallery, ...card.variants.flatMap((v) => card.cardSides.map((s) => v.thumbs[s]))]) if (img) { fileAsset(img.assetId); addItem(img.assetId, img.label, img.sha256); } for (const v of listVariants(db, productId)) { const a = approvedAssetForRole(db, productId, "packshot_34", v.id); if (!a) preview.blockers.push(`${v.side}/${v.shade_code}: zatwierdź packshot 3/4 do przeglądu wariantów.`); const shade = listShades(db, v.fabric_id).find((s) => s.code === v.shade_code); if (shade?.official_image_path) { depend("source", shade.official_image_path); depend("file_source", shade.official_image_path); } } preview.changes.push({ label: "Parametry na karcie", before: null, after: card.params }); preview.effects.push("Nowy PDF w Dokumentach jako kandydat. Poprzednia karta i jej akceptacja pozostają w historii."); break; } case "generate_shot": { depend("shot_refs", action.role); const refs = resolveShotRefs(db, productId, action.role); if (!refs.primaryId) preview.blockers.push(`Przypisz zdjęcie primary pod ujęcie ${action.role}.`); for (const id of refs.sourceIds) { depend("source", id); depend("file_source", id); addItem(id, id === refs.primaryId ? "Referencja główna" : "Referencja wspierająca"); } preview.cost = { externalUsd: null, description: "Koszt wymaga aktualnej wyceny dostawcy i osobno uzgodnionego limitu." }; preview.blockers.push("Płatne zlecenia orkiestratora są wstrzymane. Wymagana osobna zgoda na zakres, dostawcę i limit kosztów; ten przycisk nie odblokowuje generacji."); preview.effects.push("Nie powstanie płatny job i żadne zdjęcie nie zostanie wysłane do providera."); break; } case "export_base": { const consented = db.prepare("SELECT COUNT(*) n FROM export_consents WHERE product_id = ? AND revoked_at IS NULL").get(productId) as { n: number }; const batches = db.prepare("SELECT status, COUNT(*) n FROM export_batches WHERE product_id = ? GROUP BY status").all(productId) as Array<{ status: string; n: number }>; if (consented.n) preview.warnings.push(`Aktywne zgody eksportowe: ${consented.n}. Stany wsadów: ${batches.map((b) => `${b.status}×${b.n}`).join(", ") || "brak"}.`); preview.blockers.push("Dispatch Base nie jest operacją orkiestratora — wymaga zatwierdzonego snapshotu, osobnej zgody eksportowej (SHA+zakres) i skonfigurowanego adaptera. Zgoda i outbox są w zakładce Oferta."); preview.effects.push("Orkiestrator nie łączy się z Base i nie tworzy zgody; pokazuje tylko stan bramki."); break; } } preview.blockers = [...new Set(preview.blockers)]; preview.warnings = [...new Set(preview.warnings)]; return { preview: previewSchema.parse(preview), data }; } export function prepareOperation(db: Db, cfg: AppConfig, productId: string, input: z.input, actor: SessionUser): Operation { const parsed = prepareOperationSchema.parse(input); const fingerprint = contentHash({ productId, action: parsed.action, directive: parsed.directive ?? null }); return tx(db, () => { productRow(db, productId); const previous = db.prepare("SELECT * FROM orchestrator_operations WHERE created_by=? AND request_key=?").get(actor.id, parsed.idempotencyKey) as OperationRow | undefined; if (previous) { if (previous.request_sha256 !== fingerprint) throw new OrchestratorError("Ten klucz idempotencji wskazuje inną operację."); return toOperation(previous); } const pending = Number(db.prepare("SELECT COUNT(*) n FROM orchestrator_operations WHERE product_id=? AND status='proposed' AND expires_at>?").get(productId, nowIso())!.n); if (pending >= 100) throw new OrchestratorError("Limit 100 otwartych propozycji. Odrzuć zbędne operacje.", 429); const { preview, data } = planOperation(db, cfg, productId, parsed.action); const id = newId("opr"), now = nowIso(), expires = new Date(Date.now() + 24 * 3600_000).toISOString(); const sha256 = contentHash({ version: ORCHESTRATOR_VERSION, productId, action: parsed.action, preview, data, expires }); db.prepare(`INSERT INTO orchestrator_operations(id,product_id,request_key,request_sha256,action_json,preview_json,preview_sha256,dependencies_json,status,directive,created_by,created_at,expires_at,updated_at) VALUES(?,?,?,?,?,?,?,?,'proposed',?,?,?,?,?)`).run(id, productId, parsed.idempotencyKey, fingerprint, JSON.stringify(parsed.action), JSON.stringify(preview), sha256, JSON.stringify(data), parsed.directive ?? null, actor.id, now, expires, now); audit(db, { actor: actor.id, action: "orchestrator.proposed", entity: "orchestrator_operations", entityId: id, detail: { productId, kind: parsed.action.kind, previewSha256: sha256 } }); return getOperation(db, productId, id); }); } function executeSynchronous(db: Db, productId: string, action: OrchestratorAction, actor: string, note: string): OperationResult { switch (action.kind) { case "set_fact": setFact(db, productId, { ...action, changedBy: actor }); return { factsChanged: 1 }; case "confirm_facts": for (const key of action.keys) { const f = getFact(db, productId, key)!; setFact(db, productId, { ...f, status: "confirmed", changedBy: actor }); } return { factsChanged: action.keys.length }; case "bind_fabric": { const fabric = db.prepare("SELECT id FROM fabrics WHERE code=?").get(action.fabricCode) as { id: string }; return { variantsCreated: ensureVariantMatrix(db, { productId, fabricId: fabric.id, sides: action.sides }).created }; } case "compose_offer": { const p = copyPlan(db, productId, action.channel); const saved = saveContentRevision(db, { productId, actor, expectedDraftSha256: p.source.draftSha256, ...(p.base ? { baseRevisionId: p.base.id, baseRevisionSha256: p.base.sha256 } : {}), edits: p.edits, note }); return { revisionId: saved.id }; } default: throw new OrchestratorError("Ta operacja nie ma wykonawcy transakcyjnego."); } } export function decideOperation(db: Db, cfg: AppConfig, productId: string, id: string, input: z.input, actor: SessionUser): Operation { const decision = operationDecisionSchema.parse(input); const outcome = tx(db, () => { const row = operationRow(db, productId, id), op = toOperation(row); if (decision.expectedPreviewSha256 !== op.previewSha256) throw new OrchestratorError("SHA nie odpowiada podglądowi operacji."); const canApprove = op.preview.requiredRole === "operator" ? ["operator", "approver", "admin"].includes(actor.role) : ["approver", "admin"].includes(actor.role); if (decision.decision === "approve" && !canApprove) throw new OrchestratorError("Brak uprawnień do wykonania tej operacji.", 403); if (decision.decision === "reject" && actor.id !== op.createdBy && !["approver", "admin"].includes(actor.role)) throw new OrchestratorError("Brak uprawnień do odrzucenia cudzej propozycji.", 403); if (["queued", "running", "succeeded"].includes(op.status) && decision.decision === "approve") return { operation: op }; if (op.status === "rejected" && decision.decision === "reject") return { operation: op }; if (op.status !== "proposed") throw new OrchestratorError("Operacja jest zakończona lub nieaktualna. Przygotuj nową propozycję."); const now = nowIso(); if (decision.decision === "approve") { if (op.preview.blockers.length) throw new OrchestratorError("Operacja ma blokady. Uzupełnij warunki i przygotuj nowy podgląd.", 422); try { if (row.expires_at <= now) throw new OrchestratorError("Podgląd jest nieaktualny: upłynęło 24 h."); const integrity = contentHash({ version: ORCHESTRATOR_VERSION, productId, action: op.action, preview: op.preview, data: planDataSchema.parse(JSON.parse(row.dependencies_json)), expires: row.expires_at }); if (integrity !== op.previewSha256) throw new OrchestratorError("Podgląd jest nieaktualny lub jego zapis nie odpowiada SHA."); assertDependencies(db, cfg, row); const current = planOperation(db, cfg, productId, op.action); if (current.preview.blockers.length || contentHash(current.preview) !== contentHash(op.preview)) throw new OrchestratorError("Podgląd jest nieaktualny. Przygotuj nową operację."); } catch (err) { if (!(err instanceof OrchestratorError)) throw err; db.prepare("UPDATE orchestrator_operations SET status='stale',error=?,updated_at=? WHERE id=?").run(err.message, now, id); audit(db, { actor: actor.id, action: "orchestrator.stale", entity: "orchestrator_operations", entityId: id, detail: { productId } }); return { error: err }; } } let result: OperationResult | null = null, jobId: string | null = null; let status: Operation["status"] = "rejected"; if (decision.decision === "approve") { if (["recolor_missing", "review_media", "render_card"].includes(op.action.kind)) { const active = Number(db.prepare("SELECT COUNT(*) n FROM jobs WHERE status IN ('queued','running','waiting')").get()!.n); if (active >= 20) throw new OrchestratorError("Kolejka jest pełna. Poczekaj na zakończenie bieżących zadań.", 429); const same = db.prepare("SELECT id FROM orchestrator_operations WHERE product_id=? AND json_extract(action_json,'$.kind')=? AND status IN ('queued','running')").get(productId, op.action.kind); if (same) throw new OrchestratorError("Taka operacja jest już w kolejce dla produktu."); jobId = enqueueJob(db, { type: "orchestrator_action", payload: { productId, operationId: id }, idempotencyKey: `orchestrator:${id}`, maxAttempts: 3 }).id; status = "queued"; } else { result = operationResultSchema.parse(executeSynchronous(db, productId, op.action, actor.id, decision.note)); status = "succeeded"; } } db.prepare("UPDATE orchestrator_operations SET status=?,decided_by=?,decided_at=?,decision_note=?,job_id=?,result_json=?,updated_at=? WHERE id=?") .run(status, actor.id, now, decision.note, jobId, result ? JSON.stringify(result) : null, now, id); audit(db, { actor: actor.id, action: `orchestrator.${decision.decision === "approve" ? "approved" : "rejected"}`, entity: "orchestrator_operations", entityId: id, detail: { productId, previewSha256: op.previewSha256, jobId, result } }); return { operation: getOperation(db, productId, id) }; }); if (outcome.error) throw outcome.error; return outcome.operation!; } export async function runOrchestratorJob(db: Db, cfg: AppConfig, jobId: string, stopped: () => boolean = () => false): Promise { const job = getJob(db, jobId); if (!job) return; const payload = z.object({ productId: z.string(), operationId: z.string() }).strict().parse(JSON.parse(job.payload_json)); const row = operationRow(db, payload.productId, payload.operationId); if (row.job_id !== jobId || !row.decided_by || !["queued", "running", "succeeded"].includes(row.status)) { finishJob(db, jobId, { ok: false, error: "Brak zatwierdzonej operacji dla zadania." }); return; } if (row.status === "succeeded") { finishJob(db, jobId, { ok: true, result: JSON.parse(row.result_json ?? "{}") }); return; } const action = actionSchema.parse(JSON.parse(row.action_json)), plan = planDataSchema.parse(JSON.parse(row.dependencies_json)); const result: OperationResult = row.result_json ? operationResultSchema.parse(JSON.parse(row.result_json)) : { assetIds: [], verdictIds: [], completed: 0, total: action.kind === "recolor_missing" ? plan.tasks.length : action.kind === "review_media" ? plan.entries.length : 1 }; const guard = () => assertDependencies(db, cfg, row); const save = () => { db.prepare("UPDATE orchestrator_operations SET result_json=?,updated_at=? WHERE id=?").run(JSON.stringify(result), nowIso(), row.id); db.prepare("UPDATE jobs SET result_json=?,updated_at=? WHERE id=?").run(JSON.stringify(result), nowIso(), jobId); }; try { const actor = db.prepare("SELECT role FROM users WHERE id=?").get(row.decided_by) as { role: string } | undefined; if (!actor || !["operator", "approver", "admin"].includes(actor.role)) throw new OrchestratorError("Użytkownik utracił uprawnienia do wykonania operacji.", 403); if (job.attempts > job.max_attempts) throw new OrchestratorError("Przekroczono limit wznowień. Przygotuj nową propozycję."); guard(); db.prepare("UPDATE orchestrator_operations SET status='running',updated_at=? WHERE id=?").run(nowIso(), row.id); const total = result.total ?? 1; for (let index = result.completed ?? 0; index < total; index++) { if (stopped()) { save(); db.prepare("UPDATE orchestrator_operations SET status='queued' WHERE id=?").run(row.id); deferJob(db, jobId, 0); return; } guard(); if (action.kind === "recolor_missing") { const task = plan.tasks[index]!; const existing = db.prepare("SELECT id FROM assets WHERE job_id=? AND variant_id=? AND role=?").get(jobId, task.variantId, task.role) as { id: string } | undefined; if (existing) result.assetIds!.push(existing.id); else { const output = await buildRecolor(db, cfg, { productId: row.product_id, ...task, jobId, beforePersist: guard, requireMask: true }); if ("missing" in output) throw new OrchestratorError(output.missing.join("; ")); if (output.asset.status === "rejected") throw new OrchestratorError("Wynik odpowiada odrzuconej wersji. Wymagana ręczna decyzja."); result.assetIds!.push(output.asset.id); } } else if (action.kind === "render_card") { const existing = db.prepare("SELECT id FROM assets WHERE job_id=? AND role='document'").get(jobId) as { id: string } | undefined; if (existing) result.assetIds!.push(existing.id); else { const output = await buildProductCard(db, cfg, row.product_id, { jobId, beforePersist: guard }); if ("missing" in output) throw new OrchestratorError(output.missing.join("; ")); result.assetIds!.push(output.asset.id); } } else if (action.kind === "review_media") { const entry = plan.entries[index]!; const existing = db.prepare("SELECT id FROM macius_verdicts WHERE asset_id=? AND asset_sha256=? AND model='macius-rules' AND prompt_version=? ORDER BY rowid DESC LIMIT 1").get(entry.id, entry.sha256, MACIUS_PROMPT_VERSION) as { id: string } | undefined; const verdict = existing ?? await reviewAsset(db, cfg, { assetId: entry.id, createdBy: row.decided_by, provider: "rules" }); result.verdictIds!.push(verdict.id); } else throw new OrchestratorError("Niedozwolone narzędzie w kolejce orkiestratora."); result.completed = index + 1; save(); } guard(); tx(db, () => { db.prepare("UPDATE orchestrator_operations SET status='succeeded',result_json=?,updated_at=? WHERE id=?").run(JSON.stringify(result), nowIso(), row.id); finishJob(db, jobId, { ok: true, result }); audit(db, { actor: row.decided_by!, action: "orchestrator.completed", entity: "orchestrator_operations", entityId: row.id, detail: { productId: row.product_id, jobId, result } }); }); } catch (err) { const message = err instanceof OrchestratorError ? err.message : "Nie udało się wykonać operacji. Sprawdź dane wejściowe i dostępność lokalnego silnika; wyniki częściowe zachowano."; tx(db, () => { save(); db.prepare("UPDATE orchestrator_operations SET status=?,error=?,updated_at=? WHERE id=?").run(err instanceof OrchestratorError && /nieaktualn/.test(message) ? "stale" : "failed", message, nowIso(), row.id); finishJob(db, jobId, { ok: false, error: message }); audit(db, { actor: "system:orchestrator", action: "orchestrator.failed", entity: "orchestrator_operations", entityId: row.id, detail: { productId: row.product_id, jobId, completed: result.completed ?? 0 } }); }); } } export function inspectProduct(db: Db, cfg: AppConfig, productId: string): ProductInspection { productRow(db, productId); const facts = getFacts(db, productId), variants = listVariants(db, productId), assets = listAssets(db, productId); const confirmed = (key: string) => facts.some((f) => f.key === key && f.status === "confirmed" && f.value !== null && !!f.sourceRef); const recommendations: Recommendation[] = []; const add = (id: string, priority: Recommendation["priority"], area: Recommendation["area"], title: string, reason: string, stage: Recommendation["stage"], action: ActionRequest | null, cta: string) => recommendations.push({ id, priority, area, title, reason, stage, action: action ? actionSchema.parse(action) : null, cta }); const pendingFacts = facts.filter((f) => f.status !== "confirmed"); const confirmable = pendingFacts.filter((f) => f.status === "unverified" && f.sourceRef && f.value !== null && f.value !== "").slice(0, 100); if (confirmable.length) add("facts-review", "next", "facts", `Sprawdź ${confirmable.length} faktów ze źródłami`, "Przygotuję dokładną listę wartości i dowodów do decyzji zatwierdzającego.", "fakty", { kind: "confirm_facts", keys: confirmable.map((f) => f.key) }, "Przejrzyj fakty"); const dimensions = ["dimensions.width_cm", "dimensions.depth_cm", "dimensions.height_with_cushions_cm"].filter((key) => !confirmed(key)); if (dimensions.length) add("dimensions", "blocker", "facts", "Uzupełnij wymiary ze źródła", `Brakuje potwierdzeń: ${dimensions.join(", ")}. Nie wyznaczam wymiarów ze zdjęcia.`, "fakty", null, "Uzupełnij fakty"); if (!variants.length) add("fabric-bind", "next", "fabrics", "Wybierz kolekcję i strony", "Wybór tkaniny pozwoli przygotować kompletną macierz odcieni bez ręcznego dodawania wariantów.", "warianty", null, "Wybierz tkaninę"); for (const id of new Set(variants.map((v) => v.fabric_id))) { const f = db.prepare("SELECT * FROM fabrics WHERE id=?").get(id) as unknown as FabricRow; const shades = listShades(db, id); const unverified = shades.filter((s) => s.verification !== "confirmed" || !s.official_image_path); if (f.verification !== "confirmed" || unverified.length) add(`fabric-${id}`, "blocker", "fabrics", `Zweryfikuj kolekcję ${f.name}`, `${unverified.length} odcieni bez potwierdzenia lub oficjalnej próbki. Kolekcja: ${f.verification}.`, "warianty", null, "Otwórz katalog tkanin"); const sides = [...new Set(variants.filter((v) => v.fabric_id === id).map((v) => v.side))]; if (sides.length * shades.length > variants.filter((v) => v.fabric_id === id).length) add(`matrix-${id}`, "next", "variants", `Uzupełnij nowe odcienie ${f.name}`, "W katalogu są odcienie bez wariantów dla wybranych stron.", "warianty", { kind: "bind_fabric", fabricCode: f.code, sides }, "Przygotuj macierz"); } const masters = OFFER_ROLES.flatMap((role) => { const a = approvedAssetForRole(db, productId, role); return a ? [a] : []; }); for (const role of OFFER_ROLES) if (!masters.some((a) => a.role === role)) { const candidate = assets.find((a) => !a.variant_id && a.role === role && a.status === "candidate"); add(`shot-${role}`, candidate ? "review" : "next", "gallery", candidate ? `Oceń kandydat ujęcia ${role}` : `Przygotuj ujęcie ${role}`, candidate ? "Gotowy kandydat wymaga oględzin i decyzji człowieka, zamiast kolejnej generacji." : "Przygotuję zakres i referencje; generacja płatna pozostaje za osobną zgodą.", "galeria", candidate ? null : { kind: "generate_shot", role }, candidate ? "Otwórz galerię" : "Pokaż warunki"); } let missingMedia = 0; for (const v of variants) for (const role of OFFER_ROLES) if (!assets.some((a) => a.variant_id === v.id && a.role === role && ["approved", "candidate"].includes(a.status))) missingMedia++; const staleMedia = assets.filter((a) => a.variant_id && a.status === "approved").filter((a) => { const master = approvedAssetForRole(db, productId, a.role); try { const prov = JSON.parse(a.provenance_json) as { baseAsset?: { id?: string; sha256?: string }; anchorShade?: string }; return !master || master.id !== a.parent_asset_id || prov.baseAsset?.id !== master.id || prov.baseAsset.sha256 !== master.sha256 || prov.anchorShade !== getFact(db, productId, "fabric.photographed_shade")?.value || approvedAssetForRole(db, productId, a.role, a.variant_id)?.id !== a.id; } catch { return true; } }).length; if (staleMedia) add("stale-media", "blocker", "variants", `Sprawdź nieaktualne media: ${staleMedia}`, "Zmienił się master, kotwica odcienia lub akceptacja. Nie regeneruję zatwierdzonych obrazów automatycznie — sprawdź zależności w przeglądzie.", "warianty", null, "Sprawdź zależności"); if (missingMedia && masters.length) add("recolor", "next", "variants", `Brakuje ${missingMedia} mediów wariantów`, "Przygotuję lokalny recolor pustych miejsc. Kandydaci i odrzucone wersje wymagają osobnego przeglądu, nie automatycznej regeneracji.", "warianty", { kind: "recolor_missing" }, "Przygotuj zakres recoloru"); const verdicts = latestVerdictsForProduct(db, productId); const active = assets.filter((a) => ["approved", "candidate"].includes(a.status)); const qualityIssues = active.filter((a) => { const v = verdicts[a.id]; return v && v.asset_sha256 === a.sha256 && v.verdict !== "ok"; }).length; if (qualityIssues) add("qa-issues", "review", "quality", `Maciuś zgłasza uwagi: ${qualityIssues}`, "Otwórz przegląd, porównaj ze źródłem i zdecyduj. Nie kasuję negatywnych werdyktów.", "warianty", null, "Przejrzyj uwagi"); if (active.some((a) => a.kind === "image" && (!verdicts[a.id] || verdicts[a.id]!.asset_sha256 !== a.sha256 || verdicts[a.id]!.prompt_version !== MACIUS_PROMPT_VERSION))) add("qa-missing", "next", "quality", "Zleć brakujący pre-screening", "Przygotuję listę nieocenionych masterów i wybranych mediów wariantów. Reguły są doradcze, nie certyfikują zgodności.", "warianty", { kind: "review_media" }, "Przygotuj kontrolę"); const document = approvedAssetForRole(db, productId, "document"); let staleDocument = false; if (document) { try { const prov = JSON.parse(document.provenance_json) as { factsSnapshot?: Record; images?: Array<{ assetId: string; sha256: string }>; blueprint?: { assetId: string; sha256: string }; variants?: Array<{ shade: string; thumbs: Partial> }> }; staleDocument = !prov.factsSnapshot || Object.entries(prov.factsSnapshot).some(([key, value]) => contentHash(facts.find((f) => f.key === key && f.status === "confirmed")?.value ?? null) !== contentHash(value)); staleDocument ||= [...(prov.images ?? []), ...(prov.blueprint ? [prov.blueprint] : [])].some((ref) => { const a = getAsset(db, ref.assetId); return !a || a.sha256 !== ref.sha256 || approvedAssetForRole(db, productId, a.role, a.variant_id)?.id !== a.id; }); staleDocument ||= variants.some((v) => { const ref = prov.variants?.find((s) => s.shade === v.shade_code)?.thumbs[v.side]; const asset = approvedAssetForRole(db, productId, "packshot_34", v.id); return !ref || !asset || ref.assetId !== asset.id || ref.sha256 !== asset.sha256; }); } catch { staleDocument = true; } } if (!document || staleDocument) add("card", staleDocument ? "blocker" : "next", "documents", staleDocument ? "Karta PDF wymaga aktualizacji" : "Przygotuj kartę produktu", "Nowa karta użyje bieżących potwierdzonych faktów i zatwierdzonych obrazów; wynik czeka na decyzję człowieka.", "dokumenty", { kind: "render_card" }, "Przygotuj kartę"); const revisions = listContentRevisions(db, productId); if (!revisions[0]?.current) add("content", "next", "content", "Przygotuj aktualne treści kanałowe", "Złożę opisy z potwierdzonych danych i pokażę treść przed zapisaniem rewizji. Bez dopisywania parametrów i certyfikatów.", "oferta", { kind: "compose_offer" }, "Przygotuj opisy"); else if (revisions[0].decision?.decision !== "approved") add("content-review", "review", "content", "Oceń zapisaną rewizję treści", "Zapis projektu nie jest jego końcową akceptacją. Otwórz treść i podejmij decyzję dla dokładnej wersji.", "oferta", null, "Otwórz treści"); const integrationKeys = ["commerce.price_pln", "commerce.sku_pattern", "gpsr.manufacturer", "gpsr.responsible_person", "gpsr.safety_information", "base.inventory_id", "base.category_id", "base.allegro_category_id", "base.allegro_account"]; const missing = integrationKeys.filter((key) => !confirmed(key)); if (missing.length) add("integration-data", "blocker", "integrations", "Uzupełnij dane handlowe i kanału", `Brak potwierdzeń: ${missing.join(", ")}. ID konta i ceny nie są kopiowane z innych produktów.`, "fakty", null, "Uzupełnij dane"); add("dispatch", "blocker", "integrations", "Wysyłka do Base jest wstrzymana", "Zapis w katalogu Base nie oznacza publikacji Allegro. Potrzebne są osobna zgoda, aktualny snapshot i kontrola wyniku.", "oferta", { kind: "export_base" }, "Pokaż bramkę wysyłki"); const order = (id: string) => { if (id === "facts-review") return 0; if (id === "dimensions") return 1; if (id.startsWith("fabric-")) return 2; if (id.startsWith("matrix-")) return 3; if (id.startsWith("shot-")) return id === "shot-packshot_34" ? 4 : id === "shot-hero" ? 5 : 6; return ({ "stale-media": 7, "qa-issues": 8, recolor: 9, "qa-missing": 10, card: 11, content: 12, "content-review": 13, "integration-data": 14, dispatch: 15 } as Record)[id] ?? 16; }; recommendations.sort((a, b) => order(a.id) - order(b.id)); const state = { recommendations, counts: { pendingFacts: pendingFacts.length, variants: variants.length, approvedMasters: masters.length, missingMedia, candidates: active.filter((a) => a.status === "candidate").length, qualityIssues } }; return inspectionSchema.parse({ productId, version: ORCHESTRATOR_VERSION, ...state, stateSha256: contentHash({ state, facts, variants, assets: assets.map((a) => ({ id: a.id, sha256: a.sha256, status: a.status })), revisions: revisions.map((r) => ({ id: r.id, sha256: r.sha256, current: r.current, decision: r.decision })) }), checkedAt: nowIso(), boundaries: ["Monitoring regułowy; bez zewnętrznego LLM i automatycznych akceptacji.", "Monitoring sprawdza ewidencję i zależności. Integralność bajtów jest sprawdzana przy przygotowaniu i wykonaniu operacji.", "Płatne AI i dispatch Base są wstrzymane; wideo i certyfikacja wymagają odrębnego procesu."] }); } export function monitorProducts(db: Db, cfg: AppConfig): void { const products = db.prepare("SELECT id FROM products WHERE status='active' ORDER BY id").all() as { id: string }[]; for (const product of products) { const inspection = inspectProduct(db, cfg, product.id); db.prepare("INSERT INTO orchestrator_inspections(product_id,state_sha256,inspection_json,checked_at) VALUES(?,?,?,?) ON CONFLICT(product_id) DO UPDATE SET state_sha256=excluded.state_sha256,inspection_json=excluded.inspection_json,checked_at=excluded.checked_at") .run(product.id, inspection.stateSha256, JSON.stringify(inspection), inspection.checkedAt); } } export function interpretDirective(db: Db, productId: string, text: string): DirectiveResult { productRow(db, productId); const examples = ["Przygotuj opis pod Allegro", "Uzupełnij brakujące warianty", "Sprawdź jakość mediów", "Przygotuj kartę PDF", "Ustaw szerokość na 215 cm; źródło: pomiar hali"]; const normalized = text.trim().toLocaleLowerCase("pl").replace(/[.!?]+$/, "").normalize("NFD").replace(/[\u0300-\u036f]/g, "").replaceAll("ł", "l"); const result = (action: ActionRequest | null, message: string): DirectiveResult => ({ action, message, examples }); if (/\b(nie|bez|zamiast|ignoruj|pomin)\b/.test(normalized) || /\b(i|oraz|potem)\b/.test(normalized)) return result(null, "Podaj jedną jednoznaczną operację. Nie wykonuję poleceń negowanych ani wieloetapowych bez oddzielnych podglądów."); if (/^(przygotuj|dostosuj|odswiez|zapisz) (opis|opisy|tresc|tresci|oferte)( (pod|dla|do|na))?( allegro| base| baselinker| woocommerce| google| meta)?$/.test(normalized)) { const channel = normalized.match(/(allegro|woocommerce|google|meta)$/)?.[1] as "allegro" | "woocommerce" | "google" | "meta" | undefined; return result({ kind: "compose_offer", channel: channel ?? (/base(linker)?$/.test(normalized) ? "main" : "all") }, "Przygotuję regułowy projekt treści z faktów. Zatwierdzenie zapisze rewizję, bez publikacji."); } if (/^(uzupelnij|przygotuj|generuj|wygeneruj) (brakujace )?(warianty|media wariantow|recolory)$/.test(normalized)) return result({ kind: "recolor_missing" }, "Przygotuję dokładny zakres pustych miejsc. Nie regeneruję zatwierdzonych ani odrzuconych wersji."); if (/^(sprawdz|ocen) (jakosc|jakosc mediow|media)$/.test(normalized)) return result({ kind: "review_media" }, "Przygotuję lokalny pre-screening Maciusia, bez zmiany akceptacji."); if (/^(przygotuj|odswiez|wygeneruj) (karte|karte pdf|karte produktu|pdf)$/.test(normalized)) return result({ kind: "render_card" }, "Przygotuję kartę z aktualnych faktów i zatwierdzonych materiałów."); if (/^(wyslij|eksportuj) (do )?base(linker)?$/.test(normalized)) return result({ kind: "export_base" }, "Pokażę blokady wysyłki. Ta dyspozycja nie jest zgodą na dispatch."); const shot = /^(?:przygotuj|wygeneruj|zlec) (?:ujecie|render) (hero|packshot_front|packshot_34|side|back|sleep|storage|detail)$/.exec(normalized); if (shot) return result({ kind: "generate_shot", role: shot[1] as typeof OFFER_ROLES[number] }, "Przygotuję referencje i pokażę bramkę kosztów. Płatna generacja pozostaje wstrzymana."); if (/^potwierdz (fakty|niezweryfikowane fakty)$/.test(normalized)) { const keys = getFacts(db, productId).filter((f) => f.status === "unverified" && f.sourceRef && f.value !== null).map((f) => f.key).slice(0, 100); return keys.length ? result({ kind: "confirm_facts", keys }, "Pokażę wartości i dowody do decyzji uprawnionego człowieka.") : result(null, "Brak niezweryfikowanych faktów ze źródłami. Uzupełnij dane, zanim je potwierdzisz."); } const correction = /^(?:ustaw|zmien) (.+?) (?:na|=) (.+?); zrodlo: (.+)$/.exec(normalized); if (correction) { const aliases: Record = { szerokosc: "dimensions.width_cm", glebokosc: "dimensions.depth_cm", wysokosc: "dimensions.height_with_cushions_cm", cena: "commerce.price_pln" }; const factKey = aliases[correction[1]!] ?? correction[1]!; const raw = text.match(/(?:na|=)\s+(.+?);\s*źr[oó]d[lł]o:\s*(.+)$/i); const valueText = raw?.[1] ?? correction[2]!; const number = /^(\d+(?:[.,]\d+)?)\s*(cm|pln|zl|zł)?$/i.exec(valueText); if (!number || (!aliases[correction[1]!] && !/^(dimensions|sleeping)\.[a-z_]+_cm$/.test(factKey))) return result(null, "Nie rozpoznaję jednoznacznie parametru lub wartości. Użyj formularza korekty faktu."); const unit = factKey === "commerce.price_pln" ? "PLN" : "cm"; if (number[2] && (unit === "cm" ? number[2].toLowerCase() !== "cm" : number[2].toLowerCase() === "cm")) return result(null, "Jednostka nie pasuje do parametru. Nie przeliczam ani nie zmieniam jej po cichu."); return result({ kind: "set_fact", key: factKey, value: Number(number[1]!.replace(",", ".")), unit, status: "unverified", sourceKind: "owner", sourceRef: raw?.[2] ?? correction[3]! }, "Przygotuję korektę jako niezweryfikowaną z podanym źródłem. Potwierdzenie faktu jest osobną decyzją uprawnionego człowieka."); } return result(null, "Nie rozpoznaję bezpiecznie tej dyspozycji. Wybierz konkretną rekomendację lub formularz operacji; nie zgaduję danych ani zakresu."); }