From ea284b325c50af6446f80ab07364754875bbeb65 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sat, 7 Nov 2026 02:07:01 +0000 Subject: [PATCH] Ads: show a page-text draft at once, then AI copy; never dead-end the form c0mpute's 9B model answered ad copy in the shape of the JSON Schema, so all fields read undefined and, with both paid keys out of credit, the form failed. ai-providers 0.1.1 unwraps that. On top of it: - readC0mputeCopy keeps near misses: long copy is shortened, missing colours and summaries are filled, only headline and body are required. - /dashboard/ads/new draws a page-text draft in seconds and swaps in the model's copy when it arrives, without overwriting edits. The slow call is an /api route because server actions run one at a time and would hold up Save. - If every provider fails (order unchanged: c0mpute, then OpenAI/Anthropic) the draft stays. Regenerate and A/B arms still fail rather than get page copy. - A format with no words is left out instead of shipped blank. Co-Authored-By: Claude Opus 5.5 --- app/(app)/dashboard/ads/new/form.tsx | 74 +++++++++++++++++- app/actions/ads.ts | 27 +++++++ app/api/ads/preview/route.ts | 15 ++++ bun.lock | 6 +- lib/ads/agent.ts | 3 + lib/ads/creative.ts | 96 ++++++++++++++++++++--- lib/lx/backendAi.ts | 9 ++- package.json | 2 +- tests/ads-copy-fallback.test.ts | 110 +++++++++++++++++++++++++++ 9 files changed, 323 insertions(+), 19 deletions(-) create mode 100644 app/api/ads/preview/route.ts create mode 100644 tests/ads-copy-fallback.test.ts 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([]); + }); +});