diff --git a/app/(app)/dashboard/ads/new/form.tsx b/app/(app)/dashboard/ads/new/form.tsx index 28963a31..87b8c393 100644 --- a/app/(app)/dashboard/ads/new/form.tsx +++ b/app/(app)/dashboard/ads/new/form.tsx @@ -2,7 +2,8 @@ import { useState, useTransition, useRef } from "react"; import { useRouter } from "next/navigation"; -import { previewAds, saveCampaign, uploadAdAsset } from "@/app/actions/ads"; +import { draftAds, saveCampaign, uploadAdAsset, type previewAds } from "@/app/actions/ads"; +import { PAGE_COPY_PROVIDER } from "@/lib/ads/agent"; import { DESIGN_FORMATS, paletteFor, type AdCreative, type AdFormatId } from "@/lib/ads/formats"; import type { SiteBrand } from "@/lib/ads/brand"; import { AdPreview } from "@/components/ads/ad-preview"; @@ -10,6 +11,8 @@ import { ColorField } from "@/components/ads/color-field"; import type { AdTheme } from "@/lib/ads/theme"; import { originalHeroPngDownloadUrl } from "@/lib/ads/heroImageUrl"; +type Preview = Extract>, { ok: true }>; + function dollars(cents: number): string { return `$${(cents / 100).toFixed(2)}`; } @@ -28,6 +31,12 @@ export function NewAdForm() { const [creatives, setCreatives] = useState([]); const [active, setActive] = useState("banner_300x250"); const [provider, setProvider] = useState(null); + // The model's copy arrives a minute or two after the draft. + const [improving, setImproving] = useState(false); + // Held back instead of applied, because the advertiser edited the draft. + const [readyCopy, setReadyCopy] = useState(null); + const editedRef = useRef(false); + const improveRef = useRef(null); const [generating, startGenerate] = useTransition(); const [saving, startSave] = useTransition(); @@ -47,23 +56,61 @@ export function NewAdForm() { e.preventDefault(); setError(null); startGenerate(async () => { - const res = await previewAds({ url }); + const res = await draftAds({ url }); if (!res.ok) { setError(res.error); return; } setBrand(res.brand); setCreatives(res.creatives); - setProvider(res.provider); + setProvider(PAGE_COPY_PROVIDER); + setReadyCopy(null); + editedRef.current = false; if (!name) setName(res.suggestedName); + void improve(url); }); } + function applyCopy(res: Preview) { + setBrand(res.brand); + setCreatives(res.creatives); + setProvider(res.provider); + setReadyCopy(null); + editedRef.current = false; + } + + // A failure here is not an error on the page: the draft is a working ad and + // stays, under the label that says it was written from the page. + async function improve(target: string) { + improveRef.current?.abort(); + const controller = new AbortController(); + improveRef.current = controller; + setImproving(true); + try { + const res = await fetch("/api/ads/preview", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ url: target }), + signal: controller.signal, + }); + const out = (await res.json()) as Awaited>; + if (controller.signal.aborted || !out.ok || out.provider === PAGE_COPY_PROVIDER) return; + if (editedRef.current) setReadyCopy(out); + else applyCopy(out); + } catch { + // Aborted by a newer run or a save, or the connection dropped. + } finally { + if (improveRef.current === controller) setImproving(false); + } + } + // copy edits touch only the active format; colours/assets are shared brand-level function patchActive(patch: Partial) { + editedRef.current = true; setCreatives((cs) => cs.map((c) => (c.format === active ? { ...c, ...patch } : c))); } function patchAll(patch: Partial) { + editedRef.current = true; setCreatives((cs) => cs.map((c) => ({ ...c, ...patch }))); } @@ -75,6 +122,7 @@ export function NewAdForm() { const field = key === "bg" ? "bgColor" : key === "fg" ? "fgColor" : "accentColor"; return patchAll({ [field]: v } as Partial); } + editedRef.current = true; setCreatives((cs) => cs.map((c) => { const seed = paletteFor(c, "light"); @@ -108,6 +156,7 @@ export function NewAdForm() { function save() { setError(null); + improveRef.current?.abort(); startSave(async () => { const res = await saveCampaign({ name, @@ -217,7 +266,11 @@ export function NewAdForm() {

Preview

{provider && ( - Generated with {provider} + {improving + ? "Draft from your page. AI copy is on the way, usually a minute or two; you can edit or save now." + : provider === PAGE_COPY_PROVIDER + ? "AI unavailable, written from your page. Edit before saving." + : `Generated with ${provider}`} )} @@ -230,6 +283,19 @@ export function NewAdForm() { A five-second streaming pre-roll is rendered from this design when you save. It appears on the campaign page, with an MP4 download, once ready. + {readyCopy && ( +
+ AI copy is ready. Using it replaces your edits. + + + + +
+ )}
{DESIGN_FORMATS.map((f) => { const c = creatives.find((x) => x.format === f.id); diff --git a/app/actions/ads.ts b/app/actions/ads.ts index 16643d30..e579d7d7 100644 --- a/app/actions/ads.ts +++ b/app/actions/ads.ts @@ -16,6 +16,7 @@ import { type RenderState, } from "@/lib/ads/video/jobs"; import { + draftAdCreatives, generateAdCreatives, DESIGN_FORMAT_IDS, cleanSummary, @@ -81,6 +82,30 @@ function summaryPayload( }; } +// The instant first draft for /dashboard/ads/new: the page's own words, no +// model. The form then asks POST /api/ads/preview for the generated version. +export async function draftAds(input: { url: string }): Promise< + | { ok: true; brand: SiteBrand; creatives: AdCreative[]; suggestedName: string } + | { ok: false; error: string } +> { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) return { ok: false, error: "Not authenticated." }; + + const check = isAllowedTargetUrl(input.url); + if (!check.ok) return { ok: false, error: check.reason }; + + try { + const { brand, creatives } = await draftAdCreatives(check.url); + if (creatives.length === 0) return { ok: false, error: "Nothing on that page to write an ad from." }; + return { ok: true, brand, creatives, suggestedName: brand.title?.slice(0, 60) || domainOf(check.url) }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : "Could not read that URL." }; + } +} + // Auto-generate ad creatives from a destination URL. No DB write — the client // holds the result, lets the user edit/replace, then calls saveCampaign. export async function previewAds(input: { url: string }): Promise< @@ -106,7 +131,9 @@ export async function previewAds(input: { url: string }): Promise< try { const { brand, creatives, provider, summary } = await generateAdCreatives(check.url, { supabase, + pageCopyOnFailure: true, }); + if (creatives.length === 0) return { ok: false, error: "Nothing on that page to write an ad from." }; return { ok: true, brand, diff --git a/app/api/ads/preview/route.ts b/app/api/ads/preview/route.ts new file mode 100644 index 00000000..15d417ab --- /dev/null +++ b/app/api/ads/preview/route.ts @@ -0,0 +1,15 @@ +import { NextResponse, type NextRequest } from "next/server"; +import { previewAds } from "@/app/actions/ads"; + +// A route and not a server action on purpose: Next runs a client's server +// actions one at a time, and this takes minutes on c0mpute. As an action it +// would hold back every Save and logo upload on the form behind it. +export async function POST(req: NextRequest) { + const body = (await req.json().catch(() => null)) as { url?: unknown } | null; + if (typeof body?.url !== "string") { + return NextResponse.json({ ok: false, error: "url is required." }, { status: 400 }); + } + const result = await previewAds({ url: body.url }); + const status = result.ok || result.error !== "Not authenticated." ? 200 : 401; + return NextResponse.json(result, { status }); +} diff --git a/bun.lock b/bun.lock index 82003d41..dd6fd997 100644 --- a/bun.lock +++ b/bun.lock @@ -10,7 +10,7 @@ "@libsql/client": "^0.18.0", "@modelcontextprotocol/sdk": "^1.32.2", "@profullstack/affiliates": "^0.3.0", - "@profullstack/ai-providers": "0.1.0", + "@profullstack/ai-providers": "0.1.1", "@profullstack/autoblog": "^0.5.0", "@profullstack/coinpay": "^0.25.0", "@profullstack/coinpay-onboard": "0.4.0", @@ -317,7 +317,7 @@ "@profullstack/affiliates": ["@profullstack/affiliates@0.3.0", "", { "dependencies": { "@profullstack/referrals": "^0.1.0" }, "bin": { "affiliates": "dist/cli.js" } }, "sha512-bp0iREciWetDWrQYOsa6SJRTzviekKwgJQuATT7vql+4equu7pN76q8tb3SP1oBMczydsv6rYUMvPpS6+QT7yQ=="], - "@profullstack/ai-providers": ["@profullstack/ai-providers@0.1.0", "", { "bin": { "ai-providers": "src/cli.js" } }, "sha512-vc7aFjozyiwIhREWLhB3xWpUPeGggM348ng5W6j4IA+gxYF3S7TNLoGWrF/ax2NttsK40C801CRkD9JSVA4aOg=="], + "@profullstack/ai-providers": ["@profullstack/ai-providers@0.1.1", "", { "bin": { "ai-providers": "src/cli.js" } }, "sha512-Roqan30yN6AqOu7cXUGZTGGkn5DYzVaWuR0Iw/k04pFPFwwF9lAk/OmGfc0fH94/f0t+wwm+HsdY8mx1wBJQAA=="], "@profullstack/autoblog": ["@profullstack/autoblog@0.5.0", "", { "dependencies": { "@profullstack/ai-providers": "0.1.0" } }, "sha512-KewpX0rM55o+7TMYeU35gqZceQFNF6zmo8zDoZRP5zoPU+XqTRcBlv6bZvl9oQHUXKXAsePJVNZKtDNhTrNEfw=="], @@ -1213,6 +1213,8 @@ "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], + "@profullstack/autoblog/@profullstack/ai-providers": ["@profullstack/ai-providers@0.1.0", "", { "bin": { "ai-providers": "src/cli.js" } }, "sha512-vc7aFjozyiwIhREWLhB3xWpUPeGggM348ng5W6j4IA+gxYF3S7TNLoGWrF/ax2NttsK40C801CRkD9JSVA4aOg=="], + "@profullstack/coinpay/@profullstack/hqtui": ["@profullstack/hqtui@0.6.3", "", { "bin": { "hqtui": "bin/hqtui.mjs" } }, "sha512-RQmULiyNt6Qcz+M0x7Hn3sGN+2C766STHA8AZv4olcYpoMeQFzUW0NnOSXtbqgaUTZCeJLZPU4NHx6NQffVvkw=="], "@profullstack/x402-client/@noble/curves": ["@noble/curves@2.4.0", "", { "dependencies": { "@noble/hashes": "2.4.0" } }, "sha512-P4/62zrgfH33CneE3Dn4WhJVA22YUU0eR51wKIan4NVRvwsA0YnPTwWGpNbpuacSujmSFLvyzpyuR30+fbq2Ew=="], diff --git a/lib/ads/agent.ts b/lib/ads/agent.ts index d6dcbc07..a061c9cf 100644 --- a/lib/ads/agent.ts +++ b/lib/ads/agent.ts @@ -22,6 +22,9 @@ export const AD_GENERATOR_AGENT = "ad-generator-agent"; +/** The `provider` of ads written from the page because no model answered. */ +export const PAGE_COPY_PROVIDER = "page text"; + type Env = Record; // Read through a variable so nothing here can be inlined at build time. diff --git a/lib/ads/creative.ts b/lib/ads/creative.ts index d306a34c..4887c294 100644 --- a/lib/ads/creative.ts +++ b/lib/ads/creative.ts @@ -7,7 +7,7 @@ import { env } from "@/lib/env"; import { generateStructuredOutput } from "@/lib/lx/backendAi"; import { extractSiteBrand, type SiteBrand } from "./brand"; import { resolveAdHeroImage } from "./heroImage"; -import { agentProviderLabel, paidHeroImagesAllowed } from "./agent"; +import { agentProviderLabel, paidHeroImagesAllowed, PAGE_COPY_PROVIDER } from "./agent"; import { renderCreativeText, renderTerminalHtml } from "./terminal"; import { renderFeedHtml } from "./feeditem"; import { NO_MEDIA, type AdMediaKind, type MediaAssets } from "./media"; @@ -74,25 +74,28 @@ function safeHex(v: string | undefined, fallback: string): string { return v && HEX.test(v.trim()) ? v.trim().toLowerCase() : fallback; } +// Hard caps on the display copy: what the units have room for. +const COPY_MAX = { headline: 48, shortHeadline: 28, body: 130, ctaText: 18, summaryShort: 1200, summaryLong: 6000 }; + const CopySchema = z.object({ headline: z .string() - .max(48) + .max(COPY_MAX.headline) .describe("Punchy ad headline in the brand's voice — max ~6 words, no period."), shortHeadline: z .string() - .max(28) + .max(COPY_MAX.shortHeadline) .describe("A tighter version for tiny mobile banners — max ~4 words."), // Structured-output SDKs strip maxLength and validate client-side, so a tight // cap makes the whole generation throw when copy runs a few chars long. Keep // the prompt guidance short but the hard cap generous (creatives re-truncate). body: z .string() - .max(130) + .max(COPY_MAX.body) .describe("One concrete benefit line, ~12 words max. No hype, no exclamation spam."), ctaText: z .string() - .max(18) + .max(COPY_MAX.ctaText) .describe("Button label, e.g. 'Try it free', 'Get started', 'Learn more'."), bgColor: z.string().describe("DARK-mode background hex like #0b0d10 — on-brand, good contrast with fgColor."), fgColor: z.string().describe("DARK-mode text hex with strong contrast against bgColor."), @@ -120,14 +123,14 @@ const CopySchema = z.object({ // error. Length guidance stays in the description, which is advisory. summaryShort: z .string() - .max(1200) + .max(COPY_MAX.summaryShort) .describe( "One or two plain sentences saying what this is and who it is for, in third person. " + "Reads as an editorial note, not a slogan — no exclamation marks, no second person, no CTA.", ), summaryLong: z .string() - .max(6000) + .max(COPY_MAX.summaryLong) .describe( "Two or three short paragraphs, separated by a blank line, for a sponsored section of a " + "blog post. Third person, factual, specific to this product. Describe what it does, who " + @@ -137,6 +140,48 @@ const CopySchema = z.object({ }); type AdCopy = z.infer; +/** Shortens to the last whole sentence that fits, else the last whole word. */ +function fitCopy(text: string, max: number, ellipsis = ""): string { + if (text.length <= max) return text; + const room = text.slice(0, max); + const sentence = /^[\s\S]*[.!?](?=\s)/.exec(text.slice(0, max + 1))?.[0]; + if (sentence && sentence.length >= max / 3) return sentence; + const words = room.slice(0, room.length - ellipsis.length).replace(/\s+\S*$/, ""); + return `${words.replace(/[\s,;:.-]+$/, "")}${ellipsis}`; +} + +/** + * What to keep from a c0mpute answer. + * + * The strict CopySchema makes all twelve fields fail together, and the models + * we run on c0mpute miss it in small ways: `body` came back 160 to 180 + * characters on most live runs against a cap of 130. Only a headline and a + * benefit line make an ad, so those are all that is required. Long copy is + * shortened here, and a missing colour or summary stays empty: copyToCreatives + * derives colours from the brand, and an empty summary means no prose. + */ +export function readC0mputeCopy(value: unknown): AdCopy { + const raw = (typeof value === "object" && value !== null ? value : {}) as Record; + const text = (key: keyof AdCopy) => (typeof raw[key] === "string" ? (raw[key] as string).trim() : ""); + const headline = fitCopy(text("headline"), COPY_MAX.headline); + const body = fitCopy(text("body"), COPY_MAX.body, "…"); + if (!headline || !body) throw new Error("headline and body are required"); + return { + headline, + shortHeadline: fitCopy(text("shortHeadline") || headline, COPY_MAX.shortHeadline), + body, + ctaText: fitCopy(text("ctaText"), COPY_MAX.ctaText) || "Learn more", + bgColor: text("bgColor"), + fgColor: text("fgColor"), + accentColor: text("accentColor"), + lightBgColor: text("lightBgColor"), + lightFgColor: text("lightFgColor"), + lightAccentColor: text("lightAccentColor"), + summaryShort: fitCopy(text("summaryShort"), COPY_MAX.summaryShort), + summaryLong: fitCopy(text("summaryLong"), COPY_MAX.summaryLong), + }; +} + /** * The summary half of CopySchema on its own, derived from it rather than * retyped so the two can never disagree about lengths or descriptions. @@ -308,7 +353,11 @@ function copyToCreatives(brand: SiteBrand, copy: AdCopy, heroUrl: string | null) // tiny banner uses the short headline; others use the full one headline: format === "banner_320x50" ? copy.shortHeadline || copy.headline : copy.headline, ...base, - })); + })).filter(hasWords); // A format with nothing to say is left out, not shipped blank. +} + +function hasWords(creative: Pick): boolean { + return !!creative.headline?.trim() && !!creative.body?.trim(); } /** @@ -487,9 +536,28 @@ function describesTheFetch(text: string): boolean { ); } +/** + * An ad written from the page alone, in the time it takes to fetch the page. + * The form shows this at once and swaps in the model's copy when it arrives, + * which on c0mpute is a minute or two later. + */ +export async function draftAdCreatives(rawUrl: string): Promise<{ brand: SiteBrand; creatives: AdCreative[] }> { + const brand = await extractSiteBrand(rawUrl); + return { brand, creatives: creativesFromCopy(brand, templateCopy(brand), brand.ogImage) }; +} + export async function generateAdCreatives( rawUrl: string, - opts: { supabase?: SupabaseClient; variation?: AdVariationRequest } = {}, + opts: { + supabase?: SupabaseClient; + variation?: AdVariationRequest; + /** + * When every provider fails, write the copy from the page instead of + * throwing. Only for a first draft the advertiser reviews: regenerating a + * campaign would overwrite their copy with it, and A/B arms would all match. + */ + pageCopyOnFailure?: boolean; + } = {}, ): Promise<{ brand: SiteBrand; creatives: AdCreative[]; @@ -501,9 +569,10 @@ export async function generateAdCreatives( const anthropic = env.anthropicApiKey ? new Anthropic({ apiKey: env.anthropicApiKey }) : null; const openai = env.openaiApiKey ? new OpenAI({ apiKey: env.openaiApiKey }) : null; - const { provider, output } = await generateStructuredOutput({ + const copy = generateStructuredOutput({ name: "ad_copy", schema: CopySchema, + c0mputeParse: readC0mputeCopy, system: opts.variation ? [ SYSTEM_PROMPT, @@ -519,7 +588,12 @@ export async function generateAdCreatives( anthropic, openai, anthropicEffort: "low", + }).catch((err: unknown) => { + if (!opts.pageCopyOnFailure) throw err; + console.warn(`[ads] ${brand.domain}: using page copy: ${err instanceof Error ? err.message : String(err)}`); + return { provider: PAGE_COPY_PROVIDER, output: templateCopy(brand) }; }); + const { provider, output } = await copy; // Hero image: the advertiser's og:image when it exists, else a gpt-image-2.5 // fallback (only when we have a Supabase client to host the upload). Best- @@ -552,7 +626,7 @@ export async function generateAdCreatives( return { brand, creatives: copyToCreatives(brand, output, heroUrl), - provider: agentProviderLabel(provider), + provider: provider === PAGE_COPY_PROVIDER ? provider : agentProviderLabel(provider), summary, }; } diff --git a/lib/lx/backendAi.ts b/lib/lx/backendAi.ts index 3b5b429e..6dafef3a 100644 --- a/lib/lx/backendAi.ts +++ b/lib/lx/backendAi.ts @@ -28,6 +28,13 @@ type StructuredOutputArgs = { anthropicEffort?: "low" | "medium" | "high" | false; openaiStructuredOutputs?: boolean; openaiJsonSchema?: Record; + /** + * Replaces `schema` when checking a c0mpute answer. OpenAI and Anthropic are + * held to the schema by their structured-output modes; a small open model is + * not, and for some callers a near miss is worth keeping rather than failing. + * Throw to reject the answer (it gets a repair round, then the next provider). + */ + c0mputeParse?: (value: unknown) => T; }; export function normalizeBackendAiPreference( @@ -175,7 +182,7 @@ async function generateWithC0mpute( (z.toJSONSchema(args.schema as any) as Record); const out = await generateObjectOn(provider, { name: args.name, - schema: args.schema, + schema: args.c0mputeParse ?? args.schema, jsonSchema, system: args.system, user: args.user, diff --git a/package.json b/package.json index a2e1f486..5343bdce 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,7 @@ "@libsql/client": "^0.18.0", "@modelcontextprotocol/sdk": "^1.32.2", "@profullstack/affiliates": "^0.3.0", - "@profullstack/ai-providers": "0.1.0", + "@profullstack/ai-providers": "0.1.1", "@profullstack/autoblog": "^0.5.0", "@profullstack/coinpay": "^0.25.0", "@profullstack/coinpay-onboard": "0.4.0", diff --git a/tests/ads-copy-fallback.test.ts b/tests/ads-copy-fallback.test.ts new file mode 100644 index 00000000..7c396ef7 --- /dev/null +++ b/tests/ads-copy-fallback.test.ts @@ -0,0 +1,110 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { PAGE_COPY_PROVIDER } from "@/lib/ads/agent"; + +const generate = vi.hoisted(() => vi.fn()); +vi.mock("@/lib/lx/backendAi", async (original) => ({ + ...(await original()), + generateStructuredOutput: generate, +})); +vi.mock("@/lib/ads/heroImage", () => ({ resolveAdHeroImage: vi.fn(async () => null) })); +vi.mock("@/lib/ads/brand", async (original) => ({ + ...(await original()), + extractSiteBrand: vi.fn(async () => ({ + url: "https://hqterm.sh/", + domain: "hqterm.sh", + title: "hqterm — terminals that survive sleep", + description: "Persistent terminals over plain SSH.", + text: "hqterm keeps your shell alive across sleep and network changes.", + logoUrl: null, + ogImage: null, + themeColor: null, + palette: [], + })), +})); + +const { draftAdCreatives, generateAdCreatives, readC0mputeCopy, creativesFromCopy, DESIGN_FORMAT_IDS } = await import( + "@/lib/ads/creative" +); + +const dead = new Error("Backend AI generation failed (c0mpute: no node; openai: 429; anthropic: 400)"); + +beforeEach(() => { + generate.mockReset(); +}); + +describe("readC0mputeCopy", () => { + it("keeps an answer whose only fault is length", () => { + const copy = readC0mputeCopy({ + headline: "Stop Losing Traffic To Broken Links On Every Page You Publish", + body: "CrawlProof monitors your entire site all day long. We find broken links, track visitor behavior, and even run a network of ads between your indie sites.", + ctaText: "Start Monitoring Your Site", + }); + expect(copy.headline.length).toBeLessThanOrEqual(48); + expect(copy.headline.endsWith(" ")).toBe(false); + expect(copy.body).toBe("CrawlProof monitors your entire site all day long."); + expect(copy.ctaText.length).toBeLessThanOrEqual(18); + }); + + it("marks a cut with no sentence end to stop at", () => { + const copy = readC0mputeCopy({ headline: "H", body: "word ".repeat(60) }); + expect(copy.body.length).toBeLessThanOrEqual(130); + expect(copy.body.endsWith("word…")).toBe(true); + }); + + it("fills what an ad can do without", () => { + const copy = readC0mputeCopy({ headline: "Sources in, feeds out, every single day of it", body: "b" }); + expect(copy.shortHeadline.length).toBeLessThanOrEqual(28); + expect(copy.ctaText).toBe("Learn more"); + expect(copy.summaryLong).toBe(""); + }); + + it("rejects an answer with no ad in it, so the next provider gets a turn", () => { + expect(() => readC0mputeCopy({ headline: "only" })).toThrow(/required/); + expect(() => readC0mputeCopy({ headline: { type: "string" }, body: { type: "string" } })).toThrow(/required/); + expect(() => readC0mputeCopy(null)).toThrow(/required/); + }); +}); + +describe("generateAdCreatives when every provider fails", () => { + it("writes a first draft from the page", async () => { + generate.mockRejectedValue(dead); + const out = await generateAdCreatives("https://hqterm.sh", { pageCopyOnFailure: true }); + expect(out.provider).toBe(PAGE_COPY_PROVIDER); + expect(out.creatives.map((c) => c.format)).toEqual([...DESIGN_FORMAT_IDS]); + expect(out.creatives[0].headline).toBe("hqterm"); + expect(out.creatives[0].body).toBe("Persistent terminals over plain SSH."); + }); + + it("still throws by default, because regenerate and A/B arms must not get page copy", async () => { + generate.mockRejectedValue(dead); + await expect(generateAdCreatives("https://hqterm.sh")).rejects.toThrow("Backend AI generation failed"); + }); + + it("uses a model when one answers, and offers c0mpute the lenient reader", async () => { + generate.mockResolvedValue({ + provider: "c0mpute", + output: readC0mputeCopy({ headline: "Terminals that survive sleep", body: "Close the laptop." }), + }); + const out = await generateAdCreatives("https://hqterm.sh", { pageCopyOnFailure: true }); + expect(out.provider).toBe("ad-generator-agent/c0mpute"); + expect(out.creatives[0].headline).toBe("Terminals that survive sleep"); + expect(generate.mock.calls[0][0].c0mputeParse).toBe(readC0mputeCopy); + }); +}); + +describe("draftAdCreatives", () => { + it("makes every design format from the page without calling a model", async () => { + const out = await draftAdCreatives("https://hqterm.sh"); + expect(generate).not.toHaveBeenCalled(); + expect(out.creatives.map((c) => c.format)).toEqual([...DESIGN_FORMAT_IDS]); + expect(out.brand.domain).toBe("hqterm.sh"); + }); +}); + +describe("creativesFromCopy", () => { + it("leaves out formats it has no words for", () => { + const brand = { url: "https://x.test/", domain: "x.test", title: "", description: "", text: "", logoUrl: null, ogImage: null, themeColor: null, palette: [] }; + const copy = readC0mputeCopy({ headline: "H", body: "B" }); + expect(creativesFromCopy(brand as never, { ...copy, body: " " }, null)).toEqual([]); + }); +});