A Nakodo campaign starts from a website. We read the site and write a brief: what the brand sells, who buys it, the markets, the searches, the kind of creator that would fit. The how it works page spells the method out, including the part that matters here: everything in the brief can be changed before anything is searched. (If you came looking for the other kind of brief, the one a brand sends a creator, that is a separate guide and a separate document.)
Writing a brief from a website means guessing. The brief records its guesses, and the app offers to walk the brand through them under a "Check these guesses" panel. Originally a guess was a sentence:
assumptions: string[]
Then the panel got better. Instead of listing sentences above the brief, it rings the actual field each guess is about and prints the guess underneath it. For that, a sentence is not enough:
// A guess the brief was written on, and where to check it. `part` is null on
// briefs written before guesses carried one: those point at the whole brief.
export type Assumption = { note: string; part: BriefPart | null };
The brief lives in a JSONB column, so there was never a schema migration to write. There was still an old-shape problem, and three places had to decide what to do about it.
Two schemas for one field
Reading accepts both shapes and normalizes:
// As stored: older briefs kept each guess as a plain sentence.
const storedGuesses = z.array(
z.union([z.string(), z.object({ note: z.string(), part: z.enum(BRIEF_PARTS).nullable() })])
.transform((a): Assumption => (typeof a === "string" ? { note: a, part: null } : a)),
);
and stored briefs that are read straight out of JSONB without being parsed get the same treatment in one line:
export function readAssumptions(brief: Pick<Brief, "assumptions">): Assumption[] {
return (brief.assumptions as (Assumption | string)[]).map((a) => (typeof a === "string" ? { note: a, part: null } : a));
}
Writing is stricter than reading, and narrower than the type:
const guesses = (parts: readonly [BriefPart, ...BriefPart[]]) =>
z.array(z.object({
note: z.string().describe("The guess, in a few words"),
part: z.enum(parts).describe("The part of the brief the guess is in"),
}));
parts is not the full list of eight. It is partsFor(kind), which is six parts for a creator campaign and seven for a business one, because only business campaigns have places to target and only creator campaigns have a follower range. The schema handed to the model is built per campaign, so a creator campaign cannot come back with a guess about its non-existent targeting. part: BriefPart | null is the widest thing the type allows, z.enum(parts) is the narrowest thing a given campaign accepts, and the gap between them is the whole reason this is not one shared schema.
The UI had to mean something for null
The easy thing would be to ring every part when a guess does not say which one it belongs to. The component says no:
// Two notes a part at most: past that the brief reads as a page of warnings
// instead of fields to change. A guess with no part (an older brief, written
// before guesses carried one) marks nothing: ringing every part for it would
// point at the whole brief, which is no help at all; the panel keeps those on
// screen instead.
const MAX_PER_PART = 2;
So old briefs keep the old behaviour, a list, and new briefs get the rings. That is a perfectly liveable end state, and it is the reason the backfill below is a convenience rather than a correctness fix. A migration you are allowed to not run is a nice kind of migration to have.
The backfill is a verb, and the model may decline
Retrofitting a field onto free text is exactly the job a model is good at, so there is a CLI verb for it:
pnpm pipeline guess-parts [campaignId] [--reopen]
It asks, for each old guess, which part of the brief it is about, plus the same guess rewritten in a few words, because the old ones were sentences and the new panel has room for a phrase. The option that makes it useful is the one that is not a part:
const schema = z.object({
placements: z.array(z.object({
index: z.number().describe("The guess this is for, counting from 1"),
part: z.enum([...parts, "none"]).describe("The part of the brief the guess is about"),
note: z.string().describe("The guess in a few words"),
})),
});
"none" means this guess belongs nowhere, and such guesses are dropped rather than parked. The file explains which ones those are: advice to verify something, remarks on how searching works, a reading back of what the brand already chose. None of those name a field the brand could edit, so under the new panel there is nothing for them to ring and nothing for the brand to do. A guess that cannot be acted on is not a guess, it is a paragraph.
The loop that applies the answer assumes the model is fallible in three specific ways:
for (const p of output.placements) {
const note = p.note.trim();
// An index the model invented, a guess it dropped, or one it left empty.
if (!note || p.part === "none" || !old[p.index - 1]) continue;
placed.push({ note, part: p.part });
}
return placed.slice(0, MAX_GUESSES);
Indexes are one-based because that is how they were presented in the input, old[p.index - 1] is the existence check that makes a hallucinated eleventh guess a no-op, and the slice means a model that returns more rows than it was given still cannot grow the field past the four guesses the brief is allowed.
Rerunnable by construction
The verb skips what it should not touch, and the skips are what make it safe to paste twice:
if (!row.brief || (row.brief.assumptionsChecked && !reopen)) continue;
const old = readAssumptions(row.brief);
if (old.length === 0 || old.every((a) => a.part)) continue;
A brief whose guesses the brand already settled is left alone, and so is one where every guess already carries a part, which is to say every brief the verb has already done. The second run of this costs the price of one query.
--reopen is the deliberate exception, and it un-settles a brief by deleting a key rather than writing a false one:
if (reopen) delete brief.assumptionsChecked;
The same convention runs through our brief JSONB: defaults are absences, and a flag that is not true is not stored. It keeps briefs written years apart comparable, and it means the "has this been checked" question has exactly one true answer rather than one per schema generation.
The log is per brief rather than a count, so you can read the placements before anyone opens the app: each brief prints its domain, how many old guesses it had, then one line per placement as part: note. A brief whose guesses were all advice prints (none were about a field, all dropped) instead, which is the output you most want to see spelled out, because a silent zero and a dropped-everything zero look identical in a summary.
If you want to see the brief shape this is all in service of, the how it works page lists what a brief contains and what a brand can change in it, and the pricing page lists how many website research runs each plan gets, which is the budget a brief like this is written out of.
Top comments (0)