feat(intake): question cap and transcript sanitisation
This commit is contained in:
parent
0c4bbd951f
commit
95c4c6c8fa
2 changed files with 135 additions and 0 deletions
81
src/lib/intake/turn.test.ts
Normal file
81
src/lib/intake/turn.test.ts
Normal file
|
|
@ -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.");
|
||||
});
|
||||
});
|
||||
54
src/lib/intake/turn.ts
Normal file
54
src/lib/intake/turn.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
Loading…
Reference in a new issue