Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 95 additions & 2 deletions app/actions/ads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,11 @@ import { getOrCreateDefaultOrg } from "@/lib/orgs";
import {
generateAdCreatives,
AD_FORMAT_IDS,
cleanSummary,
summaryDomain,
type AdCreative,
type AdFormatId,
type AdSummary,
} from "@/lib/ads/creative";
import type { SiteBrand } from "@/lib/ads/brand";
import { MIN_PAYOUT_CENTS, DEFAULT_BID_CREDITS } from "@/lib/ads/pricing";
Expand All @@ -26,10 +29,55 @@ function domainOf(url: string): string {
}
}

/**
* The summary columns to write, or {} when there is nothing trustworthy.
*
* Two guards, and the second is the point of the whole feature. Prose is only
* stored when it is non-empty *and* when the domain it was written from is the
* domain the campaign now points at — the user can paste one URL, get a
* preview, then edit the URL before saving, and a summary describing the first
* site would be a confident description of the wrong product.
*
* The lengths are re-clamped here rather than trusted from the client: this is
* a server action and its input crosses the network.
*
* @param summary what the generator produced (or the client sent back)
* @param domain the campaign's destination domain, normalised
*/
function summaryPayload(
summary: Partial<AdSummary> | null | undefined,
domain: string,
): Record<string, unknown> {
if (!summary) return {};

const short = cleanSummary(summary.short, 400);
const long = cleanSummary(summary.long, 1600);
if (!short && !long) return {};

// summaryDomain() normalises a URL; the value here is already a bare host, so
// compare it directly and case-insensitively.
const wrote = String(summary.domain ?? "").toLowerCase();
if (!wrote || wrote !== domain.toLowerCase()) return {};

return {
summary_short: short || null,
summary_long: long || null,
summary_domain: domain.toLowerCase(),
summary_generated_at: new Date().toISOString(),
};
}

