feat(intake): constrain what a draft may contain (#223)
The SDK validates the model's response against this before any of it reaches the database, so a model that answers in prose or invents a field becomes a caught error rather than a row full of nonsense. Everything the model may decline to answer is nullable, because it is told to say nothing rather than guess. A null category is a better answer than a wrong one, and resolving it is what the review queue is for. The name and description are not nullable: a draft without them is not a partial success worth storing. The price is an integer, bounded at both ends. A fractional, negative or absurd figure reaching the review queue is a number somebody has to notice is wrong, and being trustworthy at a glance is that queue's whole job. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,47 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the model must return, enforced rather than hoped for.
|
||||||
|
*
|
||||||
|
* Constraining the shape is the difference between a bad draft and a crash:
|
||||||
|
* the SDK validates the response against this before any of it reaches the
|
||||||
|
* database, so a model that answers in prose or invents a field becomes a
|
||||||
|
* caught error rather than a row full of nonsense.
|
||||||
|
*
|
||||||
|
* Every field the model may decline to answer is nullable, because it is told
|
||||||
|
* to say nothing rather than guess. A null category means it did not recognise
|
||||||
|
* one, which is a better answer than a wrong one and is exactly what the review
|
||||||
|
* queue exists to resolve.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Beyond anything this shop sells, so an absurd number is caught here. */
|
||||||
|
const MAX_SUGGESTED_PRICE_CENTS = 1_000_000;
|
||||||
|
|
||||||
|
export const DraftSchema = z.object({
|
||||||
|
/** A short title. Until this lands, the item is named for its submission. */
|
||||||
|
name: z.string().min(1).max(200),
|
||||||
|
/** The storefront body, rendered with html:false like every other stored body. */
|
||||||
|
description: z.string().min(1).max(4000),
|
||||||
|
/**
|
||||||
|
* Chosen from the categories it was given, or null. The prompt supplies the
|
||||||
|
* closed set; this cannot enforce membership, so applyDraft checks the answer
|
||||||
|
* against the real table before writing anything.
|
||||||
|
*/
|
||||||
|
category: z.string().nullable(),
|
||||||
|
/** Also from a supplied set, and also checked on the way in rather than here. */
|
||||||
|
tags: z.array(z.string()),
|
||||||
|
/**
|
||||||
|
* Cents, or null when it will not guess. Integer and bounded at both ends,
|
||||||
|
* because a fractional, negative or absurd figure reaching the review queue
|
||||||
|
* is a number somebody has to notice is wrong — and being trustworthy at a
|
||||||
|
* glance is that queue's whole job.
|
||||||
|
*/
|
||||||
|
suggestedPriceCents: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(0)
|
||||||
|
.max(MAX_SUGGESTED_PRICE_CENTS)
|
||||||
|
.nullable()
|
||||||
|
});
|
||||||
|
|
||||||
|
export type DraftResult = z.infer<typeof DraftSchema>;
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { DraftSchema } from '../../src/intake/draftSchema';
|
||||||
|
|
||||||
|
const valid = {
|
||||||
|
name: 'Blue stoneware vase',
|
||||||
|
description: 'A hand-thrown vase with a chipped base.',
|
||||||
|
category: 'Ceramics',
|
||||||
|
tags: ['stoneware', 'blue'],
|
||||||
|
suggestedPriceCents: 4500
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('DraftSchema', () => {
|
||||||
|
it('accepts a complete draft', () => {
|
||||||
|
expect(DraftSchema.parse(valid)).toEqual(valid);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The model is told to say nothing rather than guess, so every field it may
|
||||||
|
// decline to answer has to be expressible as absent.
|
||||||
|
it('accepts a draft with no category, tags or price', () => {
|
||||||
|
const parsed = DraftSchema.parse({
|
||||||
|
name: valid.name,
|
||||||
|
description: valid.description,
|
||||||
|
category: null,
|
||||||
|
tags: [],
|
||||||
|
suggestedPriceCents: null
|
||||||
|
});
|
||||||
|
expect(parsed.category).toBeNull();
|
||||||
|
expect(parsed.suggestedPriceCents).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
// A name and a description are the whole point. A draft without them is not
|
||||||
|
// a partial success worth storing.
|
||||||
|
it('refuses a draft with no name', () => {
|
||||||
|
expect(() => DraftSchema.parse({ ...valid, name: '' })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a draft with no description', () => {
|
||||||
|
expect(() => DraftSchema.parse({ ...valid, description: '' })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
// A negative or absurd price reaching the review queue would be a number
|
||||||
|
// somebody has to notice is wrong. Cheaper to refuse it here.
|
||||||
|
it('refuses a negative price', () => {
|
||||||
|
expect(() => DraftSchema.parse({ ...valid, suggestedPriceCents: -1 })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a price beyond anything this shop sells', () => {
|
||||||
|
expect(() => DraftSchema.parse({ ...valid, suggestedPriceCents: 100_000_00 })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cents, not dollars. A float would round somewhere nobody is looking.
|
||||||
|
it('refuses a fractional price', () => {
|
||||||
|
expect(() => DraftSchema.parse({ ...valid, suggestedPriceCents: 45.5 })).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user