diff --git a/src/lib/intake/turn.test.ts b/src/lib/intake/turn.test.ts new file mode 100644 index 0000000..3bc11e6 --- /dev/null +++ b/src/lib/intake/turn.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect } from "vitest"; +import { + MAX_ANSWER_LENGTH, + MAX_QUESTIONS, + canAskMore, + sanitiseTurns, + type Turn, +} from "@/lib/intake/turn"; + +function turns(count: number): Turn[] { + return Array.from({ length: count }, (_, i) => ({ + question: `Question ${i + 1}?`, + answer: `Answer ${i + 1}`, + })); +} + +describe("MAX_QUESTIONS", () => { + it("is capped at seven", () => { + // Past about seven, people abandon the form and the developer gets a worse + // brief than a one-line email would have produced. + expect(MAX_QUESTIONS).toBe(7); + }); +}); + +describe("canAskMore", () => { + it("allows a first question", () => { + expect(canAskMore([])).toBe(true); + }); + + it("allows questions up to the cap", () => { + expect(canAskMore(turns(MAX_QUESTIONS - 1))).toBe(true); + }); + + it("refuses at the cap", () => { + expect(canAskMore(turns(MAX_QUESTIONS))).toBe(false); + }); + + it("refuses beyond the cap", () => { + // The transcript arrives from the browser, so it can be anything. + expect(canAskMore(turns(50))).toBe(false); + }); +}); + +describe("sanitiseTurns", () => { + it("keeps well-formed turns unchanged", () => { + const input = [{ question: "Why?", answer: "Because." }]; + expect(sanitiseTurns(input)).toEqual(input); + }); + + it("truncates the transcript to the cap", () => { + // A client could post a thousand turns to blow up the prompt and the bill. + expect(sanitiseTurns(turns(500))).toHaveLength(MAX_QUESTIONS); + }); + + it("caps an over-long answer", () => { + const [turn] = sanitiseTurns([{ question: "Why?", answer: "x".repeat(50_000) }]); + expect(turn.answer).toHaveLength(MAX_ANSWER_LENGTH); + }); + + it("drops turns with no answer", () => { + const result = sanitiseTurns([ + { question: "One?", answer: "Yes" }, + { question: "Two?", answer: " " }, + ]); + expect(result).toHaveLength(1); + }); + + it("survives malformed input from the browser", () => { + // These come off the wire as JSON; TypeScript guarantees nothing here. + expect(sanitiseTurns(null as never)).toEqual([]); + expect(sanitiseTurns("nope" as never)).toEqual([]); + expect(sanitiseTurns([{ question: 1, answer: 2 }] as never)).toEqual([]); + expect(sanitiseTurns([null, undefined] as never)).toEqual([]); + }); + + it("trims whitespace", () => { + const [turn] = sanitiseTurns([{ question: " Why? ", answer: " Because. " }]); + expect(turn.question).toBe("Why?"); + expect(turn.answer).toBe("Because."); + }); +}); diff --git a/src/lib/intake/turn.ts b/src/lib/intake/turn.ts new file mode 100644 index 0000000..cd90529 --- /dev/null +++ b/src/lib/intake/turn.ts @@ -0,0 +1,54 @@ +/** + * The interview's hard limits. + * + * Enforced in code rather than requested in the prompt. A model that decides an + * eighth question is warranted must not be able to ask it, and a transcript + * arriving from a browser must not be able to grow without bound. + */ + +/** Past roughly this many, people abandon the form. */ +export const MAX_QUESTIONS = 7; + +/** One answer. Generous for prose, far short of a denial-of-wallet. */ +export const MAX_ANSWER_LENGTH = 4000; + +export interface Turn { + question: string; + answer: string; +} + +export function canAskMore(turns: Turn[]): boolean { + return (turns?.length ?? 0) < MAX_QUESTIONS; +} + +/** + * Clean a transcript that arrived from the browser. + * + * The client holds the transcript between turns, so everything here is + * untrusted: the shape, the length, the types. Anything that is not a pair of + * non-empty strings is dropped rather than coerced. + */ +export function sanitiseTurns(input: Turn[]): Turn[] { + if (!Array.isArray(input)) return []; + + const out: Turn[] = []; + + for (const entry of input) { + if (!entry || typeof entry !== "object") continue; + + const question = typeof entry.question === "string" ? entry.question.trim() : ""; + const answer = typeof entry.answer === "string" ? entry.answer.trim() : ""; + + // A turn with no answer is not a turn; it is an unanswered question. + if (!question || !answer) continue; + + out.push({ + question: question.slice(0, 500), + answer: answer.slice(0, MAX_ANSWER_LENGTH), + }); + + if (out.length >= MAX_QUESTIONS) break; + } + + return out; +}