Every 'being written in parallel' / 'ASSUMED SIGNATURE' TODO is gone: each referenced module now exists and each assumption was checked against it rather than the comment simply deleted. - logo/presets/retouch: renderer typed as the real Renderer contract instead of unknown / (...args: never[]). - director: the ModelRegistry assumption is VERIFIED against the installed @earendil-works types (find() on ModelRegistry, getModel() on ModelRuntime). - strings: the img2imgBug TODO had it backwards -- drawthings raises a graded message a static string cannot express, and this is the fallback. - poster: an Italian hint was being spliced into an English art prompt, which degrades these models. New director.refineArtPrompt() folds the note in via the LLM and falls back to the old splice on any failure, so it can only improve on the previous behaviour. scrubArtPrompt still runs either way. Remaining TODOs are one category only (Italian strings living in per-file tables rather than ui/strings.ts) and are accurate, not stale.
1628 lines
65 KiB
TypeScript
1628 lines
65 KiB
TypeScript
/**
|
||
* director.ts — the ART DIRECTOR.
|
||
*
|
||
* One structured-output LLM call turns a plain-language Italian brief into a validated
|
||
* DesignSpec. Everything downstream is a pure function of that object, so this file is
|
||
* where output quality is actually decided: the prompt below is the product.
|
||
*
|
||
* ---------------------------------------------------------------------------
|
||
* WHY THE MODEL IS NOT TRUSTED WITH EVERYTHING
|
||
* ---------------------------------------------------------------------------
|
||
* The LLM is asked for a *narrower* object than DesignSpec (see DirectorOutputSchema):
|
||
*
|
||
* - it names a palette MOOD, never hex values — palettes.ts already proved every
|
||
* shipped palette clears WCAG AA, and a model-invented `#7a7a7a` would not;
|
||
* - it picks fonts from StringEnums built from FONTS, so a caps-only family can never
|
||
* be sampled into the body slot in the first place;
|
||
* - it never emits `slug` (slugify() owns that pattern), `seed` (we pin it so draft
|
||
* and final are the same image), `source`/`photoPath` (the brief owns those) or
|
||
* `logo` (the preset owns that).
|
||
*
|
||
* The narrowed output is then assembled into a real DesignSpec and validated against
|
||
* DesignSpecSchema — the fixed contract. A mismatch is fed back verbatim for exactly one
|
||
* retry, after which we give up with an Italian error rather than shipping a broken job.
|
||
*
|
||
* On top of schema validation there is a deterministic repair pass (reconcileBlocks,
|
||
* scrubArtPrompt, ensureNegative). Schemas constrain SHAPE; only code can enforce
|
||
* "did not invent a venue" and "did not ask the diffusion model to draw a word".
|
||
*
|
||
* ---------------------------------------------------------------------------
|
||
* WHICH MODEL RUNS THIS
|
||
* ---------------------------------------------------------------------------
|
||
* `cfg.directorProvider` / `cfg.directorModel` choose the provider and model; when they
|
||
* are unset we fall back to whatever model pi currently has loaded (`ctx.model`).
|
||
*
|
||
* The user's subscription is **opencode Go** ($10/month), which pi-ai already ships as
|
||
* the built-in provider id `opencode-go` (OpenAI-compatible, base
|
||
* `https://opencode.ai/zen/go/v1`, credential `OPENCODE_API_KEY`, ~23 text models).
|
||
* It contains ZERO image models, so it can only ever be the art director — never the
|
||
* renderer. To register it with pi, either:
|
||
*
|
||
* 1. export OPENCODE_API_KEY=... # ambient, picked up automatically, or
|
||
* 2. add to ~/.pi/agent/auth.json:
|
||
* { "opencode-go": { "type": "api_key", "key": "..." } }
|
||
*
|
||
* then in ~/.pi/agent/pi-imgen.json:
|
||
*
|
||
* { "directorProvider": "opencode-go", "directorModel": "glm-5.3" }
|
||
*
|
||
* On a pi build old enough not to know `opencode-go`, declare it as a custom provider in
|
||
* ~/.pi/agent/models.json instead (api "openai-completions", baseUrl
|
||
* "https://opencode.ai/zen/go/v1", apiKey env OPENCODE_API_KEY) and point
|
||
* `directorProvider` at whatever id you gave it.
|
||
*/
|
||
|
||
import { Type, type Static } from "typebox";
|
||
import {
|
||
StringEnum,
|
||
contentText,
|
||
parseJsonWithRepair,
|
||
validateToolArguments,
|
||
type Api,
|
||
type AssistantMessage,
|
||
type AuthResult,
|
||
type Context,
|
||
type Message,
|
||
type Model,
|
||
type Tool,
|
||
} from "@earendil-works/pi-ai";
|
||
|
||
import type { ImgenConfig, Preset } from "../config.ts";
|
||
import { S, errorText } from "../ui/strings.ts";
|
||
import {
|
||
foldAccents,
|
||
formatItalianDate,
|
||
parseDate,
|
||
slugify,
|
||
type ParsedDate,
|
||
} from "../job.ts";
|
||
import {
|
||
BLOCK_ROLES,
|
||
BlockSchema,
|
||
DesignSpecSchema,
|
||
FORMATS,
|
||
TEMPLATES,
|
||
type Block,
|
||
type DesignSpec,
|
||
type Format,
|
||
type Palette,
|
||
type TemplateName,
|
||
} from "./spec.ts";
|
||
import { FONTS, PAIRINGS, bodyFamilies, byFamily, displayFamilies } from "./fonts.ts";
|
||
import {
|
||
DEFAULT_PALETTE_NAME,
|
||
PALETTES,
|
||
PALETTE_NAMES,
|
||
mergePresetDetailed,
|
||
paletteByName,
|
||
paletteFor,
|
||
} from "./palettes.ts";
|
||
|
||
export type BlockRole = (typeof BLOCK_ROLES)[number];
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Public types
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/**
|
||
* What the guided form collects. Every field is optional because the form lets him skip
|
||
* anything; `freeText` is the "Altro / contesto" box every form must have, and is also
|
||
* where text typed after the command lands.
|
||
*/
|
||
export interface Brief {
|
||
/** Which command produced this brief. Changes the art direction, not the schema. */
|
||
kind?: "poster" | "logo" | "social";
|
||
title?: string;
|
||
subtitle?: string;
|
||
date?: string;
|
||
venue?: string;
|
||
details?: string;
|
||
price?: string;
|
||
footer?: string;
|
||
/** Free-form context: tone, audience, what must NOT appear. */
|
||
freeText?: string;
|
||
/** Chosen in the form; when absent the director picks one. */
|
||
format?: Format;
|
||
/** Locked by the user during a refinement; the director must not override it. */
|
||
template?: TemplateName;
|
||
/** Name of the brand preset to apply, as a key of `cfg.presets`. */
|
||
preset?: string;
|
||
/** An existing photo to use instead of generated artwork. */
|
||
photoPath?: string;
|
||
/** Pin the artwork seed (re-render of an approved draft). */
|
||
seed?: number;
|
||
}
|
||
|
||
/**
|
||
* A repair the deterministic pass had to make. `code` is stable and machine-readable;
|
||
* `italian` is ready to show.
|
||
*
|
||
* TODO: move to `S.director` in ui/strings.ts. Kept together here so the move is a
|
||
* single cut-and-paste — nothing else in this file contains Italian.
|
||
* When it grows one, delete WARNING_TEXT below and render from `code` + `params` there.
|
||
*/
|
||
export interface DirectorWarning {
|
||
code: DirectorWarningCode;
|
||
italian: string;
|
||
params?: Record<string, string>;
|
||
}
|
||
|
||
export type DirectorWarningCode =
|
||
| "font-display-replaced"
|
||
| "font-body-replaced"
|
||
| "palette-unknown"
|
||
| "template-unknown"
|
||
| "format-unknown"
|
||
| "art-prompt-scrubbed"
|
||
| "art-prompt-empty"
|
||
| "negative-completed"
|
||
| "block-restored"
|
||
| "block-invented"
|
||
| "block-empty"
|
||
| "title-restored"
|
||
| "preset-applied"
|
||
| "retried";
|
||
|
||
export interface DirectResult {
|
||
spec: DesignSpec;
|
||
/** One Italian line from the director: why these choices. For "Ho capito così". */
|
||
note?: string;
|
||
warnings: DirectorWarning[];
|
||
/** "provider/model" actually used — worth logging, and worth showing in /doctor. */
|
||
model: string;
|
||
/** 1 when the first attempt validated, 2 when the retry saved it. */
|
||
attempts: number;
|
||
elapsedMs: number;
|
||
}
|
||
|
||
/** Confidence is reported honestly: a missing venue stays missing. */
|
||
export type Confidence = "alta" | "media" | "bassa";
|
||
|
||
export interface ParsedBrief {
|
||
/** Pre-filled form fields. A field is present ONLY when it was really in the text. */
|
||
fields: Partial<Record<BlockRole, string>>;
|
||
/** Whatever was left over — tone, audience, wishes. Goes in "Altro / contesto". */
|
||
freeText: string;
|
||
format?: Format;
|
||
/** Structured form of `fields.date`, for slugs. The DISPLAYED date stays his wording. */
|
||
dateParsed?: ParsedDate;
|
||
confidence: Confidence;
|
||
/** Roles the text simply did not mention. The form should ask for these. */
|
||
missing: BlockRole[];
|
||
/** True when the LLM was unavailable and the local regex fallback produced this. */
|
||
offline: boolean;
|
||
}
|
||
|
||
export interface CaptionResult {
|
||
/** The post copy, Italian, no hashtags inside it. */
|
||
text: string;
|
||
/** Lowercase, accent-free, no leading '#'. */
|
||
hashtags: string[];
|
||
/** text + blank line + "#a #b #c" — exactly what goes into caption.txt. */
|
||
full: string;
|
||
}
|
||
|
||
/**
|
||
* The slice of pi's ExtensionContext this module needs. `modelRegistry` is deliberately
|
||
* `unknown`: pi's ModelRegistry is a compatibility facade whose exact surface varies by
|
||
* version, so we probe for methods at runtime instead of compiling against them. An
|
||
* ExtensionContext / ExtensionCommandContext is structurally assignable to this.
|
||
*/
|
||
export interface DirectorContext {
|
||
modelRegistry?: unknown;
|
||
model?: Model<Api> | undefined;
|
||
signal?: AbortSignal | undefined;
|
||
}
|
||
|
||
/** Anything that stopped the director. `italian` is what the user is shown. */
|
||
export class DirectorError extends Error {
|
||
constructor(message: string, readonly italian: string, readonly detail?: string) {
|
||
super(message);
|
||
this.name = "DirectorError";
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// The narrowed schema the model actually fills in
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/**
|
||
* Built from FONTS at module load, so adding a family to fonts.ts changes what the
|
||
* director may choose with no edit here. `bodyFamilies()` already excludes caps-only
|
||
* faces, which is the guarantee that a caps-only family can never reach a body block.
|
||
*/
|
||
const DISPLAY_FAMILIES = displayFamilies();
|
||
const BODY_FAMILIES = bodyFamilies();
|
||
|
||
export const DIRECTOR_TOOL_NAME = "emit_design";
|
||
|
||
export const DirectorOutputSchema = Type.Object(
|
||
{
|
||
note: Type.String({
|
||
description:
|
||
"ONE short sentence in ITALIAN explaining the choice, shown to the user as 'Ho capito così'.",
|
||
}),
|
||
format: StringEnum([...FORMATS], {
|
||
description: "Primary output format. Use 'a3-portrait' for a printed event poster.",
|
||
}),
|
||
template: StringEnum([...TEMPLATES], {
|
||
description: "Layout template. See the guidance in the system prompt.",
|
||
}),
|
||
palette_mood: StringEnum(PALETTE_NAMES, {
|
||
description:
|
||
"Name of a curated palette. Never invent hex colours: the palette settles the colours AND suggests the artwork's colour direction.",
|
||
}),
|
||
fonts: Type.Object({
|
||
display: StringEnum(DISPLAY_FAMILIES, { description: "Headline family." }),
|
||
body: StringEnum(BODY_FAMILIES, {
|
||
description: "Family for every non-headline line. Caps-only families are not in this list.",
|
||
}),
|
||
}),
|
||
art: Type.Object({
|
||
prompt: Type.String({
|
||
description:
|
||
"ENGLISH. The ARTWORK ONLY: subject, setting, light, colour, medium, composition. Must never request text, words, letters, numbers, signage or typography.",
|
||
}),
|
||
negative: Type.String({
|
||
description:
|
||
"ENGLISH, comma separated. Must start with: text, letters, words, typography, watermark, signature.",
|
||
}),
|
||
}),
|
||
blocks: Type.Array(BlockSchema, {
|
||
minItems: 1,
|
||
description:
|
||
"The real text, in ITALIAN. Dates, venues and prices are copied from the brief CHARACTER FOR CHARACTER.",
|
||
}),
|
||
},
|
||
{ $id: "DirectorOutput" },
|
||
);
|
||
|
||
export type DirectorOutput = Static<typeof DirectorOutputSchema>;
|
||
|
||
const FreeTextSchema = Type.Object(
|
||
{
|
||
title: Type.Optional(Type.String({ description: "The event name, if stated." })),
|
||
subtitle: Type.Optional(Type.String()),
|
||
date: Type.Optional(
|
||
Type.String({ description: "VERBATIM substring of the input, e.g. 'sabato 12 settembre'." }),
|
||
),
|
||
venue: Type.Optional(Type.String({ description: "VERBATIM. Never guess a place." })),
|
||
details: Type.Optional(Type.String()),
|
||
price: Type.Optional(Type.String()),
|
||
footer: Type.Optional(Type.String()),
|
||
rest: Type.String({ description: "Everything left over: tone, audience, wishes. May be empty." }),
|
||
format: Type.Optional(StringEnum([...FORMATS])),
|
||
confidence: StringEnum(["alta", "media", "bassa"], {
|
||
description: "How sure you are. Use 'bassa' when you had to guess anything at all.",
|
||
}),
|
||
},
|
||
{ $id: "FreeTextBrief" },
|
||
);
|
||
|
||
const CaptionSchema = Type.Object(
|
||
{
|
||
caption: Type.String({ description: "ITALIAN post copy. No hashtags inside it." }),
|
||
hashtags: Type.Array(Type.String(), { minItems: 3, maxItems: 12 }),
|
||
},
|
||
{ $id: "Caption" },
|
||
);
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Prompt construction — this is the part that decides quality
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/** The six words the negative prompt must always contain, per the DesignSpec contract. */
|
||
export const REQUIRED_NEGATIVE = [
|
||
"text",
|
||
"letters",
|
||
"words",
|
||
"typography",
|
||
"watermark",
|
||
"signature",
|
||
] as const;
|
||
|
||
/**
|
||
* Words that mean "draw something readable". A clause containing one of these is cut out
|
||
* of art.prompt before it ever reaches the diffusion model. Word-boundary matched so
|
||
* "design" is not caught by "sign".
|
||
*/
|
||
const LETTERING_RE = new RegExp(
|
||
"\\b(" +
|
||
[
|
||
"text", "texts", "lettering", "letter", "letters", "word", "words",
|
||
"typography", "typographic", "typeface", "font", "fonts",
|
||
"caption", "captions", "headline", "headlines", "title", "titles", "subtitle",
|
||
"signage", "sign", "signs", "billboard", "billboards", "placard", "placards",
|
||
"banner", "banners", "marquee", "slogan", "slogans", "graffiti",
|
||
"says", "saying", "spells", "spelled", "spelling", "inscribed", "inscription",
|
||
"writing", "written", "calligraphy", "handwriting", "handwritten",
|
||
"label", "labels", "logo", "logos", "watermark", "signature",
|
||
"poster", "posters", "flyer", "leaflet", "book cover", "magazine", "newspaper",
|
||
"menu", "menus", "ticket", "tickets", "receipt", "certificate",
|
||
"number", "numbers", "numeral", "digit", "digits",
|
||
].join("|") +
|
||
")\\b",
|
||
"i",
|
||
);
|
||
|
||
function fontCatalogue(): string {
|
||
return FONTS.map((f) => {
|
||
const slots: string[] = [];
|
||
if (f.display) slots.push("display");
|
||
if (f.body && !f.capsOnly) slots.push("body");
|
||
const caps = f.capsOnly ? " [CAPS-ONLY — never as body]" : "";
|
||
return `- ${f.family} (${f.mood}) — ${slots.join(" + ") || "display"}${caps}. ${f.note}`;
|
||
}).join("\n");
|
||
}
|
||
|
||
function pairingCatalogue(): string {
|
||
return PAIRINGS.map((p) => `- ${p.mood}: ${p.display} + ${p.body}`).join("\n");
|
||
}
|
||
|
||
function paletteCatalogue(): string {
|
||
return PALETTES.map(
|
||
(p) => `- "${p.name}" — ${p.mood}\n artwork should read as: ${p.artHint}`,
|
||
).join("\n");
|
||
}
|
||
|
||
/**
|
||
* When each template suits an event. Written as advice rather than rules, because the
|
||
* StringEnum already makes an invalid value impossible and what the model actually needs
|
||
* is a reason to prefer one.
|
||
*/
|
||
const TEMPLATE_GUIDE = `- hero-bottom — artwork full-bleed, all text in a band along the bottom.
|
||
The default. Safest with a long title, safest with photographic or painterly artwork,
|
||
and the one that survives being reshared on a phone. Choose it when in doubt.
|
||
- banded — a solid colour band straight across the middle.
|
||
Loud and cheap-and-cheerful: sagre, feste di piazza, mercatini. Also the rescue when
|
||
the artwork is busy edge to edge and nothing would sit legibly on top of it.
|
||
- framed — artwork inset inside a coloured frame.
|
||
Formal and quiet: teatro, mostre, concerti di musica classica. The frame gives small
|
||
print (patrocini, sponsor, orari) somewhere to live without crowding the image.
|
||
- centred-stack — the type stack centred over the artwork.
|
||
Only when the artwork has a calm, empty middle: a sky, a wall, fog, a flat colour
|
||
field. Elegant and modern, and merciless if the artwork is busy.
|
||
- split — a hard split, artwork on one half, flat colour on the other.
|
||
Editorial and graphic: club nights, rassegne, anything with a lot of text to place.
|
||
Good when there are five or six lines to fit and none of them can be dropped.`;
|
||
|
||
/**
|
||
* The hand-written few-shot. This is the single highest-leverage thing in the file: the
|
||
* model copies its density, its restraint and its verbatim handling of the date far more
|
||
* reliably than it follows any rule written in prose.
|
||
*/
|
||
const FEW_SHOT_BRIEF = `Titolo: Sagra della Castagna
|
||
Data e ora: sabato 12 e domenica 13 ottobre
|
||
Dove: Piazza della Repubblica, Rocca di Papa
|
||
Prezzo: Ingresso libero
|
||
Altro / contesto: la fa la pro loco, stand gastronomici dalla mezzogiorno, la sera c'e la musica dal vivo, e una cosa di paese, deve essere allegra e calda, niente robe moderne`;
|
||
|
||
const FEW_SHOT_OUTPUT: DirectorOutput = {
|
||
note: "Sagra di paese: colori caldi da luna park, titolo grasso che si legge dall'altro lato della piazza.",
|
||
format: "a3-portrait",
|
||
template: "hero-bottom",
|
||
palette_mood: "Sagra paesana",
|
||
fonts: { display: "Alfa Slab One", body: "Outfit" },
|
||
art: {
|
||
prompt:
|
||
"warm autumn still life in an Italian village square at golden hour, a woven wicker basket spilling glossy chestnuts across a rustic wooden table, a perforated roasting pan over glowing embers, thin curl of wood smoke, strings of small warm bulb lights blurred in the background, red and white checked cloth, painterly illustration in warm browns cream and deep red, soft low evening light, uncluttered composition with generous empty space across the lower third",
|
||
negative:
|
||
"text, letters, words, typography, watermark, signature, signage, numbers, faces, modern packaging, plastic, harsh flash, cluttered background, low contrast mush",
|
||
},
|
||
blocks: [
|
||
{ role: "title", text: "Sagra della Castagna" },
|
||
{ role: "subtitle", text: "Rocca di Papa" },
|
||
{ role: "date", text: "sabato 12 e domenica 13 ottobre" },
|
||
{ role: "venue", text: "Piazza della Repubblica" },
|
||
{ role: "details", text: "Stand gastronomici dalle 12 · Musica dal vivo la sera", optional: true },
|
||
{ role: "price", text: "Ingresso libero" },
|
||
{ role: "footer", text: "A cura della Pro Loco", optional: true },
|
||
],
|
||
};
|
||
|
||
const FEW_SHOT_NOTES = `Why that example is right:
|
||
- the date is CHARACTER FOR CHARACTER what he typed — not normalised to "12-13 ottobre",
|
||
not expanded with a year he never mentioned;
|
||
- "Rocca di Papa" was promoted to subtitle because the venue line already carries the
|
||
square, and a poster read from across a piazza wants the town name big;
|
||
- "stand gastronomici dalla mezzogiorno" was tidied into "dalle 12" — that is tightening
|
||
wording he gave, which is allowed. Inventing "ore 12:00 - 23:00" would not be;
|
||
- the Pro Loco appears only because he mentioned it, and is marked optional:true because
|
||
the poster still works without it;
|
||
- the artwork is a still life. Not a poster, not a sign, not a festival banner: things
|
||
that would tempt the model into drawing letters;
|
||
- the last clause of the art prompt reserves empty space exactly where hero-bottom puts
|
||
the text band. Composition and template are chosen together.`;
|
||
|
||
/**
|
||
* The system prompt. English, per the project rule that model-facing text stays English;
|
||
* everything the model WRITES for the user stays Italian, which the prompt says loudly.
|
||
*/
|
||
export function buildDirectorSystemPrompt(options: { kind?: Brief["kind"]; tone?: string } = {}): string {
|
||
const kind = options.kind ?? "poster";
|
||
const kindLine =
|
||
kind === "logo"
|
||
? "You are designing a LOGO: one strong mark, at most two lines of text, no dates, no venue."
|
||
: kind === "social"
|
||
? "You are designing SOCIAL images: read on a phone, at a glance, thumb-sized. Fewer words than a poster."
|
||
: "You are designing a PRINTED EVENT POSTER, read from three metres away on a wall.";
|
||
|
||
return `You are the art director of a small Italian design studio. Your client organises
|
||
local events — sagre, concerti, mostre, serate in piazza. He is not a designer and not a
|
||
prompt engineer. He describes an event in plain Italian and expects a finished poster.
|
||
|
||
${kindLine}
|
||
|
||
You produce ONE JSON object matching the given schema. Nothing else: no prose, no
|
||
markdown fence, no explanation outside the object.
|
||
|
||
=== HOW THE PIPELINE WORKS (this changes what you must write) ===
|
||
A local diffusion model paints the ARTWORK ONLY. It never draws a single letter. The real
|
||
text is typeset afterwards with real fonts, from the \`blocks\` you return. That is why
|
||
Italian accents, dates and venue names are always correct — and why any request for
|
||
lettering in \`art.prompt\` produces garbled fake writing across the poster, which is the
|
||
single worst failure this system can have.
|
||
|
||
=== 1. TEXT: PRESERVE, DO NOT INVENT ===
|
||
- \`blocks[].text\` is ITALIAN and is what gets printed.
|
||
- The date, the venue, the price and the footer are copied from the brief CHARACTER FOR
|
||
CHARACTER: same words, same accents, same punctuation, same capitalisation. "sabato 12
|
||
settembre, ore 21" stays exactly that. Do not normalise it, do not add a year, do not
|
||
convert it to a numeric date, do not translate it.
|
||
- You MAY sharpen the TITLE: drop filler, fix capitalisation, cut it to something a person
|
||
can read from across a square. You may not rename the event.
|
||
- You may NEVER invent a fact that is not in the brief: no venue, no time, no price, no
|
||
sponsor, no phone number, no website, no "prenotazione obbligatoria". If he did not say
|
||
it, the block does not exist. A poster with four lines is better than a poster with six
|
||
lines two of which are lies.
|
||
- One block per role, at most. Order does not matter; priority does.
|
||
- Mark a block \`optional: true\` when the poster still works without it — details, footer,
|
||
price, sometimes subtitle. Never mark the title or the date optional. The renderer drops
|
||
optional blocks when space runs short, so this is how you tell it what is expendable.
|
||
- No emoji. No ALL CAPS in the text: the template decides case, not you.
|
||
|
||
=== 2. FONTS: ONLY THE BUNDLED FAMILIES ===
|
||
Choose \`fonts.display\` and \`fonts.body\` from these, and nothing else:
|
||
|
||
${fontCatalogue()}
|
||
|
||
Families marked CAPS-ONLY draw capitals at lowercase codepoints. They are headline faces
|
||
and must NEVER be used as \`fonts.body\` — a caps-only body line is unreadable.
|
||
|
||
Prefer one of these curated pairings unless the brief clearly calls for something else.
|
||
They are proven, and each one has a palette of the same name:
|
||
|
||
${pairingCatalogue()}
|
||
|
||
=== 3. PALETTE: NAME A MOOD, NEVER A COLOUR ===
|
||
\`palette_mood\` must be one of these names exactly. Each was proved to clear WCAG AA
|
||
contrast, so naming one is what guarantees the text stays readable:
|
||
|
||
${paletteCatalogue()}
|
||
|
||
Pick the palette and the fonts together — the pairing named after the palette is almost
|
||
always the right answer. The palette's "artwork should read as" line tells you the colour
|
||
direction \`art.prompt\` must agree with; a warm sagra palette under cold blue artwork looks
|
||
broken.
|
||
|
||
=== 4. TEMPLATE ===
|
||
${TEMPLATE_GUIDE}
|
||
|
||
=== 5. ART PROMPT: ARTWORK ONLY, IN ENGLISH ===
|
||
\`art.prompt\` is English, 40 to 80 words, one flowing comma-separated description of:
|
||
subject, setting, time of day and light, colour direction (agreeing with the palette),
|
||
medium or style (painterly illustration, risograph, photograph, linocut, collage…), and
|
||
composition — including where you are leaving EMPTY SPACE for the text band the template
|
||
will put there.
|
||
|
||
FORBIDDEN in \`art.prompt\`, without exception: text, letters, words, numbers, dates,
|
||
typography, fonts, captions, titles, signage, street signs, billboards, banners with
|
||
writing, labels, logos, watermarks, book or magazine covers, and "a poster of…". Describe
|
||
a scene or an object, never a printed thing. If the event is a concert, describe the
|
||
instrument, the light, the crowd's silhouette — not the gig poster.
|
||
|
||
Also avoid: recognisable real people, celebrities, brand marks, and anything that would
|
||
put a face in the middle of the composition where the title goes.
|
||
|
||
\`art.negative\` is English, comma separated, and MUST begin with exactly:
|
||
text, letters, words, typography, watermark, signature
|
||
then add whatever else this particular image needs to avoid.
|
||
|
||
=== 6. NOTE ===
|
||
\`note\` is ONE short sentence in Italian, addressed to him, saying what you understood and
|
||
why you chose this look. It is shown under the heading "Ho capito così". Warm and plain:
|
||
no jargon, no font names he does not know, no English.
|
||
|
||
=== WORKED EXAMPLE ===
|
||
Brief:
|
||
${FEW_SHOT_BRIEF}
|
||
|
||
Correct output:
|
||
${JSON.stringify(FEW_SHOT_OUTPUT, null, 2)}
|
||
|
||
${FEW_SHOT_NOTES}
|
||
${options.tone ? `\n=== HOUSE TONE (from his saved style — respect it) ===\n${options.tone}\n` : ""}`;
|
||
}
|
||
|
||
/** The brief, rendered the way the form labels it, so the model sees his own words. */
|
||
export function formatBrief(brief: Brief): string {
|
||
const lines: string[] = [];
|
||
const push = (label: string, value?: string) => {
|
||
const v = value?.trim();
|
||
if (v) lines.push(`${label}: ${v}`);
|
||
};
|
||
push("Titolo", brief.title);
|
||
push("Sottotitolo", brief.subtitle);
|
||
push("Data e ora", brief.date);
|
||
push("Dove", brief.venue);
|
||
push("Dettagli", brief.details);
|
||
push("Prezzo", brief.price);
|
||
push("In fondo", brief.footer);
|
||
push("Altro / contesto", brief.freeText);
|
||
if (brief.format) lines.push(`Formato richiesto: ${brief.format}`);
|
||
if (brief.template) lines.push(`Impaginazione già scelta (non cambiarla): ${brief.template}`);
|
||
if (brief.photoPath) {
|
||
lines.push(
|
||
"NOTA: userà una sua fotografia al posto di un'immagine generata. Scrivi comunque " +
|
||
"art.prompt descrivendo la fotografia ideale, servirà come ripiego.",
|
||
);
|
||
}
|
||
return lines.join("\n");
|
||
}
|
||
|
||
/** Everything he actually typed, folded — the corpus every anti-invention check runs on. */
|
||
function briefCorpus(brief: Brief): string {
|
||
return foldAccents(
|
||
[
|
||
brief.title, brief.subtitle, brief.date, brief.venue,
|
||
brief.details, brief.price, brief.footer, brief.freeText,
|
||
]
|
||
.filter(Boolean)
|
||
.join(" \n "),
|
||
);
|
||
}
|
||
|
||
/**
|
||
* True when `text` is grounded in what he wrote: at least 60% of its meaningful tokens
|
||
* appear in the corpus. Cheap, accent-blind, and enough to catch a hallucinated venue
|
||
* without rejecting a legitimate rewording.
|
||
*/
|
||
function supportedByBrief(text: string, corpus: string): boolean {
|
||
const tokens = foldAccents(text)
|
||
.replace(/[^a-z0-9]+/g, " ")
|
||
.split(" ")
|
||
.filter((t) => t.length >= 3 && !STOPWORDS.has(t));
|
||
if (tokens.length === 0) return true; // "10 €", "ore 21" — nothing to check against
|
||
const hits = tokens.filter((t) => corpus.includes(t)).length;
|
||
return hits / tokens.length >= 0.6;
|
||
}
|
||
|
||
const STOPWORDS = new Set([
|
||
"con", "per", "del", "della", "dei", "delle", "dal", "dalla", "alle", "allo", "alla",
|
||
"una", "uno", "gli", "che", "non", "più", "piu", "dalle", "sul", "sulla", "nel", "nella",
|
||
]);
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Model plumbing
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/** Runtime probe of pi's ModelRegistry; see DirectorContext for why this is untyped. */
|
||
type Registry = Record<string, unknown>;
|
||
|
||
function registryOf(ctx: DirectorContext): Registry | undefined {
|
||
const r = ctx.modelRegistry;
|
||
return r && typeof r === "object" ? (r as Registry) : undefined;
|
||
}
|
||
|
||
function callable(reg: Registry | undefined, name: string): ((...args: unknown[]) => unknown) | undefined {
|
||
const fn = reg?.[name];
|
||
return typeof fn === "function" ? (fn as (...args: unknown[]) => unknown).bind(reg) : undefined;
|
||
}
|
||
|
||
/**
|
||
* Model ids we reach for when the user configured a provider but not a model. Substring
|
||
* match, best-first — a hint only, and the catalogue moves fast enough that the real fix
|
||
* is to set `directorModel` in the config.
|
||
*/
|
||
const MODEL_PREFERENCE = ["glm-5", "qwen3.7", "kimi-k", "deepseek-v4-pro", "minimax-m", "gpt-5"];
|
||
|
||
function resolveDirectorModel(cfg: ImgenConfig, ctx: DirectorContext): Model<Api> {
|
||
const reg = registryOf(ctx);
|
||
const wantProvider = cfg.directorProvider?.trim();
|
||
const wantModel = cfg.directorModel?.trim();
|
||
|
||
if (wantProvider) {
|
||
// VERIFIED against @earendil-works/pi-coding-agent: ModelRegistry declares
|
||
// find(provider, modelId) and ModelRuntime declares getModel(providerId, modelId).
|
||
// Both are probed because which object arrives depends on how the host built ctx.
|
||
const lookup = callable(reg, "getModel") ?? callable(reg, "find");
|
||
if (wantModel && lookup) {
|
||
const found = lookup(wantProvider, wantModel) as Model<Api> | undefined;
|
||
if (found) return found;
|
||
}
|
||
const list = callable(reg, "getModels");
|
||
const models = (list?.(wantProvider) ?? []) as readonly Model<Api>[];
|
||
if (models.length) {
|
||
if (wantModel) {
|
||
const exact = models.find((m) => m.id === wantModel);
|
||
if (exact) return exact;
|
||
}
|
||
for (const hint of MODEL_PREFERENCE) {
|
||
const hit = models.find((m) => m.id.includes(hint));
|
||
if (hit) return hit;
|
||
}
|
||
return models[0]!;
|
||
}
|
||
if (wantModel) {
|
||
throw new DirectorError(
|
||
`director model ${wantProvider}/${wantModel} not found in the registry`,
|
||
errorText(S.errors.directorUnavailable),
|
||
`${wantProvider}/${wantModel}`,
|
||
);
|
||
}
|
||
}
|
||
|
||
// Fall back to whatever pi currently has loaded.
|
||
if (ctx.model) return ctx.model;
|
||
|
||
throw new DirectorError(
|
||
"no director model: neither cfg.directorProvider/directorModel nor ctx.model resolved",
|
||
errorText(S.errors.directorUnavailable),
|
||
);
|
||
}
|
||
|
||
const DEFAULT_TIMEOUT_MS = 90_000;
|
||
|
||
function withTimeout(signal: AbortSignal | undefined, ms: number): AbortSignal | undefined {
|
||
const timeout = typeof AbortSignal.timeout === "function" ? AbortSignal.timeout(ms) : undefined;
|
||
if (!timeout) return signal;
|
||
if (!signal) return timeout;
|
||
// AbortSignal.any is Node >= 20.3; degrade to the caller's signal if it is missing.
|
||
return typeof AbortSignal.any === "function" ? AbortSignal.any([signal, timeout]) : signal;
|
||
}
|
||
|
||
/**
|
||
* One request. Prefers the registry's own `complete`/`completeSimple` (which resolve auth
|
||
* internally); falls back to resolving auth with `getProviderAuth(id)` and driving the
|
||
* provider's stream directly, which is the documented "use pi's credentials without a
|
||
* loaded model" path.
|
||
*/
|
||
async function complete(
|
||
model: Model<Api>,
|
||
context: Context,
|
||
ctx: DirectorContext,
|
||
opts: { maxTokens: number; temperature: number },
|
||
): Promise<AssistantMessage> {
|
||
const reg = registryOf(ctx);
|
||
const signal = withTimeout(ctx.signal, DEFAULT_TIMEOUT_MS);
|
||
const streamOptions = {
|
||
signal,
|
||
temperature: opts.temperature,
|
||
maxTokens: Math.min(opts.maxTokens, model.maxTokens || opts.maxTokens),
|
||
toolChoice: "auto" as const,
|
||
};
|
||
|
||
const direct = callable(reg, "complete") ?? callable(reg, "completeSimple");
|
||
if (direct) {
|
||
return (await direct(model, context, streamOptions)) as AssistantMessage;
|
||
}
|
||
|
||
const getProvider = callable(reg, "getProvider");
|
||
const provider = getProvider?.(model.provider) as
|
||
| { streamSimple?: (m: Model<Api>, c: Context, o?: unknown) => { result(): Promise<AssistantMessage> } }
|
||
| undefined;
|
||
if (!provider?.streamSimple) {
|
||
throw new DirectorError(
|
||
`provider ${model.provider} exposes no usable stream entry point`,
|
||
errorText(S.errors.directorUnavailable),
|
||
model.provider,
|
||
);
|
||
}
|
||
|
||
const getAuth = callable(reg, "getProviderAuth");
|
||
const resolved = (await getAuth?.(model.provider)) as AuthResult | undefined;
|
||
const auth = resolved?.auth;
|
||
// A resolved baseUrl overrides the catalogue's; everything else rides on the options.
|
||
const effective = auth?.baseUrl ? { ...model, baseUrl: auth.baseUrl } : model;
|
||
|
||
return await provider
|
||
.streamSimple(effective, context, {
|
||
...streamOptions,
|
||
apiKey: auth?.apiKey,
|
||
headers: auth?.headers,
|
||
env: resolved?.env,
|
||
})
|
||
.result();
|
||
}
|
||
|
||
/** The first balanced `{...}` in a string, or undefined. Survives prose and fences. */
|
||
function firstJsonObject(text: string): string | undefined {
|
||
const start = text.indexOf("{");
|
||
if (start < 0) return undefined;
|
||
let depth = 0;
|
||
let inString = false;
|
||
let escaped = false;
|
||
for (let i = start; i < text.length; i++) {
|
||
const c = text[i]!;
|
||
if (inString) {
|
||
if (escaped) escaped = false;
|
||
else if (c === "\\") escaped = true;
|
||
else if (c === '"') inString = false;
|
||
continue;
|
||
}
|
||
if (c === '"') inString = true;
|
||
else if (c === "{") depth++;
|
||
else if (c === "}" && --depth === 0) return text.slice(start, i + 1);
|
||
}
|
||
return undefined;
|
||
}
|
||
|
||
/**
|
||
* Pulls the structured object out of a reply: the tool call when the model made one,
|
||
* otherwise JSON out of the text. Both routes end in the same validator, so a model that
|
||
* cannot do tool calls still works.
|
||
*/
|
||
function extractStructured(message: AssistantMessage, tool: Tool): unknown {
|
||
const call = message.content.find(
|
||
(c): c is Extract<typeof c, { type: "toolCall" }> => c.type === "toolCall" && c.name === tool.name,
|
||
);
|
||
if (call) return validateToolArguments(tool, call);
|
||
|
||
const text = contentText(message.content);
|
||
const json = firstJsonObject(text);
|
||
if (!json) {
|
||
throw new Error(
|
||
`model returned no tool call and no JSON object (stopReason=${message.stopReason})` +
|
||
(message.errorMessage ? `: ${message.errorMessage}` : ""),
|
||
);
|
||
}
|
||
const parsed = parseJsonWithRepair<unknown>(json);
|
||
return validateToolArguments(tool, {
|
||
type: "toolCall",
|
||
id: "inline",
|
||
name: tool.name,
|
||
arguments: parsed as Record<string, unknown>,
|
||
});
|
||
}
|
||
|
||
/**
|
||
* One structured call with exactly one retry, feeding the validation error back verbatim.
|
||
* `finish` runs after schema validation and may throw its own Error — a DesignSpec that
|
||
* fails DesignSpecSchema is a mismatch like any other and earns the same retry.
|
||
*/
|
||
async function askStructured<T>(
|
||
ctx: DirectorContext,
|
||
model: Model<Api>,
|
||
systemPrompt: string,
|
||
userPrompt: string,
|
||
schema: Parameters<typeof Type.Object>[0] extends never ? never : Tool["parameters"],
|
||
toolName: string,
|
||
toolDescription: string,
|
||
finish: (raw: unknown) => T,
|
||
opts: { maxTokens: number; temperature: number },
|
||
): Promise<{ value: T; attempts: number }> {
|
||
const tool: Tool = {
|
||
name: toolName,
|
||
description: toolDescription,
|
||
parameters: schema,
|
||
// "prefer" rather than "require": opencode Go fronts a couple of dozen different
|
||
// models and not all of them honour strict json-schema sampling. The validator plus
|
||
// the retry is what actually guarantees the shape.
|
||
constrainedSampling: { type: "json_schema", strict: "prefer" },
|
||
};
|
||
|
||
const messages: Message[] = [{ role: "user", content: userPrompt, timestamp: Date.now() }];
|
||
let lastError: Error | undefined;
|
||
|
||
for (let attempt = 1; attempt <= 2; attempt++) {
|
||
const context: Context = { systemPrompt, messages: [...messages], tools: [tool] };
|
||
let reply: AssistantMessage;
|
||
try {
|
||
reply = await complete(model, context, ctx, opts);
|
||
} catch (e) {
|
||
if (ctx.signal?.aborted) throw new DirectorError("aborted", errorText(S.errors.cancelled));
|
||
throw new DirectorError(
|
||
`director request failed: ${(e as Error).message}`,
|
||
errorText(S.errors.directorUnavailable),
|
||
(e as Error).message,
|
||
);
|
||
}
|
||
|
||
if (reply.stopReason === "aborted") {
|
||
throw new DirectorError("aborted", errorText(S.errors.cancelled));
|
||
}
|
||
|
||
try {
|
||
return { value: finish(extractStructured(reply, tool)), attempts: attempt };
|
||
} catch (e) {
|
||
lastError = e as Error;
|
||
if (attempt === 2) break;
|
||
// Feed the failure back. The assistant turn is replayed so the model sees what it
|
||
// actually produced, then a user turn naming the exact violated constraint.
|
||
messages.push(reply);
|
||
messages.push({
|
||
role: "user",
|
||
content:
|
||
"That output was rejected. Fix it and return the corrected JSON object only, " +
|
||
"with no commentary:\n\n" +
|
||
lastError.message,
|
||
timestamp: Date.now(),
|
||
});
|
||
}
|
||
}
|
||
|
||
throw new DirectorError(
|
||
`director output failed validation twice: ${lastError?.message}`,
|
||
errorText(S.errors.directorFailed(lastError?.message)),
|
||
lastError?.message,
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// direct() — brief -> DesignSpec
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/**
|
||
* The one call the whole extension is built around.
|
||
*
|
||
* Throws DirectorError (with an Italian `italian` field) on an empty brief, an
|
||
* unreachable model, or two consecutive schema failures. Never returns a spec that would
|
||
* not validate.
|
||
*/
|
||
export async function direct(brief: Brief, cfg: ImgenConfig, ctx: DirectorContext): Promise<DirectResult> {
|
||
const started = Date.now();
|
||
|
||
const hasSomething = Boolean(
|
||
brief.title?.trim() || brief.freeText?.trim() || brief.subtitle?.trim() || brief.venue?.trim(),
|
||
);
|
||
if (!hasSomething) {
|
||
throw new DirectorError("empty brief", errorText(S.errors.briefEmpty));
|
||
}
|
||
|
||
const preset = brief.preset ? cfg.presets[brief.preset] : undefined;
|
||
if (brief.preset && !preset) {
|
||
throw new DirectorError(
|
||
`unknown preset ${brief.preset}`,
|
||
errorText(S.errors.presetUnknown(brief.preset)),
|
||
brief.preset,
|
||
);
|
||
}
|
||
|
||
const model = resolveDirectorModel(cfg, ctx);
|
||
const systemPrompt = buildDirectorSystemPrompt({ kind: brief.kind, tone: preset?.tone });
|
||
const userPrompt = `Brief:\n${formatBrief(brief)}\n\nReturn the JSON object.`;
|
||
|
||
const warnings: DirectorWarning[] = [];
|
||
let note: string | undefined;
|
||
|
||
const { value: spec, attempts } = await askStructured(
|
||
ctx,
|
||
model,
|
||
systemPrompt,
|
||
userPrompt,
|
||
DirectorOutputSchema,
|
||
DIRECTOR_TOOL_NAME,
|
||
"Emit the finished design specification for this event.",
|
||
(raw) => {
|
||
// A fresh warning list per attempt: repairs from a rejected attempt are not real.
|
||
warnings.length = 0;
|
||
const out = raw as DirectorOutput;
|
||
note = out.note?.trim() || undefined;
|
||
const assembled = assembleSpec(out, brief, preset, warnings);
|
||
// The fixed contract has the last word.
|
||
return validateToolArguments(
|
||
{ name: "DesignSpec", description: "", parameters: DesignSpecSchema },
|
||
{ type: "toolCall", id: "spec", name: "DesignSpec", arguments: assembled as unknown as Record<string, unknown> },
|
||
) as DesignSpec;
|
||
},
|
||
{ maxTokens: 3000, temperature: 0.6 },
|
||
);
|
||
|
||
if (attempts > 1) warnings.push(warn("retried"));
|
||
if (preset) warnings.push(warn("preset-applied", { nome: preset.label }));
|
||
|
||
return {
|
||
spec,
|
||
note,
|
||
warnings,
|
||
model: `${model.provider}/${model.id}`,
|
||
attempts,
|
||
elapsedMs: Date.now() - started,
|
||
};
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// The deterministic repair pass
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/** Turns the narrowed director output into a real DesignSpec, repairing as it goes. */
|
||
function assembleSpec(
|
||
out: DirectorOutput,
|
||
brief: Brief,
|
||
preset: Preset | undefined,
|
||
warnings: DirectorWarning[],
|
||
): DesignSpec {
|
||
const corpus = briefCorpus(brief);
|
||
|
||
// --- palette ------------------------------------------------------------
|
||
let moodName = out.palette_mood;
|
||
if (!paletteByName(moodName)) {
|
||
warnings.push(warn("palette-unknown", { nome: String(moodName) }));
|
||
moodName = DEFAULT_PALETTE_NAME;
|
||
}
|
||
let palette: Palette = paletteFor(moodName);
|
||
if (preset) {
|
||
const merged = mergePresetDetailed(palette, preset);
|
||
palette = merged.palette;
|
||
// mergePresetDetailed already phrases its own Italian warnings.
|
||
for (const w of merged.warnings) warnings.push({ code: "preset-applied", italian: w });
|
||
}
|
||
|
||
// --- fonts --------------------------------------------------------------
|
||
const fonts = chooseFonts(out.fonts, moodName, preset, warnings);
|
||
|
||
// --- template / format --------------------------------------------------
|
||
let template: TemplateName = brief.template ?? (out.template as TemplateName);
|
||
if (!(TEMPLATES as readonly string[]).includes(template)) {
|
||
warnings.push(warn("template-unknown", { nome: String(out.template) }));
|
||
template = "hero-bottom";
|
||
}
|
||
let format: Format = brief.format ?? (out.format as Format);
|
||
if (!(FORMATS as readonly string[]).includes(format)) {
|
||
warnings.push(warn("format-unknown", { nome: String(out.format) }));
|
||
format = "a3-portrait";
|
||
}
|
||
|
||
// --- blocks -------------------------------------------------------------
|
||
const blocks = reconcileBlocks(out.blocks ?? [], brief, corpus, warnings);
|
||
|
||
// --- art ----------------------------------------------------------------
|
||
const artHint = paletteByName(moodName)?.artHint;
|
||
const prompt = scrubArtPrompt(out.art?.prompt ?? "", artHint, warnings);
|
||
const negative = ensureNegative(out.art?.negative ?? "", warnings);
|
||
|
||
const title = blocks.find((b) => b.role === "title")?.text ?? brief.title ?? "senza titolo";
|
||
const dateText = blocks.find((b) => b.role === "date")?.text ?? brief.date;
|
||
// "sabato 12 e domenica 13 ottobre" would slug as "-sabato-12-e"; normalise it for the
|
||
// folder name only. The printed date is untouched.
|
||
const parsedDate = dateText ? parseSpokenDate(dateText) : undefined;
|
||
|
||
const spec: DesignSpec = {
|
||
slug: slugify(title, parsedDate ? formatItalianDate(parsedDate) : dateText),
|
||
format,
|
||
template,
|
||
palette,
|
||
fonts,
|
||
art: {
|
||
prompt,
|
||
negative,
|
||
seed: brief.seed ?? randomSeed(),
|
||
source: brief.photoPath ? "photo" : "generate",
|
||
...(brief.photoPath ? { photoPath: brief.photoPath } : {}),
|
||
},
|
||
blocks,
|
||
};
|
||
|
||
if (preset?.logo) {
|
||
spec.logo = { path: preset.logo, corner: "br", scale: 0.12 };
|
||
}
|
||
return spec;
|
||
}
|
||
|
||
/**
|
||
* Fonts, in order of trust: the preset (his brand), then the director's pick, then the
|
||
* palette's own pairing, then the safe default. A caps-only family reaching `body` is the
|
||
* one failure this must make impossible, so it is checked last and unconditionally.
|
||
*/
|
||
function chooseFonts(
|
||
picked: DirectorOutput["fonts"] | undefined,
|
||
moodName: string,
|
||
preset: Preset | undefined,
|
||
warnings: DirectorWarning[],
|
||
): DesignSpec["fonts"] {
|
||
const pairing = PAIRINGS.find((p) => p.mood.toLowerCase().startsWith(moodName.trim().toLowerCase()));
|
||
|
||
const wantedDisplay = preset?.fonts?.display ?? picked?.display;
|
||
const wantedBody = preset?.fonts?.body ?? picked?.body;
|
||
|
||
let display = wantedDisplay;
|
||
if (!display || !byFamily(display)?.display) {
|
||
if (display) warnings.push(warn("font-display-replaced", { carattere: display }));
|
||
display = pairing?.display ?? "Anton";
|
||
}
|
||
|
||
let body = wantedBody;
|
||
const bodyEntry = body ? byFamily(body) : undefined;
|
||
if (!body || !bodyEntry?.body || bodyEntry.capsOnly) {
|
||
if (body) warnings.push(warn("font-body-replaced", { carattere: body }));
|
||
body = pairing?.body ?? "Inter";
|
||
}
|
||
|
||
// Belt and braces: whatever happened above, the body face is never caps-only.
|
||
if (byFamily(body)?.capsOnly) body = "Inter";
|
||
|
||
return { display: byFamily(display)!.family, body: byFamily(body)!.family };
|
||
}
|
||
|
||
/**
|
||
* Enforces the "preserve his wording, invent nothing" contract.
|
||
*
|
||
* - a role he filled in is restored to his exact string, whatever the model wrote;
|
||
* - a role he did not fill in survives only if it is grounded in the brief text;
|
||
* - the title may be rewritten, but not replaced by something unrelated;
|
||
* - blocks are deduped by role and ordered by BLOCK_ROLES priority.
|
||
*/
|
||
function reconcileBlocks(
|
||
raw: readonly Block[],
|
||
brief: Brief,
|
||
corpus: string,
|
||
warnings: DirectorWarning[],
|
||
): Block[] {
|
||
const byRole = new Map<BlockRole, Block>();
|
||
for (const b of raw) {
|
||
if (!b || typeof b.text !== "string") continue;
|
||
const role = b.role as BlockRole;
|
||
if (!(BLOCK_ROLES as readonly string[]).includes(role)) continue;
|
||
const text = b.text.trim().replace(/\s+/g, " ");
|
||
if (!text) {
|
||
warnings.push(warn("block-empty", { campo: role }));
|
||
continue;
|
||
}
|
||
if (!byRole.has(role)) byRole.set(role, { ...b, role, text });
|
||
}
|
||
|
||
const given: Partial<Record<BlockRole, string | undefined>> = {
|
||
title: brief.title,
|
||
subtitle: brief.subtitle,
|
||
date: brief.date,
|
||
venue: brief.venue,
|
||
details: brief.details,
|
||
price: brief.price,
|
||
footer: brief.footer,
|
||
};
|
||
|
||
for (const role of BLOCK_ROLES) {
|
||
const his = given[role]?.trim();
|
||
const block = byRole.get(role);
|
||
|
||
if (his) {
|
||
// Everything except the title is his, verbatim. The title he may have sharpened.
|
||
if (role === "title") {
|
||
if (!block) byRole.set(role, { role, text: his });
|
||
else if (!supportedByBrief(block.text, corpus)) {
|
||
warnings.push(warn("title-restored", { titolo: his }));
|
||
byRole.set(role, { ...block, text: his });
|
||
}
|
||
} else if (!block) {
|
||
byRole.set(role, { role, text: his, ...(isSecondary(role) ? { optional: true } : {}) });
|
||
} else if (block.text !== his) {
|
||
warnings.push(warn("block-restored", { campo: role, testo: his }));
|
||
byRole.set(role, { ...block, text: his });
|
||
}
|
||
continue;
|
||
}
|
||
|
||
// He gave nothing for this role: the model may only have derived it from free text.
|
||
if (block && FACTUAL_ROLES.has(role) && !supportedByBrief(block.text, corpus)) {
|
||
warnings.push(warn("block-invented", { campo: role, testo: block.text }));
|
||
byRole.delete(role);
|
||
}
|
||
}
|
||
|
||
const ordered = BLOCK_ROLES.map((r) => byRole.get(r)).filter((b): b is Block => Boolean(b));
|
||
if (ordered.length === 0) {
|
||
// DesignSpecSchema demands minItems 1, and a poster with no words is not a poster.
|
||
ordered.push({ role: "title", text: brief.title?.trim() || "Evento" });
|
||
}
|
||
return ordered;
|
||
}
|
||
|
||
/** Roles that state a fact about the world, and therefore may never be invented. */
|
||
const FACTUAL_ROLES = new Set<BlockRole>(["date", "venue", "price", "details", "footer"]);
|
||
|
||
/** Roles the renderer is allowed to drop when space runs short. */
|
||
function isSecondary(role: BlockRole): boolean {
|
||
return role === "details" || role === "footer" || role === "price";
|
||
}
|
||
|
||
/**
|
||
* Removes any clause that asks the diffusion model for something readable.
|
||
*
|
||
* Splitting on commas and dropping whole clauses is deliberately blunt: a prompt that
|
||
* loses a clause still paints, while a prompt that keeps "vintage poster lettering"
|
||
* produces fake garbled Italian across the artwork, which is unrecoverable.
|
||
*/
|
||
export function scrubArtPrompt(
|
||
prompt: string,
|
||
artHint: string | undefined,
|
||
warnings: DirectorWarning[],
|
||
): string {
|
||
const clauses = prompt
|
||
.split(/[,;]+/)
|
||
.map((c) => c.trim())
|
||
.filter(Boolean);
|
||
const kept = clauses.filter((c) => !LETTERING_RE.test(c));
|
||
|
||
if (kept.length !== clauses.length) {
|
||
warnings.push(warn("art-prompt-scrubbed"));
|
||
}
|
||
|
||
let out = kept.join(", ").trim();
|
||
if (!out) {
|
||
warnings.push(warn("art-prompt-empty"));
|
||
// The palette's own artHint is the best fallback available — but it goes through the
|
||
// same filter, because a hint like "hand-painted banners" is exactly the kind of
|
||
// subject that grows fake lettering.
|
||
const hint = (artHint ?? "")
|
||
.split(/[,;]+/)
|
||
.map((c) => c.trim())
|
||
.filter((c) => c && !LETTERING_RE.test(c))
|
||
.join(", ");
|
||
out = hint || GENERIC_ART_FALLBACK;
|
||
}
|
||
// Long prompts stop helping and start confusing small local models.
|
||
if (out.length > 700) out = out.slice(0, 700).replace(/,[^,]*$/, "");
|
||
return out;
|
||
}
|
||
|
||
/** Last-resort artwork: a texture that cannot possibly be mistaken for a printed thing. */
|
||
const GENERIC_ART_FALLBACK =
|
||
"soft painterly colour wash, gentle gradient light, subtle paper grain, no subject, generous empty space";
|
||
|
||
/** Guarantees the six mandatory terms lead the negative prompt, keeping the model's extras. */
|
||
export function ensureNegative(negative: string, warnings: DirectorWarning[]): string {
|
||
const have = new Set(
|
||
negative
|
||
.split(/[,;]+/)
|
||
.map((t) => t.trim().toLowerCase())
|
||
.filter(Boolean),
|
||
);
|
||
const missing = REQUIRED_NEGATIVE.filter((t) => !have.has(t));
|
||
if (missing.length) warnings.push(warn("negative-completed"));
|
||
|
||
const extras = [...have].filter((t) => !(REQUIRED_NEGATIVE as readonly string[]).includes(t));
|
||
return [...REQUIRED_NEGATIVE, ...extras].join(", ");
|
||
}
|
||
|
||
/** 31-bit, so it survives every backend's integer handling. */
|
||
function randomSeed(): number {
|
||
return Math.floor(Math.random() * 0x7fffffff);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// parseFreeText() — "/poster concerto jazz al chiostro 12 settembre"
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const PARSE_SYSTEM_PROMPT = `You extract structured fields from one line of informal Italian
|
||
typed after a command, so a form can be pre-filled. You are not designing anything.
|
||
|
||
RULES
|
||
- Every field you return must be a VERBATIM substring of the input, unchanged: same
|
||
words, same accents, same capitalisation. You are cutting the sentence up, not
|
||
rewriting it. The only exception is \`title\`, where you may drop a leading verb
|
||
("faccio un concerto..." -> "concerto...").
|
||
- Return a field ONLY if it is actually there. An event with no venue has no \`venue\`.
|
||
Guessing a plausible piazza is the worst thing you can do here: he will not notice the
|
||
invention until the poster is printed.
|
||
- Italian dates come in many shapes and all of them stay verbatim: "12 set",
|
||
"sabato 12 settembre", "12/09", "il 12 e 13 ottobre", "venerdì sera". Copy the whole
|
||
date-and-time phrase into \`date\`, including "ore 21" if present.
|
||
- \`venue\` is a place: "al chiostro", "in piazza Grande", "al bar Centrale", "Teatro
|
||
Comunale". Strip the leading preposition, keep the name.
|
||
- \`rest\` gets everything that is not a field: tone, audience, wishes, what to avoid.
|
||
Never leave meaningful words out of both the fields and \`rest\`.
|
||
- \`confidence\`: "alta" only when title, date and venue were all clearly stated. "bassa"
|
||
whenever you were tempted to guess.
|
||
|
||
EXAMPLES
|
||
Input: concerto jazz al chiostro 12 settembre
|
||
{"title":"concerto jazz","date":"12 settembre","venue":"chiostro","rest":"","confidence":"media"}
|
||
|
||
Input: sagra della porchetta sabato 3 e domenica 4 agosto in piazza a Ariccia, ingresso libero, deve essere allegra
|
||
{"title":"sagra della porchetta","date":"sabato 3 e domenica 4 agosto","venue":"piazza a Ariccia","price":"ingresso libero","rest":"deve essere allegra","confidence":"alta"}
|
||
|
||
Input: una cosa per la mia band, tipo anni 70
|
||
{"title":"la mia band","rest":"tipo anni 70","confidence":"bassa"}`;
|
||
|
||
/**
|
||
* Pre-fills the form from free text. Honest by construction: anything the model returns
|
||
* that is not actually in the text is discarded, and a failed model call degrades to a
|
||
* local regex parse rather than to an error — the form still opens.
|
||
*/
|
||
export async function parseFreeText(
|
||
text: string,
|
||
ctx: DirectorContext,
|
||
cfg?: ImgenConfig,
|
||
): Promise<ParsedBrief> {
|
||
const input = text.trim();
|
||
if (!input) return emptyParse();
|
||
|
||
let model: Model<Api>;
|
||
try {
|
||
model = resolveDirectorModel(cfg ?? ({ presets: {} } as unknown as ImgenConfig), ctx);
|
||
} catch {
|
||
return parseFreeTextLocally(input);
|
||
}
|
||
|
||
let raw: Static<typeof FreeTextSchema>;
|
||
try {
|
||
const { value } = await askStructured(
|
||
ctx,
|
||
model,
|
||
PARSE_SYSTEM_PROMPT,
|
||
`Input: ${input}`,
|
||
FreeTextSchema,
|
||
"prefill_form",
|
||
"Extract the fields present in this line of Italian.",
|
||
(v) => v as Static<typeof FreeTextSchema>,
|
||
{ maxTokens: 800, temperature: 0.1 },
|
||
);
|
||
raw = value;
|
||
} catch {
|
||
// A form that opens half-filled beats an error message. Fall back and say so.
|
||
return parseFreeTextLocally(input);
|
||
}
|
||
|
||
const corpus = foldAccents(input);
|
||
const fields: Partial<Record<BlockRole, string>> = {};
|
||
let dropped = false;
|
||
|
||
for (const role of BLOCK_ROLES) {
|
||
const value = (raw as Record<string, unknown>)[role];
|
||
if (typeof value !== "string") continue;
|
||
const trimmed = value.trim();
|
||
if (!trimmed) continue;
|
||
// The anti-invention gate: it has to be in what he typed.
|
||
if (!supportedByBrief(trimmed, corpus)) {
|
||
dropped = true;
|
||
continue;
|
||
}
|
||
fields[role] = trimmed;
|
||
}
|
||
|
||
const local = parseFreeTextLocally(input);
|
||
// The regex date parser is stricter than the model at spotting "12/09"; if the model
|
||
// missed a date the local pass found, take the local one.
|
||
if (!fields.date && local.fields.date) fields.date = local.fields.date;
|
||
if (!fields.title && local.fields.title) fields.title = local.fields.title;
|
||
|
||
const missing = BLOCK_ROLES.filter((r) => !fields[r]);
|
||
let confidence: Confidence =
|
||
raw.confidence === "alta" || raw.confidence === "media" || raw.confidence === "bassa"
|
||
? raw.confidence
|
||
: "media";
|
||
if (dropped && confidence === "alta") confidence = "media";
|
||
if (!fields.title) confidence = "bassa";
|
||
|
||
return {
|
||
fields,
|
||
freeText: typeof raw.rest === "string" ? raw.rest.trim() : "",
|
||
format: (FORMATS as readonly string[]).includes(raw.format ?? "") ? (raw.format as Format) : undefined,
|
||
dateParsed: fields.date ? parseSpokenDate(fields.date) : undefined,
|
||
confidence,
|
||
missing,
|
||
offline: false,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* No-model fallback. Finds an Italian date with job.ts's parser, a venue after a locative
|
||
* preposition, and treats the head of the line as the title. Deliberately timid: it
|
||
* reports "bassa" and leaves everything it is unsure about in `freeText`.
|
||
*/
|
||
export function parseFreeTextLocally(text: string): ParsedBrief {
|
||
// A command prefix may still be attached when this is called straight off the input line.
|
||
const input = text.trim().replace(/^\/[a-z][\w-]*\s*/i, "").trim();
|
||
if (!input) return emptyParse();
|
||
|
||
const fields: Partial<Record<BlockRole, string>> = {};
|
||
const spans: [number, number][] = [];
|
||
const take = (m: RegExpExecArray | null, group = 0): string | undefined => {
|
||
if (!m || m[group] === undefined) return undefined;
|
||
const offset = group === 0 ? m.index : m.index + m[0].indexOf(m[group]!);
|
||
spans.push([offset, offset + m[group]!.length]);
|
||
return m[group]!.trim();
|
||
};
|
||
|
||
// Date, kept verbatim as he typed it. Three shapes, longest first:
|
||
// worded ("sabato 12 e domenica 13 ottobre"), numeric ("12/09"), bare weekday
|
||
// ("venerdì sera"). The bare weekday has no parseable date, and that is fine —
|
||
// the wording is what gets printed; `dateParsed` is only for the slug.
|
||
const dateHit = DATE_RE.exec(input);
|
||
const date = take(dateHit);
|
||
if (date) fields.date = date;
|
||
|
||
// Venue: "al chiostro", "in piazza a Ariccia", "presso il Teatro Comunale".
|
||
const venueHit = VENUE_RE.exec(input);
|
||
const venue = take(venueHit, 1)?.replace(/[,.;].*$/, "").trim();
|
||
if (venue && venue.length >= 3) fields.venue = venue;
|
||
else if (venue) spans.pop();
|
||
|
||
const priceHit = PRICE_RE.exec(input);
|
||
const price = take(priceHit);
|
||
if (price) fields.price = price;
|
||
|
||
// Title: everything before the first thing we recognised.
|
||
const firstHit = spans.length ? Math.min(...spans.map(([s]) => s)) : input.length;
|
||
const head = input
|
||
.slice(0, firstHit)
|
||
.replace(/\b(?:il|lo|la|i|gli|le|del|della|al|in|a|di|e)\s*$/i, "")
|
||
.replace(/[,;:–—-]\s*$/, "")
|
||
.trim();
|
||
if (head.length >= 3) {
|
||
fields.title = head;
|
||
spans.push([0, firstHit]);
|
||
}
|
||
|
||
let rest = leftovers(input, spans);
|
||
|
||
// He may have led with the date ("sabato 12 settembre concerto d'estate al Teatro"),
|
||
// leaving nothing before the first span. Promote the meatiest leftover instead of
|
||
// opening the form with an empty title and a confusing "Altro" box.
|
||
if (!fields.title) {
|
||
const frags = rest.split(/,\s*/).map((f) => f.trim()).filter((f) => f.length >= 3);
|
||
const best = frags.slice().sort((a, b) => b.length - a.length)[0];
|
||
if (best) {
|
||
const cleaned = best.replace(/\b(?:al|allo|alla|in|a|di|del|della|e|per|con)\s*$/i, "").trim();
|
||
if (cleaned.length >= 3) {
|
||
fields.title = cleaned;
|
||
rest = frags.filter((f) => f !== best).join(", ");
|
||
}
|
||
}
|
||
}
|
||
|
||
return {
|
||
fields,
|
||
freeText: rest,
|
||
dateParsed: fields.date ? parseSpokenDate(fields.date) : undefined,
|
||
confidence: "bassa",
|
||
missing: BLOCK_ROLES.filter((r) => !fields[r]),
|
||
offline: true,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* job.ts's parseDate stops at the first number it sees, so "sabato 3 e domenica 4 agosto"
|
||
* defeats it. Retry from the last conjunction, where the day that carries the month is.
|
||
* Only ever used for the slug — the printed date stays his wording either way.
|
||
*/
|
||
function parseSpokenDate(text: string): ParsedDate | undefined {
|
||
const direct = parseDate(text);
|
||
if (direct) return direct;
|
||
const tail = text.split(/\s+e\s+/i).pop();
|
||
return tail && tail !== text ? parseDate(tail) : undefined;
|
||
}
|
||
|
||
const WEEKDAY = "(?:luned[iì]|marted[iì]|mercoled[iì]|gioved[iì]|venerd[iì]|sabato|domenica)";
|
||
const MONTH = "(?:gen|feb|mar|apr|mag|giu|lug|ago|sett?|ott|nov|dic)[a-zà-ù]*";
|
||
const TIME = "(?:\\s*,?\\s*(?:ore|alle|dalle)\\s*\\d{1,2}(?:[:.]\\d{2})?)?";
|
||
const DAYPART = "(?:\\s+(?:sera|serata|pomeriggio|mattina|mattino|notte))?";
|
||
|
||
/** "sabato 12 e domenica 13 ottobre 2026, ore 21" | "12/09/26" | "venerdì sera". */
|
||
const DATE_RE = new RegExp(
|
||
"(?:" +
|
||
// worded, with an optional second day for two-day sagre
|
||
`(?:${WEEKDAY}\\s+)?\\d{1,2}(?:\\s*(?:e|-|\\/)\\s*(?:${WEEKDAY}\\s+)?\\d{1,2})?\\s+(?:di\\s+)?${MONTH}(?:\\s+\\d{4})?` +
|
||
"|" +
|
||
// numeric, day-first as in Italy
|
||
`(?:${WEEKDAY}\\s+)?\\d{1,2}\\s*[\\/.\\-]\\s*\\d{1,2}(?:\\s*[\\/.\\-]\\s*\\d{2,4})?` +
|
||
"|" +
|
||
// bare weekday, optionally with a part of the day
|
||
`${WEEKDAY}${DAYPART}` +
|
||
")" +
|
||
TIME,
|
||
"i",
|
||
);
|
||
|
||
const VENUE_RE =
|
||
/\b(?:al|allo|alla|all'|ai|agli|alle|in|presso|dal|dalla|da)\s+((?:il\s+|lo\s+|la\s+|i\s+|gli\s+|le\s+)?[A-Za-zÀ-ÿ'][\wÀ-ÿ'’]*(?:\s+(?:di|del|della|dei|delle|d'|a)?\s*[A-ZÀ-Ý][\wÀ-ÿ'’]*){0,3})/;
|
||
|
||
const PRICE_RE =
|
||
/(ingresso\s+(?:libero|gratuito|a\s+offerta(?:\s+libera)?)|\bgratis\b|\b\d{1,3}(?:[.,]\d{2})?\s*(?:€|euro\b)|€\s*\d{1,3}(?:[.,]\d{2})?)/i;
|
||
|
||
/** Function words that carry nothing on their own — a fragment made only of these is noise. */
|
||
const FILLER = new Set([
|
||
"al", "allo", "alla", "all", "ai", "agli", "alle", "in", "presso", "dal", "dalla", "da",
|
||
"il", "lo", "la", "i", "gli", "le", "un", "una", "uno", "e", "di", "del", "della", "dei",
|
||
"delle", "per", "con", "a", "the", "of",
|
||
]);
|
||
|
||
/**
|
||
* What is left of the line once the recognised spans are cut out, tidied so the "Altro /
|
||
* contesto" box does not open showing "al , in a". Fragments made only of function words
|
||
* are dropped; everything else is kept exactly as he wrote it.
|
||
*/
|
||
function leftovers(input: string, spans: readonly [number, number][]): string {
|
||
const merged = [...spans].sort((a, b) => a[0] - b[0]);
|
||
const pieces: string[] = [];
|
||
let cursor = 0;
|
||
for (const [s, e] of merged) {
|
||
if (s > cursor) pieces.push(input.slice(cursor, s));
|
||
cursor = Math.max(cursor, e);
|
||
}
|
||
if (cursor < input.length) pieces.push(input.slice(cursor));
|
||
|
||
return pieces
|
||
.join(" ")
|
||
.split(/[,;\n]+/)
|
||
.map((frag) => frag.replace(/\s+/g, " ").trim())
|
||
.filter((frag) => {
|
||
const words = frag.toLowerCase().replace(/[^a-zà-ÿ0-9\s]/g, " ").split(/\s+/).filter(Boolean);
|
||
return words.length > 0 && words.some((w) => !FILLER.has(w));
|
||
})
|
||
.join(", ")
|
||
.replace(/^[\s,;.\-–—]+|[\s,;.\-–—]+$/g, "");
|
||
}
|
||
|
||
function emptyParse(): ParsedBrief {
|
||
return {
|
||
fields: {},
|
||
freeText: "",
|
||
confidence: "bassa",
|
||
missing: [...BLOCK_ROLES],
|
||
offline: true,
|
||
};
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// generateCaption() — only when he asks
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const CAPTION_SYSTEM_PROMPT = `You write the social post that goes with a poster, in ITALIAN,
|
||
for someone who organises small local events.
|
||
|
||
- 2 to 4 short sentences. Spoken Italian, warm, no marketing voice, no "non perdetevi
|
||
questo evento imperdibile", no exclamation marks stacked up.
|
||
- Repeat the date and the venue EXACTLY as they appear in the design, character for
|
||
character. They are already correct; changing them is the only way to get them wrong.
|
||
- Invent nothing: no times, no prices, no line-ups, no "prenotazione obbligatoria", no
|
||
links, no "link in bio". If it is not in the design, it does not go in the caption.
|
||
- At most one emoji, and only if it genuinely fits the event.
|
||
- hashtags: 5 to 8, lowercase, no accents, no '#' prefix, no spaces. Mix the event, the
|
||
kind of event, and the town. Do not hashtag the date.
|
||
|
||
Return only the JSON object.`;
|
||
|
||
/**
|
||
* Italian social copy for a finished job. Called ONLY after he has answered yes to
|
||
* "Vuoi che scriva anche il testo del post?" — never on its own initiative, and never as
|
||
* a rewrite of copy he wrote himself.
|
||
*/
|
||
export async function generateCaption(
|
||
spec: DesignSpec,
|
||
brief: Brief,
|
||
ctx: DirectorContext,
|
||
cfg?: ImgenConfig,
|
||
): Promise<CaptionResult> {
|
||
const model = resolveDirectorModel(cfg ?? ({ presets: {} } as unknown as ImgenConfig), ctx);
|
||
|
||
const lines = spec.blocks.map((b) => `${b.role}: ${b.text}`).join("\n");
|
||
const extra = brief.freeText?.trim();
|
||
const dateHint = spec.blocks.find((b) => b.role === "date")?.text;
|
||
const humanDate = dateHint ? formatItalianDate(dateHint) : "";
|
||
|
||
const userPrompt =
|
||
`Il testo della locandina:\n${lines}\n` +
|
||
(humanDate && humanDate !== dateHint ? `\n(la data, per capirci: ${humanDate})\n` : "") +
|
||
(extra ? `\nContesto che mi ha dato lui: ${extra}\n` : "") +
|
||
`\nScrivi il post.`;
|
||
|
||
const { value } = await askStructured(
|
||
ctx,
|
||
model,
|
||
CAPTION_SYSTEM_PROMPT,
|
||
userPrompt,
|
||
CaptionSchema,
|
||
"emit_caption",
|
||
"Emit the Italian social caption and its hashtags.",
|
||
(v) => v as Static<typeof CaptionSchema>,
|
||
{ maxTokens: 700, temperature: 0.8 },
|
||
);
|
||
|
||
const text = value.caption.trim();
|
||
const hashtags = [
|
||
...new Set(
|
||
(value.hashtags ?? [])
|
||
.map((h) => foldAccents(String(h)).replace(/[^a-z0-9]/g, ""))
|
||
.filter((h) => h.length >= 3),
|
||
),
|
||
].slice(0, 8);
|
||
|
||
return {
|
||
text,
|
||
hashtags,
|
||
full: hashtags.length ? `${text}\n\n${hashtags.map((h) => `#${h}`).join(" ")}` : text,
|
||
};
|
||
}
|
||
|
||
/** Schema for the art-prompt refinement call. One field: the revised English prompt. */
|
||
const ArtPromptSchema = Type.Object({
|
||
prompt: Type.String({
|
||
description:
|
||
"The revised image prompt, in ENGLISH. Describes artwork only. Never requests text, letters, words, numbers, signage or typography.",
|
||
}),
|
||
});
|
||
|
||
const ART_PROMPT_SYSTEM = [
|
||
"You revise prompts for a local image-generation model.",
|
||
"",
|
||
"You are given an existing ENGLISH prompt and a note written in ITALIAN by a non-technical user.",
|
||
"Fold the note's INTENT into the prompt and return the result.",
|
||
"",
|
||
"Rules, all mandatory:",
|
||
"- Output ENGLISH only. Never echo the Italian.",
|
||
"- Describe ARTWORK ONLY: subject, composition, colour, light, medium, mood.",
|
||
"- NEVER request text, letters, words, numbers, signage, posters, logos or typography.",
|
||
" Any lettering the model draws is lettering the typesetter has to fight.",
|
||
"- Keep what still applies from the original prompt; do not restart from nothing.",
|
||
"- Keep it under about 60 words. Long prompts confuse small local models.",
|
||
].join("\n");
|
||
|
||
/**
|
||
* Fold an Italian note from the user into the English art prompt.
|
||
*
|
||
* Splicing the Italian in verbatim measurably degrades output — these models are trained
|
||
* on English — so the note goes through the model that already art-directs. Every failure
|
||
* path falls back to the naive splice, which is never worse than before: this is a quality
|
||
* improvement, never a reason for the command to fail.
|
||
*/
|
||
export async function refineArtPrompt(
|
||
prompt: string,
|
||
hintItalian: string,
|
||
ctx: DirectorContext,
|
||
cfg?: ImgenConfig,
|
||
): Promise<string> {
|
||
const hint = hintItalian.trim();
|
||
const naive = () => scrubArtPrompt(hint ? `${prompt}, ${hint}` : prompt, undefined, []);
|
||
if (!hint) return scrubArtPrompt(prompt, undefined, []);
|
||
|
||
try {
|
||
const model = resolveDirectorModel(cfg ?? ({ presets: {} } as unknown as ImgenConfig), ctx);
|
||
const { value } = await askStructured(
|
||
ctx,
|
||
model,
|
||
ART_PROMPT_SYSTEM,
|
||
`Existing prompt (English):\n${prompt}\n\nHis note (Italian):\n${hint}\n\nReturn the revised English prompt.`,
|
||
ArtPromptSchema,
|
||
"emit_art_prompt",
|
||
"Emit the revised English art prompt.",
|
||
(v) => v as Static<typeof ArtPromptSchema>,
|
||
{ maxTokens: 220, temperature: 0.6 },
|
||
);
|
||
// The model is not trusted on the lettering rule: scrub regardless of what it returns.
|
||
const cleaned = scrubArtPrompt(value.prompt, undefined, []);
|
||
return cleaned.trim() ? cleaned : naive();
|
||
} catch {
|
||
return naive();
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Warning text
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/**
|
||
* TODO: move to `S.director` in ui/strings.ts. Kept here, together, so moving them is a
|
||
* single cut-and-paste — nothing else in this file contains Italian.
|
||
*/
|
||
const WARNING_TEXT: Record<DirectorWarningCode, (p: Record<string, string>) => string> = {
|
||
"font-display-replaced": (p) => `Il carattere «${p.carattere}» non andava bene per i titoli: ne ho scelto un altro.`,
|
||
"font-body-replaced": (p) => `«${p.carattere}» esiste solo in maiuscolo, quindi per le righe piccole ho usato un altro carattere.`,
|
||
"palette-unknown": () => "Non ho riconosciuto i colori proposti: ho usato quelli standard.",
|
||
"template-unknown": () => "Non ho riconosciuto l'impaginazione proposta: ho usato quella classica.",
|
||
"format-unknown": () => "Non ho riconosciuto il formato proposto: faccio la locandina A3.",
|
||
"art-prompt-scrubbed": () => "Ho tolto dalla descrizione dell'immagine i pezzi che chiedevano delle scritte: le scritte le metto io dopo, giuste.",
|
||
"art-prompt-empty": () => "La descrizione dell'immagine è rimasta vuota: ne ho usata una di riserva.",
|
||
"negative-completed": () => "Ho ricordato al programma di disegno di non scrivere nulla nell'immagine.",
|
||
"block-restored": (p) => `«${p.campo}» l'ho rimesso esattamente come l'hai scritto tu: ${p.testo}`,
|
||
"block-invented": (p) => `Ho tolto «${p.campo}»: c'era scritto qualcosa che tu non mi avevi detto.`,
|
||
"block-empty": (p) => `«${p.campo}» era vuoto, l'ho tolto.`,
|
||
"title-restored": (p) => `Ho rimesso il titolo che avevi scritto tu: ${p.titolo}`,
|
||
"preset-applied": (p) => (p.nome ? `Sto usando lo stile «${p.nome}».` : "Ho applicato il tuo stile salvato."),
|
||
retried: () => "Al primo tentativo non tornava: ho rifatto e adesso è a posto.",
|
||
};
|
||
|
||
function warn(code: DirectorWarningCode, params: Record<string, string> = {}): DirectorWarning {
|
||
return { code, params, italian: WARNING_TEXT[code](params) };
|
||
}
|