feat(intake): tell the model to describe rather than invent (#223)

Pure and separately tested, because this is where the correctness of every draft is decided. Nothing downstream can distinguish an observed detail from an invented one — the description arrives as prose either way — so the instruction is the only place that distinction can be enforced, and the tests assert it is actually present.

On a one-of-a-kind item an invented "1930s hand-thrown stoneware" is not a cosmetic error but a false claim on a public shop, and the shop answers for it rather than the model. Visible damage is called out for the same reason in reverse: a buyer finding a chip on arrival is worse than reading about it beforehand.

Categories and tags are listed rather than described, so the model chooses from what exists instead of inventing a taxonomy the storefront filters know nothing about, and declining is explicitly allowed so a model with no matching option does not pick the closest wrong one.

The note is quoted and labelled as the sender's rather than merged into the instruction: it is untrusted text from an unauthenticated stranger and should read as evidence to weigh, not as something the shop asserts. A whitespace-only note counts as no note.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-01 08:32:40 -05:00
co-authored by Claude Opus 5
parent 22910ce9e2
commit 5988f4a545
2 changed files with 147 additions and 0 deletions
+76
View File
@@ -0,0 +1,76 @@
/**
* What the model is told, and what it is shown.
*
* Pure and separately tested because this is where the correctness of every
* draft is decided. Nothing downstream can distinguish an observed detail from
* an invented one — the description arrives as prose either way — so the only
* place that distinction can be enforced is here, in the instruction.
*
* On a one-of-a-kind item an invented "1930s hand-thrown stoneware" is a false
* claim on a storefront, and it is the shop that answers for it rather than the
* model. The submitter's note is the only trustworthy source for anything a
* photograph cannot show.
*/
/**
* Listed rather than described, so the model chooses from what exists instead
* of inventing a taxonomy the storefront filters know nothing about.
*/
function offer(values: string[]): string {
return values.length > 0 ? values.join(', ') : '(none defined yet)';
}
export function buildSystemPrompt(categories: string[], tags: string[]): string {
return [
'You write short listings for a shop that sells one-of-a-kind second-hand items.',
'',
'You are given photographs of a single item, and sometimes a note from the person',
'sending it in.',
'',
'Describe only what you can see in the photographs, plus whatever the note tells you.',
'Do not state a material, age, maker, or provenance that is neither visible nor in the',
'note. If you do not know something, leave it out rather than guessing — a wrong detail',
'here becomes a false claim on a public shop, and the shop answers for it rather than you.',
'Mention visible damage plainly; a buyer finding it later is worse than reading about it now.',
'',
`Choose a category from this list, or null if none fits: ${offer(categories)}`,
`Choose tags from this list, or an empty list if none fit: ${offer(tags)}`,
'Do not invent categories or tags that are not listed.',
'',
'Suggest a price in cents if the photographs and note give you enough to judge one,',
'or null if they do not. A person reviews everything before it is listed.'
].join('\n');
}
interface Photo {
mediaType: string;
base64: string;
}
/**
* The photos, then the note.
*
* Images first because the note refers to them. The note is quoted and labelled
* as coming from the sender rather than merged into the instruction: it is
* untrusted text from an unauthenticated stranger, and it should read as
* evidence to weigh rather than as something the shop is asserting.
*/
export function buildUserContent(photos: Photo[], note: string | null): unknown[] {
const blocks: unknown[] = photos.map((photo) => ({
type: 'image',
source: { type: 'base64', media_type: photo.mediaType, data: photo.base64 }
}));
// Whitespace counts as absent. Otherwise an accidental space arrives looking
// like something the sender meant to say.
const hasNote = note !== null && note.trim() !== '';
blocks.push({
type: 'text',
text: hasNote
? `The sender wrote this about the item:\n\n${note}`
: 'The sender left no note, so the photographs are all you have.'
});
return blocks;
}