Skip to content

Commit 30bd7f6

Browse files
ralyodioclaude
andauthored
Teach the ad generator to write prose, for ads that live inside content (#202)
Every format so far renders the same tiny copy set: a headline, one benefit line averaging 76 characters, and a call to action. That is the right size for a 300x250 box and far too little for a placement that sits *inside* somebody's writing -- a sponsored paragraph in a blog post, or the body of a feed item, where the ad is read rather than glanced at. So the generator now also writes two lengths of editorial prose from the destination site it already fetches for colours and copy: summary_short one or two sentences -- the inline mention summary_long two or three paragraphs -- the blog-post form The register is the point, and it is stated explicitly in the prompt: third person, factual, no second person, no call to action, no hype adjectives, "the way a journalist would describe the product in one line of a round-up". Without that instruction the model returns three restatements of the headline, which is useless -- ad voice is exactly what makes a sponsored paragraph read as an intrusion and get skipped. The no-invention rule is restated harder for these than for the display copy, because more room is more room to fabricate: if the page does not say who it is for or what it costs, neither do we. **The domain decides what is stored.** summary_domain records which site the prose was written from. A user can paste a URL, get a preview, then edit the URL before saving, and prose confidently describing the first site is worse than no prose at all. So it is only stored when the domain it was written from matches where the campaign points, and serving re-checks the same thing on read: a mismatch is treated as absent and the ad falls back to its short creative body. Regeneration re-derives the domain from the campaign's current URL, since a changed destination is exactly when a summary goes stale. Rendering: a new feed style `article` -- artwork, heading, the real paragraphs, the call to action, and a disclosure line -- in HTML, Markdown and plain text. `card` now prefers the short summary over the banner line. Both fall back to the old behaviour for the campaigns generated before this existed, because an "article" carrying a single line is just a worse card. Disclosure is strongest in the article form, deliberately. The whole point of the long form is that it reads like editorial, so the line saying it is paid for is the only thing distinguishing it from the post above it. Two structural choices worth keeping: The summary lookup is its own query rather than two more columns on the creative join in serveAd. That join is *the* serving query -- if it fails, every unit on every slot goes dark -- and these columns sit behind an `add column if not exists` in a migration applied by hand, so a deploy can run ahead of the schema. One extra round trip on the feed path, which is one fetch per publisher build rather than one per reader, is a cheap price for not being able to take banner and terminal serving down with it. The write path is resilient for the same reason: an unknown column retries without the summary rather than refusing to create the campaign. A test caught a real bug on the way: `^\s*` in the markdown-stripping regex also matches the newline *before* the anchor under the m flag, so stripping a heading or bullet swallowed the blank line separating it from the previous paragraph and silently merged two paragraphs into one. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent f6fd26b commit 30bd7f6

9 files changed

Lines changed: 626 additions & 17 deletions

File tree

‎app/actions/ads.ts‎

Lines changed: 95 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,11 @@ import { getOrCreateDefaultOrg } from "@/lib/orgs";
88
import {
99
generateAdCreatives,
1010
AD_FORMAT_IDS,
11+
cleanSummary,
12+
summaryDomain,
1113
type AdCreative,
1214
type AdFormatId,
15+
type AdSummary,
1316
} from "@/lib/ads/creative";
1417
import type { SiteBrand } from "@/lib/ads/brand";
1518
import { MIN_PAYOUT_CENTS, DEFAULT_BID_CREDITS } from "@/lib/ads/pricing";
@@ -26,10 +29,55 @@ function domainOf(url: string): string {
2629
}
2730
}
2831

