diff --git a/backend/src/intake/draftPrompt.ts b/backend/src/intake/draftPrompt.ts new file mode 100644 index 0000000..a145b04 --- /dev/null +++ b/backend/src/intake/draftPrompt.ts @@ -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; +} diff --git a/backend/tests/unit/draftPrompt.test.ts b/backend/tests/unit/draftPrompt.test.ts new file mode 100644 index 0000000..5092ced --- /dev/null +++ b/backend/tests/unit/draftPrompt.test.ts @@ -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); + }); +});