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:
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { buildSystemPrompt, buildUserContent } from '../../src/intake/draftPrompt';
|
||||||
|
|
||||||
|
describe('buildSystemPrompt', () => {
|
||||||
|
const prompt = buildSystemPrompt(['Ceramics', 'Textiles'], ['vintage', 'blue']);
|
||||||
|
|
||||||
|
// The whole reason this function is tested rather than inlined. On a
|
||||||
|
// one-of-a-kind item an invented age or maker is a false claim on a
|
||||||
|
// storefront, and nothing downstream can tell an invented detail from an
|
||||||
|
// observed one.
|
||||||
|
it('forbids inventing what is neither visible nor in the note', () => {
|
||||||
|
expect(prompt).toMatch(/do not (state|invent)/i);
|
||||||
|
expect(prompt).toMatch(/material/i);
|
||||||
|
expect(prompt).toMatch(/age/i);
|
||||||
|
expect(prompt).toMatch(/maker/i);
|
||||||
|
expect(prompt).toMatch(/provenance/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('offers the categories it may choose from', () => {
|
||||||
|
expect(prompt).toContain('Ceramics');
|
||||||
|
expect(prompt).toContain('Textiles');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('offers the tags it may choose from', () => {
|
||||||
|
expect(prompt).toContain('vintage');
|
||||||
|
expect(prompt).toContain('blue');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Otherwise a model with no matching option picks the closest wrong one.
|
||||||
|
it('permits declining a category', () => {
|
||||||
|
expect(prompt).toMatch(/null/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('survives a shop with no categories or tags yet', () => {
|
||||||
|
expect(() => buildSystemPrompt([], [])).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildUserContent', () => {
|
||||||
|
const photo = { mediaType: 'image/jpeg', base64: 'AAAA' };
|
||||||
|
type Block = { type: string; source?: { media_type: string; data: string } };
|
||||||
|
|
||||||
|
it('sends every photo as an image block', () => {
|
||||||
|
const images = (buildUserContent([photo, photo], 'a note') as Block[]).filter(
|
||||||
|
(block) => block.type === 'image'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(images).toHaveLength(2);
|
||||||
|
expect(images[0]?.source?.media_type).toBe('image/jpeg');
|
||||||
|
expect(images[0]?.source?.data).toBe('AAAA');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes the note verbatim', () => {
|
||||||
|
const text = JSON.stringify(buildUserContent([photo], 'Chipped base, bought 1998'));
|
||||||
|
expect(text).toContain('Chipped base, bought 1998');
|
||||||
|
});
|
||||||
|
|
||||||
|
// A submission with no note is ordinary — the field is optional — and the
|
||||||
|
// model has to be told so rather than left to read an empty string as a fact
|
||||||
|
// about the item.
|
||||||
|
it('says so when there is no note', () => {
|
||||||
|
const text = JSON.stringify(buildUserContent([photo], null));
|
||||||
|
expect(text).toMatch(/no note/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
// A whitespace-only note is the same thing as no note. Sending it would let
|
||||||
|
// the model treat an accidental space as something the sender meant to say.
|
||||||
|
it('treats a blank note as no note', () => {
|
||||||
|
const text = JSON.stringify(buildUserContent([photo], ' '));
|
||||||
|
expect(text).toMatch(/no note/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user