32+
/**
33+
* The summary columns to write, or {} when there is nothing trustworthy.
34+
*
35+
* Two guards, and the second is the point of the whole feature. Prose is only
36+
* stored when it is non-empty *and* when the domain it was written from is the
37+
* domain the campaign now points at — the user can paste one URL, get a
38+
* preview, then edit the URL before saving, and a summary describing the first
39+
* site would be a confident description of the wrong product.
40+
*
41+
* The lengths are re-clamped here rather than trusted from the client: this is
42+
* a server action and its input crosses the network.
43+
*
44+
* @param summary what the generator produced (or the client sent back)
45+
* @param domain the campaign's destination domain, normalised
46+
*/
47+
function summaryPayload(
48+
summary: Partial<AdSummary> | null | undefined,
49+
domain: string,
50+
): Record<string, unknown> {
51+
if (!summary) return {};
52+
53+
const short = cleanSummary(summary.short, 400);
54+
const long = cleanSummary(summary.long, 1600);
55+
if (!short && !long) return {};
56+
57+
// summaryDomain() normalises a URL; the value here is already a bare host, so
58+
// compare it directly and case-insensitively.
59+
const wrote = String(summary.domain ?? "").toLowerCase();
60+
if (!wrote || wrote !== domain.toLowerCase()) return {};
61+
62+
return {
63+
summary_short: short || null,
64+
summary_long: long || null,
65+
summary_domain: domain.toLowerCase(),
66+
summary_generated_at: new Date().toISOString(),
67+
};
68+
}
69+
2970
// Auto-generate ad creatives from a destination URL. No DB write — the client
3071
// holds the result, lets the user edit/replace, then calls saveCampaign.
3172
export async function previewAds(input: { url: string }): Promise<
32-
| { ok: true; brand: SiteBrand; creatives: AdCreative[]; provider: string; suggestedName: string }
73+
| {
74+
ok: true;
75+
brand: SiteBrand;
76+
creatives: AdCreative[];
77+
provider: string;
78+
suggestedName: string;
79+
summary: AdSummary;
80+
}
3381
| { ok: false; error: string }
3482
> {
3583
const supabase = await createClient();
@@ -42,13 +90,16 @@ export async function previewAds(input: { url: string }): Promise<
4290
if (!check.ok) return { ok: false, error: check.reason };
4391

4492
try {
45-
const { brand, creatives, provider } = await generateAdCreatives(check.url, { supabase });
93+
const { brand, creatives, provider, summary } = await generateAdCreatives(check.url, {
94+
supabase,
95+
});
4696
return {
4797
ok: true,
4898
brand,
4999
creatives,
50100
provider,
51101
suggestedName: brand.title?.slice(0, 60) || domainOf(check.url),
102+
summary,
52103
};
53104
} catch (err) {
54105
return {
@@ -89,6 +140,7 @@ export async function saveCampaign(input: {
89140
bidCredits?: number;
90141
brand?: SiteBrand | null;
91142
creatives: Partial<AdCreative>[];
143+
summary?: Partial<AdSummary> | null;
92144
}): Promise<{ ok: true; id: string; refSlug: string } | { ok: false; error: string }> {
93145
const supabase = await createClient();
94146
const {
@@ -126,6 +178,13 @@ export async function saveCampaign(input: {
126178
};
127179
if (org.id) payload.organization_id = org.id;
128180

181+
// Editorial prose for placements that live inside content. Only stored when
182+
// it actually describes where the campaign points: the user can edit the URL
183+
// between preview and save, and prose about a different site is worse than
184+
// none. Same reason serving re-checks it (see summaryFor in lib/ads/serve).
185+
const summaryFields = summaryPayload(input.summary, domainOf(check.url));
186+
Object.assign(payload, summaryFields);
187+
129188
let campaign = await supabase
130189
.from("ad_campaigns")
131190
.insert(payload)
@@ -144,6 +203,20 @@ export async function saveCampaign(input: {
144203
.select("id, ref_slug")
145204
.single();
146205
}
206+
207+
// The summary columns live behind an `add column if not exists` and
208+
// migrations here are applied by hand, so this deploy can briefly run ahead
209+
// of the schema. Retry without them rather than refusing to create the
210+
// campaign: prose is an enhancement, a campaign that cannot be saved is the
211+
// whole product failing. Same trade the impression short_code makes.
212+
if (campaign.error && /summary_|schema cache|column/i.test(campaign.error.message ?? "")) {
213+
for (const key of Object.keys(summaryFields)) delete payload[key];
214+
campaign = await supabase
215+
.from("ad_campaigns")
216+
.insert(payload)
217+
.select("id, ref_slug")
218+
.single();
219+
}
147220
if (campaign.error || !campaign.data) {
148221
return { ok: false, error: campaign.error?.message ?? "Failed to save campaign." };
149222
}
@@ -278,6 +351,26 @@ export async function regenerateCampaign(input: {
278351
};
279352
}
280353

354+
// Refresh the editorial prose alongside the creatives. Regenerating is
355+
// exactly when a stale summary gets fixed — the destination may well be what
356+
// changed — so the domain is re-derived from the campaign's current URL
357+
// rather than from whatever the summary previously claimed.
358+
const summaryFields = summaryPayload(
359+
generated.summary,
360+
summaryDomain(campaign.destination_url),
361+
);
362+
if (Object.keys(summaryFields).length > 0) {
363+
const { error: sErr } = await supabase
364+
.from("ad_campaigns")
365+
.update(summaryFields)
366+
.eq("id", input.id);
367+
// A missing column (deploy ahead of the hand-applied migration) must not
368+
// fail a regeneration whose creatives are fine.
369+
if (sErr && !/summary_|schema cache|column/i.test(sErr.message ?? "")) {
370+
return { ok: false, error: sErr.message };
371+
}
372+
}
373+
281374
const { data: existing } = await supabase
282375
.from("ad_creatives")
283376
.select("id, format")

‎app/api/ads/feed/route.ts‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,10 @@
1616
// bare curl still rotates real campaigns.
1717
// as wire shape: rss | atom | json | html | markdown | text | fields.
1818
// Default rss. See lib/ads/feeditem for what each one is for.
19-
// style body style: text | card | terminal. Default text — the long thin one.
19+
// style body style: text | card | terminal | article. Default text — the long
20+
// thin one. `article` renders the campaign's editorial summary as real
21+
// paragraphs, for an ad that lives inside a blog post; it falls back to
22+
// `card` for campaigns with no prose.
2023
// guid identity rotation: daily | weekly | fill | static. Default daily.
2124
// n how many ads to return, 1..5. Each is an independent fill with its
2225
// own impression and its own identity.
@@ -33,7 +36,7 @@
3336
// go through the ordinary redirector and are metered per click.
3437

3538
import { NextRequest, NextResponse } from "next/server";
36-
import { serveAd } from "@/lib/ads/serve";
39+
import { campaignSummary, serveAd } from "@/lib/ads/serve";
3740
import { houseFill } from "@/lib/ads/house";
3841
import { FEED_FORMAT_ID } from "@/lib/ads/formats";
3942
import {
@@ -187,9 +190,16 @@ export async function GET(request: NextRequest) {
187190
const isHouse = fill.campaignId === "house";
188191
const clickUrl = src && isHouse ? withParam(fill.clickUrl, "s", src) : fill.clickUrl;
189192

193+
// Editorial prose, when the campaign has any that still describes where
194+
// it points. Its own query, deliberately not part of the serving join —
195+
// see campaignSummary. Best-effort: a null here just renders the short
196+
// creative body, which is what every campaign did before this existed.
197+
const summary = await campaignSummary(fill.campaignId);
198+
190199
inputs.push({
191200
creative: fill.creative,
192201
clickUrl,
202+
summary,
193203
slotId: slotId || "default",
194204
impressionId: fill.impressionId,
195205
tier: fill.tier,

‎lib/ads/creative.ts‎

Lines changed: 108 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ import { extractSiteBrand, type SiteBrand } from "./brand";
99
import { resolveAdHeroImage } from "./heroImage";
1010
import { renderCreativeText, renderTerminalHtml } from "./terminal";
1111
import { renderFeedHtml } from "./feeditem";
12+
// One implementation, next to the renderers that consume it. Re-exported so a
13+
// server-side caller working with summaries has a single import.
14+
export { summaryParagraphs } from "./feeditem";
1215
import {
1316
AD_FORMATS,
1417
AD_FORMAT_IDS,
@@ -60,6 +63,25 @@ const CopySchema = z.object({
6063
bgColor: z.string().describe("Background hex like #0b0d10 — on-brand, good contrast with fg."),
6164
fgColor: z.string().describe("Text hex with strong contrast against bg."),
6265
accentColor: z.string().describe("Accent/CTA hex — the brand's signature colour if visible."),
66+
// The two prose lengths. Everything above is display copy sized for a box;
67+
// these are for placements that sit *inside* somebody's writing, where the ad
68+
// is read rather than glanced at.
69+
summaryShort: z
70+
.string()
71+
.max(400)
72+
.describe(
73+
"One or two plain sentences saying what this is and who it is for, in third person. " +
74+
"Reads as an editorial note, not a slogan — no exclamation marks, no second person, no CTA.",
75+
),
76+
summaryLong: z
77+
.string()
78+
.max(1600)
79+
.describe(
80+
"Two or three short paragraphs, separated by a blank line, for a sponsored section of a " +
81+
"blog post. Third person, factual, specific to this product. Describe what it does, who " +
82+
"it is for, and what is distinctive — only from the page content. No headings, no lists, " +
83+
"no markdown, no links, no invented metrics or prices.",
84+
),
6385
});
6486
type AdCopy = z.infer<typeof CopySchema>;
6587

@@ -72,6 +94,20 @@ const SYSTEM_PROMPT = [
7294
"invent features, prices, or claims. Keep it concrete and specific to this product.",
7395
"Colours must be readable: high contrast between background and foreground.",
7496
"Prefer the site's real brand/accent colour when the palette makes it obvious.",
97+
// The summaries are a different register from the display copy, and saying so
98+
// explicitly is what stops the model returning three restatements of the
99+
// headline. They run inside other people's writing, next to their prose, so
100+
// ad voice is exactly what makes them read as an intrusion and get skipped.
101+
"ALSO write two editorial summaries of the advertiser, in a different register",
102+
"from the ad copy above: third person, calm, factual, the way a journalist would",
103+
"describe the product in one line of a round-up. No slogans, no second person, no",
104+
"calls to action, no exclamation marks, no hype adjectives ('revolutionary',",
105+
"'game-changing', 'seamless'). summaryShort is one or two sentences. summaryLong",
106+
"is two or three short paragraphs separated by blank lines.",
107+
"The same no-invention rule binds them, and binds them harder: these are longer,",
108+
"so there is more room to fabricate. Every fact must come from the page content.",
109+
"If the page does not say who it is for, or what it costs, or how it works, then",
110+
"neither do you — write less rather than filling the space.",
75111
].join(" ");
76112

77113
function buildUserPrompt(brand: SiteBrand): string {
@@ -111,10 +147,71 @@ function copyToCreatives(brand: SiteBrand, copy: AdCopy, heroUrl: string | null)
111147
}));
112148
}
113149

150+
/**
151+
* Editorial prose about the advertiser, in two lengths, plus the domain it was
152+
* written from.
153+
*
154+
* `domain` is what makes the pair trustworthy later: a campaign's destination
155+
* can be edited after generation, and prose describing a site the campaign no
156+
* longer points at is worse than no prose at all. Serving compares this against
157+
* the campaign's current domain and treats a mismatch as absent.
158+
*/
159+
export type AdSummary = {
160+
short: string;
161+
long: string;
162+
domain: string;
163+
};
164+
165+
/** The host a summary describes, normalised the way ad_campaigns stores it. */
166+
export function summaryDomain(rawUrl: string): string {
167+
try {
168+
return new URL(rawUrl).hostname.replace(/^www\./, "").toLowerCase();
169+
} catch {
170+
return "";
171+
}
172+
}
173+
174+
/**
175+
* Tidy a generated summary.
176+
*
177+
* Collapses the runs of blank lines a model likes to emit into single paragraph
178+
* breaks, strips any markdown it reached for despite being told not to, and
179+
* caps the length. Returns "" when there is nothing usable, which every caller
180+
* treats as "no summary" rather than rendering an empty paragraph.
181+
*/
182+
export function cleanSummary(v: unknown, maxLen: number): string {
183+
const text = String(v ?? "")
184+
// eslint-disable-next-line no-control-regex
185+
.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F]/g, "")
186+
// Markdown emphasis/heading/list marks: the summaries are rendered as HTML
187+
// and as plain text, and neither wants a stray asterisk.
188+
// [ \t] rather than \s: with the m flag, \s also matches the newline
189+
// *before* the anchor, so stripping a heading or a bullet swallowed the
190+
// blank line that separated it from the previous paragraph and silently
191+
// merged the two.
192+
.replace(/^[ \t]*#{1,6}[ \t]+/gm, "")
193+
.replace(/^[ \t]*[-*+][ \t]+/gm, "")
194+
.replace(/\*\*(.+?)\*\*/g, "$1")
195+
.replace(/(^|\s)[*_](\S[^*_]*?)[*_](?=\s|$)/g, "$1$2")
196+
.replace(/\r\n?/g, "\n")
197+
.replace(/[ \t]+/g, " ")
198+
.replace(/\n{3,}/g, "\n\n")
199+
.split("\n")
200+
.map((line) => line.trim())
201+
.join("\n")
202+
.trim();
203+
return text.slice(0, maxLen);
204+
}
205+
114206
export async function generateAdCreatives(
115207
rawUrl: string,
116208
opts: { supabase?: SupabaseClient } = {},
117-
): Promise<{ brand: SiteBrand; creatives: AdCreative[]; provider: string }> {
209+
): Promise<{
210+
brand: SiteBrand;
211+
creatives: AdCreative[];
212+
provider: string;
213+
summary: AdSummary;
214+
}> {
118215
const brand = await extractSiteBrand(rawUrl);
119216

120217
const anthropic = env.anthropicApiKey ? new Anthropic({ apiKey: env.anthropicApiKey }) : null;
@@ -149,7 +246,16 @@ export async function generateAdCreatives(
149246
heroUrl = hero?.url ?? null;
150247
}
151248

152-
return { brand, creatives: copyToCreatives(brand, output, heroUrl), provider };
249+
// The summaries are written from the page we just read, so the domain they
250+
// describe is that page's — not whatever the campaign's destination is edited
251+
// to later. Recording it here is what lets serving decide the prose is stale.
252+
const summary: AdSummary = {
253+
short: cleanSummary(output.summaryShort, 400),
254+
long: cleanSummary(output.summaryLong, 1600),
255+
domain: summaryDomain(brand.url || rawUrl),
256+
};
257+
258+
return { brand, creatives: copyToCreatives(brand, output, heroUrl), provider, summary };
153259
}
154260

155261
// Append the campaign ref for click attribution, preserving any existing query.

0 commit comments

Comments
 (0)