// 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<
| { ok: true; brand: SiteBrand; creatives: AdCreative[]; provider: string; suggestedName: string }
| {
ok: true;
brand: SiteBrand;
creatives: AdCreative[];
provider: string;
suggestedName: string;
summary: AdSummary;
}
| { ok: false; error: string }
> {
const supabase = await createClient();
Expand All @@ -42,13 +90,16 @@ export async function previewAds(input: { url: string }): Promise<
if (!check.ok) return { ok: false, error: check.reason };

try {
const { brand, creatives, provider } = await generateAdCreatives(check.url, { supabase });
const { brand, creatives, provider, summary } = await generateAdCreatives(check.url, {
supabase,
});
return {
ok: true,
brand,
creatives,
provider,
suggestedName: brand.title?.slice(0, 60) || domainOf(check.url),
summary,
};
} catch (err) {
return {
Expand Down Expand Up @@ -89,6 +140,7 @@ export async function saveCampaign(input: {
bidCredits?: number;
brand?: SiteBrand | null;
creatives: Partial<AdCreative>[];
summary?: Partial<AdSummary> | null;
}): Promise<{ ok: true; id: string; refSlug: string } | { ok: false; error: string }> {
const supabase = await createClient();
const {
Expand Down Expand Up @@ -126,6 +178,13 @@ export async function saveCampaign(input: {
};
if (org.id) payload.organization_id = org.id;

// Editorial prose for placements that live inside content. Only stored when
// it actually describes where the campaign points: the user can edit the URL
// between preview and save, and prose about a different site is worse than
// none. Same reason serving re-checks it (see summaryFor in lib/ads/serve).
const summaryFields = summaryPayload(input.summary, domainOf(check.url));
Object.assign(payload, summaryFields);

let campaign = await supabase
.from("ad_campaigns")
.insert(payload)
Expand All @@ -144,6 +203,20 @@ export async function saveCampaign(input: {
.select("id, ref_slug")
.single();
}

// The summary columns live behind an `add column if not exists` and
// migrations here are applied by hand, so this deploy can briefly run ahead
// of the schema. Retry without them rather than refusing to create the
// campaign: prose is an enhancement, a campaign that cannot be saved is the
// whole product failing. Same trade the impression short_code makes.
if (campaign.error && /summary_|schema cache|column/i.test(campaign.error.message ?? "")) {
for (const key of Object.keys(summaryFields)) delete payload[key];
campaign = await supabase
.from("ad_campaigns")
.insert(payload)
.select("id, ref_slug")
.single();
}
if (campaign.error || !campaign.data) {
return { ok: false, error: campaign.error?.message ?? "Failed to save campaign." };
}
Expand Down Expand Up @@ -278,6 +351,26 @@ export async function regenerateCampaign(input: {
};
}

// Refresh the editorial prose alongside the creatives. Regenerating is
// exactly when a stale summary gets fixed — the destination may well be what
// changed — so the domain is re-derived from the campaign's current URL
// rather than from whatever the summary previously claimed.
const summaryFields = summaryPayload(
generated.summary,
summaryDomain(campaign.destination_url),
);
if (Object.keys(summaryFields).length > 0) {
const { error: sErr } = await supabase
.from("ad_campaigns")
.update(summaryFields)
.eq("id", input.id);
// A missing column (deploy ahead of the hand-applied migration) must not
// fail a regeneration whose creatives are fine.
if (sErr && !/summary_|schema cache|column/i.test(sErr.message ?? "")) {
return { ok: false, error: sErr.message };
}
}

const { data: existing } = await supabase
.from("ad_creatives")
.select("id, format")
Expand Down
14 changes: 12 additions & 2 deletions app/api/ads/feed/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@
// bare curl still rotates real campaigns.
// as wire shape: rss | atom | json | html | markdown | text | fields.
// Default rss. See lib/ads/feeditem for what each one is for.
// style body style: text | card | terminal. Default text — the long thin one.
// style body style: text | card | terminal | article. Default text — the long
// thin one. `article` renders the campaign's editorial summary as real
// paragraphs, for an ad that lives inside a blog post; it falls back to
// `card` for campaigns with no prose.
// guid identity rotation: daily | weekly | fill | static. Default daily.
// n how many ads to return, 1..5. Each is an independent fill with its
// own impression and its own identity.
Expand All @@ -33,7 +36,7 @@
// go through the ordinary redirector and are metered per click.

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

// Editorial prose, when the campaign has any that still describes where
// it points. Its own query, deliberately not part of the serving join —
// see campaignSummary. Best-effort: a null here just renders the short
// creative body, which is what every campaign did before this existed.
const summary = await campaignSummary(fill.campaignId);

inputs.push({
creative: fill.creative,
clickUrl,
summary,
slotId: slotId || "default",
impressionId: fill.impressionId,
tier: fill.tier,
Expand Down
110 changes: 108 additions & 2 deletions lib/ads/creative.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ import { extractSiteBrand, type SiteBrand } from "./brand";
import { resolveAdHeroImage } from "./heroImage";
import { renderCreativeText, renderTerminalHtml } from "./terminal";
import { renderFeedHtml } from "./feeditem";
// One implementation, next to the renderers that consume it. Re-exported so a
// server-side caller working with summaries has a single import.
export { summaryParagraphs } from "./feeditem";
import {
AD_FORMATS,
AD_FORMAT_IDS,
Expand Down Expand Up @@ -60,6 +63,25 @@ const CopySchema = z.object({
bgColor: z.string().describe("Background hex like #0b0d10 — on-brand, good contrast with fg."),
fgColor: z.string().describe("Text hex with strong contrast against bg."),
accentColor: z.string().describe("Accent/CTA hex — the brand's signature colour if visible."),
// The two prose lengths. Everything above is display copy sized for a box;
// these are for placements that sit *inside* somebody's writing, where the ad
// is read rather than glanced at.
summaryShort: z
.string()
.max(400)
.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(1600)
.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 " +
"it is for, and what is distinctive — only from the page content. No headings, no lists, " +
"no markdown, no links, no invented metrics or prices.",
),
});
type AdCopy = z.infer<typeof CopySchema>;

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

function buildUserPrompt(brand: SiteBrand): string {
Expand Down Expand Up @@ -111,10 +147,71 @@ function copyToCreatives(brand: SiteBrand, copy: AdCopy, heroUrl: string | null)
}));
}

/**
* Editorial prose about the advertiser, in two lengths, plus the domain it was
* written from.
*
* `domain` is what makes the pair trustworthy later: a campaign's destination
* can be edited after generation, and prose describing a site the campaign no
* longer points at is worse than no prose at all. Serving compares this against
* the campaign's current domain and treats a mismatch as absent.
*/
export type AdSummary = {
short: string;
long: string;
domain: string;
};

/** The host a summary describes, normalised the way ad_campaigns stores it. */
export function summaryDomain(rawUrl: string): string {
try {
return new URL(rawUrl).hostname.replace(/^www\./, "").toLowerCase();
} catch {
return "";
}
}

/**
* Tidy a generated summary.
*
* Collapses the runs of blank lines a model likes to emit into single paragraph
* breaks, strips any markdown it reached for despite being told not to, and
* caps the length. Returns "" when there is nothing usable, which every caller
* treats as "no summary" rather than rendering an empty paragraph.
*/
export function cleanSummary(v: unknown, maxLen: number): string {
const text = String(v ?? "")
// eslint-disable-next-line no-control-regex
.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F]/g, "")
// Markdown emphasis/heading/list marks: the summaries are rendered as HTML
// and as plain text, and neither wants a stray asterisk.
// [ \t] rather than \s: with the m flag, \s also matches the newline
// *before* the anchor, so stripping a heading or a bullet swallowed the
// blank line that separated it from the previous paragraph and silently
// merged the two.
.replace(/^[ \t]*#{1,6}[ \t]+/gm, "")
.replace(/^[ \t]*[-*+][ \t]+/gm, "")
.replace(/\*\*(.+?)\*\*/g, "$1")
.replace(/(^|\s)[*_](\S[^*_]*?)[*_](?=\s|$)/g, "$1$2")
.replace(/\r\n?/g, "\n")
.replace(/[ \t]+/g, " ")
.replace(/\n{3,}/g, "\n\n")
.split("\n")
.map((line) => line.trim())
.join("\n")
.trim();
return text.slice(0, maxLen);
}

export async function generateAdCreatives(
rawUrl: string,
opts: { supabase?: SupabaseClient } = {},
): Promise<{ brand: SiteBrand; creatives: AdCreative[]; provider: string }> {
): Promise<{
brand: SiteBrand;
creatives: AdCreative[];
provider: string;
summary: AdSummary;
}> {
const brand = await extractSiteBrand(rawUrl);

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

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

return { brand, creatives: copyToCreatives(brand, output, heroUrl), provider, summary };
}

// Append the campaign ref for click attribution, preserving any existing query.
Expand Down
Loading
Loading