Feature/223 drafting worker #251
@@ -0,0 +1,78 @@
|
||||
import { PoolClient } from 'pg';
|
||||
import { DraftOutcome } from './draftListing';
|
||||
|
||||
/**
|
||||
* Writes a finished draft to the database.
|
||||
*
|
||||
* The copy goes on `item_drafts`, never on the item. The item keeps its
|
||||
* placeholder name and description until a person approves them in the review
|
||||
* queue (#225) — nothing a model wrote reaches the catalogue unreviewed.
|
||||
*
|
||||
* The price is the deliberate exception, because #220 chose to price an item on
|
||||
* arrival rather than leave it unpriced. `price_source` records that the number
|
||||
* came from a model rather than a person, which is what lets the review queue
|
||||
* show it as unconfirmed.
|
||||
*/
|
||||
export async function applyDraft(
|
||||
client: PoolClient,
|
||||
itemId: number,
|
||||
outcome: DraftOutcome
|
||||
): Promise<void> {
|
||||
const { draft } = outcome;
|
||||
|
||||
// Checked against the real table before it is stored. The schema constrains
|
||||
// the shape of the answer but cannot enforce membership, and a category the
|
||||
// shop does not have would be invisible to every storefront filter — a draft
|
||||
// nobody could find, rather than an obvious error.
|
||||
const categoryId = draft.category === null
|
||||
? null
|
||||
: (
|
||||
await client.query<{ id: number }>(
|
||||
`SELECT id FROM categories WHERE lower(name) = lower($1)`,
|
||||
[draft.category]
|
||||
)
|
||||
).rows[0]?.id ?? null;
|
||||
|
||||
const hasPrice = draft.suggestedPriceCents !== null;
|
||||
|
||||
await client.query(
|
||||
`UPDATE item_drafts
|
||||
SET state = 'ready',
|
||||
model = $2,
|
||||
ai_name = $3,
|
||||
ai_description = $4,
|
||||
ai_category_id = $5,
|
||||
ai_tag_names = $6,
|
||||
ai_suggested_price_cents = $7,
|
||||
price_source = $8,
|
||||
input_tokens = $9,
|
||||
output_tokens = $10,
|
||||
cost_micros = $11,
|
||||
ai_error = NULL,
|
||||
drafted_at = now()
|
||||
WHERE item_id = $1`,
|
||||
[
|
||||
itemId,
|
||||
outcome.model,
|
||||
draft.name,
|
||||
draft.description,
|
||||
categoryId,
|
||||
draft.tags,
|
||||
draft.suggestedPriceCents,
|
||||
hasPrice ? 'ai' : 'default',
|
||||
outcome.inputTokens,
|
||||
outcome.outputTokens,
|
||||
outcome.costMicros
|
||||
]
|
||||
);
|
||||
|
||||
// Only when there is one. Absent, the item keeps the migration's default and
|
||||
// price_source stays 'default' — the review queue shows both the same way, as
|
||||
// a number nobody has chosen yet.
|
||||
if (hasPrice) {
|
||||
await client.query(`UPDATE items SET price_cents = $2 WHERE id = $1`, [
|
||||
itemId,
|
||||
draft.suggestedPriceCents
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import { pool } from '../../src/db';
|
||||
import { applyDraft } from '../../src/intake/applyDraft';
|
||||
import { DraftOutcome } from '../../src/intake/draftListing';
|
||||
import { resetDb, closeDb } from './setup/testDb';
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetDb();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await pool.end();
|
||||
await closeDb();
|
||||
});
|
||||
|
||||
/**
|
||||
* An item at 'pending' with a draft row waiting, which is what a submission
|
||||
* leaves behind. price_cents is not set: it takes the 8000 default from #222's
|
||||
* migration, and that default is what the no-price case below asserts on.
|
||||
*/
|
||||
async function seedSubmission(): Promise<number> {
|
||||
const { rows } = await pool.query<{ id: number }>(
|
||||
`INSERT INTO items (name, status) VALUES ('Submission placeholder', 'pending') RETURNING id`
|
||||
);
|
||||
const itemId = rows[0]!.id;
|
||||
await pool.query(`INSERT INTO item_drafts (item_id, submitter_note) VALUES ($1, 'a note')`, [itemId]);
|
||||
return itemId;
|
||||
}
|
||||
|
||||
async function apply(itemId: number, outcome: DraftOutcome): Promise<void> {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await applyDraft(client, itemId, outcome);
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
const outcome: DraftOutcome = {
|
||||
draft: {
|
||||
name: 'Blue stoneware vase',
|
||||
description: 'Hand-thrown, chipped base.',
|
||||
category: null,
|
||||
tags: ['blue'],
|
||||
suggestedPriceCents: 4500
|
||||
},
|
||||
model: 'claude-sonnet-5',
|
||||
inputTokens: 1000,
|
||||
outputTokens: 200,
|
||||
costMicros: 4000
|
||||
};
|
||||
|
||||
describe('applying a draft', () => {
|
||||
it('writes the copy onto the draft row, not the item', async () => {
|
||||
const itemId = await seedSubmission();
|
||||
await apply(itemId, outcome);
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`SELECT ai_name, ai_description, state, model, ai_tag_names FROM item_drafts WHERE item_id = $1`,
|
||||
[itemId]
|
||||
);
|
||||
expect(rows[0]?.ai_name).toBe('Blue stoneware vase');
|
||||
expect(rows[0]?.state).toBe('ready');
|
||||
expect(rows[0]?.model).toBe('claude-sonnet-5');
|
||||
expect(rows[0]?.ai_tag_names).toEqual(['blue']);
|
||||
|
||||
// The item keeps its placeholder name until a person approves the draft.
|
||||
// Nothing a model wrote should reach the catalogue unreviewed.
|
||||
const item = await pool.query(`SELECT name FROM items WHERE id = $1`, [itemId]);
|
||||
expect(item.rows[0]?.name).toBe('Submission placeholder');
|
||||
});
|
||||
|
||||
// The one thing that does reach the item, because #220 chose pre-pricing over
|
||||
// an unpriced row — with price_source recording that nobody chose it.
|
||||
it('writes a suggested price onto the item and records where it came from', async () => {
|
||||
const itemId = await seedSubmission();
|
||||
await apply(itemId, outcome);
|
||||
|
||||
const item = await pool.query(`SELECT price_cents FROM items WHERE id = $1`, [itemId]);
|
||||
expect(item.rows[0]?.price_cents).toBe(4500);
|
||||
|
||||
const draft = await pool.query(
|
||||
`SELECT price_source, ai_suggested_price_cents FROM item_drafts WHERE item_id = $1`,
|
||||
[itemId]
|
||||
);
|
||||
expect(draft.rows[0]?.price_source).toBe('ai');
|
||||
expect(draft.rows[0]?.ai_suggested_price_cents).toBe(4500);
|
||||
});
|
||||
|
||||
it('leaves the default price alone when the model would not guess', async () => {
|
||||
const itemId = await seedSubmission();
|
||||
await apply(itemId, { ...outcome, draft: { ...outcome.draft, suggestedPriceCents: null } });
|
||||
|
||||
const item = await pool.query(`SELECT price_cents FROM items WHERE id = $1`, [itemId]);
|
||||
expect(item.rows[0]?.price_cents).toBe(8000);
|
||||
|
||||
const draft = await pool.query(`SELECT price_source FROM item_drafts WHERE item_id = $1`, [itemId]);
|
||||
expect(draft.rows[0]?.price_source).toBe('default');
|
||||
});
|
||||
|
||||
it('records what the call cost', async () => {
|
||||
const itemId = await seedSubmission();
|
||||
await apply(itemId, outcome);
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`SELECT input_tokens, output_tokens, cost_micros, drafted_at FROM item_drafts WHERE item_id = $1`,
|
||||
[itemId]
|
||||
);
|
||||
expect(rows[0]?.input_tokens).toBe(1000);
|
||||
expect(rows[0]?.output_tokens).toBe(200);
|
||||
expect(rows[0]?.cost_micros).toBe(4000);
|
||||
expect(rows[0]?.drafted_at).not.toBeNull();
|
||||
});
|
||||
|
||||
it('stores a category the shop actually has', async () => {
|
||||
const itemId = await seedSubmission();
|
||||
const { rows } = await pool.query<{ id: number }>(
|
||||
`INSERT INTO categories (name) VALUES ('Ceramics') RETURNING id`
|
||||
);
|
||||
|
||||
await apply(itemId, { ...outcome, draft: { ...outcome.draft, category: 'Ceramics' } });
|
||||
|
||||
const draft = await pool.query(`SELECT ai_category_id FROM item_drafts WHERE item_id = $1`, [itemId]);
|
||||
expect(draft.rows[0]?.ai_category_id).toBe(rows[0]!.id);
|
||||
});
|
||||
|
||||
// A category the shop does not have would be invisible to every storefront
|
||||
// filter, and the schema cannot enforce membership — so it is checked here.
|
||||
it('ignores a category that does not exist', async () => {
|
||||
const itemId = await seedSubmission();
|
||||
await apply(itemId, { ...outcome, draft: { ...outcome.draft, category: 'Invented Category' } });
|
||||
|
||||
const { rows } = await pool.query(`SELECT ai_category_id FROM item_drafts WHERE item_id = $1`, [itemId]);
|
||||
expect(rows[0]?.ai_category_id).toBeNull();
|
||||
});
|
||||
|
||||
// A retry after a failure has to clear the message, or a draft that
|
||||
// eventually succeeded still reads as broken in the review queue.
|
||||
it('clears a previous error when a retry succeeds', async () => {
|
||||
const itemId = await seedSubmission();
|
||||
await pool.query(
|
||||
`UPDATE item_drafts SET state = 'failed', ai_error = 'timed out', attempts = 1 WHERE item_id = $1`,
|
||||
[itemId]
|
||||
);
|
||||
|
||||
await apply(itemId, outcome);
|
||||
|
||||
const { rows } = await pool.query(`SELECT state, ai_error FROM item_drafts WHERE item_id = $1`, [itemId]);
|
||||
expect(rows[0]?.state).toBe('ready');
|
||||
expect(rows[0]?.ai_error).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user