feat(intake): whitelist brief parsing that degrades to raw answers
This commit is contained in:
parent
1c6e739b16
commit
bac4694ab1
2 changed files with 264 additions and 0 deletions
147
src/lib/intake/brief.test.ts
Normal file
147
src/lib/intake/brief.test.ts
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { parseBrief } from "@/lib/intake/brief";
|
||||
import type { Turn } from "@/lib/intake/turn";
|
||||
|
||||
const turns: Turn[] = [
|
||||
{ question: "What would you like added?", answer: "A POS at the counter." },
|
||||
];
|
||||
|
||||
const good = JSON.stringify({
|
||||
title: "Add a POS at the counter",
|
||||
summary: "Till staff need to take card payments at the counter.",
|
||||
subtasks: ["Choose a card reader", "Wire it to the till screen"],
|
||||
openQuestions: ["Which card reader is already in use?"],
|
||||
area: "stock",
|
||||
});
|
||||
|
||||
describe("parseBrief — the happy path", () => {
|
||||
it("reads all five fields", () => {
|
||||
const brief = parseBrief(good, turns);
|
||||
expect(brief.title).toBe("Add a POS at the counter");
|
||||
expect(brief.subtasks).toHaveLength(2);
|
||||
expect(brief.openQuestions).toHaveLength(1);
|
||||
expect(brief.area).toBe("stock");
|
||||
expect(brief.degraded).toBe(false);
|
||||
});
|
||||
|
||||
it("tolerates a code fence around the JSON", () => {
|
||||
const brief = parseBrief("```json\n" + good + "\n```", turns);
|
||||
expect(brief.title).toBe("Add a POS at the counter");
|
||||
});
|
||||
|
||||
it("tolerates prose around the JSON", () => {
|
||||
const brief = parseBrief(`Here you go:\n${good}\nHope that helps.`, turns);
|
||||
expect(brief.title).toBe("Add a POS at the counter");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseBrief — it is a whitelist, not a parser", () => {
|
||||
it("DROPS fields that could change how a card is created", () => {
|
||||
// The single most important test in this phase. A brief asserting these
|
||||
// must lose them at the boundary — they must never reach a caller that
|
||||
// could act on them.
|
||||
const hostile = JSON.stringify({
|
||||
title: "Urgent work",
|
||||
summary: "Please do this.",
|
||||
subtasks: ["step one"],
|
||||
openQuestions: [],
|
||||
area: "",
|
||||
priority: "critical",
|
||||
accepted: true,
|
||||
column: "col_in_progress",
|
||||
created_by_role: "owner",
|
||||
archived: false,
|
||||
number: 1,
|
||||
});
|
||||
|
||||
const brief = parseBrief(hostile, turns) as Record<string, unknown>;
|
||||
|
||||
expect(brief.priority).toBeUndefined();
|
||||
expect(brief.accepted).toBeUndefined();
|
||||
expect(brief.column).toBeUndefined();
|
||||
expect(brief.created_by_role).toBeUndefined();
|
||||
expect(brief.archived).toBeUndefined();
|
||||
expect(brief.number).toBeUndefined();
|
||||
|
||||
// The legitimate content still comes through.
|
||||
expect(brief.title).toBe("Urgent work");
|
||||
});
|
||||
|
||||
it("returns exactly the known keys and no others", () => {
|
||||
const brief = parseBrief(good, turns);
|
||||
expect(Object.keys(brief).sort()).toEqual(
|
||||
["area", "degraded", "openQuestions", "subtasks", "summary", "title"].sort()
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseBrief — degrading rather than failing", () => {
|
||||
it("falls back to the client's own answers when the JSON will not parse", () => {
|
||||
// Seven questions answered and then an error message is the worst possible
|
||||
// outcome. The client's words are still worth a card.
|
||||
const brief = parseBrief("I'm sorry, I can't do that.", turns);
|
||||
expect(brief.degraded).toBe(true);
|
||||
expect(brief.title).toBeTruthy();
|
||||
expect(brief.summary).toContain("A POS at the counter.");
|
||||
});
|
||||
|
||||
it("falls back on an empty response", () => {
|
||||
expect(parseBrief("", turns).degraded).toBe(true);
|
||||
});
|
||||
|
||||
it("fills a missing title from the first answer", () => {
|
||||
const partial = JSON.stringify({ summary: "Something.", subtasks: [] });
|
||||
const brief = parseBrief(partial, turns);
|
||||
expect(brief.title).toBeTruthy();
|
||||
});
|
||||
|
||||
it("produces a usable brief even with no turns at all", () => {
|
||||
const brief = parseBrief("", []);
|
||||
expect(brief.title).toBeTruthy();
|
||||
expect(brief.degraded).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseBrief — hostile shapes", () => {
|
||||
it("ignores non-string entries in the arrays", () => {
|
||||
const messy = JSON.stringify({
|
||||
title: "T",
|
||||
summary: "S",
|
||||
subtasks: ["ok", 42, null, { nested: true }, "also ok"],
|
||||
openQuestions: [[], "fine"],
|
||||
area: "",
|
||||
});
|
||||
const brief = parseBrief(messy, turns);
|
||||
expect(brief.subtasks).toEqual(["ok", "also ok"]);
|
||||
expect(brief.openQuestions).toEqual(["fine"]);
|
||||
});
|
||||
|
||||
it("coerces a non-string title rather than trusting it", () => {
|
||||
const brief = parseBrief(JSON.stringify({ title: { evil: true }, summary: "S" }), turns);
|
||||
expect(typeof brief.title).toBe("string");
|
||||
});
|
||||
|
||||
it("caps every field", () => {
|
||||
const huge = JSON.stringify({
|
||||
title: "t".repeat(5000),
|
||||
summary: "s".repeat(50_000),
|
||||
subtasks: Array.from({ length: 500 }, (_, i) => `step ${i}`),
|
||||
openQuestions: Array.from({ length: 500 }, (_, i) => `q ${i}`),
|
||||
area: "a".repeat(500),
|
||||
});
|
||||
const brief = parseBrief(huge, turns);
|
||||
expect(brief.title.length).toBeLessThanOrEqual(200);
|
||||
expect(brief.summary.length).toBeLessThanOrEqual(5000);
|
||||
expect(brief.subtasks.length).toBeLessThanOrEqual(30);
|
||||
expect(brief.openQuestions.length).toBeLessThanOrEqual(20);
|
||||
expect(brief.area.length).toBeLessThanOrEqual(60);
|
||||
});
|
||||
|
||||
it("handles a JSON array at the top level", () => {
|
||||
expect(parseBrief("[1,2,3]", turns).degraded).toBe(true);
|
||||
});
|
||||
|
||||
it("handles JSON null", () => {
|
||||
expect(parseBrief("null", turns).degraded).toBe(true);
|
||||
});
|
||||
});
|
||||
117
src/lib/intake/brief.ts
Normal file
117
src/lib/intake/brief.ts
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
import type { Turn } from "@/lib/intake/turn";
|
||||
|
||||
/**
|
||||
* Turn a model response into a brief.
|
||||
*
|
||||
* This is a WHITELIST, not a parser. It reads five known fields and discards
|
||||
* everything else, so a brief asserting `"priority": "critical"` or
|
||||
* `"accepted": true` loses those at the boundary — they never reach a caller
|
||||
* that could act on them.
|
||||
*
|
||||
* That matters because this is the product's only untrusted input: the text
|
||||
* being summarised was written by someone other than the developer. The
|
||||
* security property to keep is that the worst a hostile brief can achieve is a
|
||||
* strangely-worded card in the Inbox.
|
||||
*
|
||||
* It also never throws. Seven questions answered and then an error message is
|
||||
* the worst outcome available — a brief that will not parse degrades to the
|
||||
* client's own answers, which are still worth a card.
|
||||
*/
|
||||
|
||||
const MAX_TITLE = 200;
|
||||
const MAX_SUMMARY = 5000;
|
||||
const MAX_SUBTASKS = 30;
|
||||
const MAX_SUBTASK = 300;
|
||||
const MAX_QUESTIONS_OUT = 20;
|
||||
const MAX_AREA = 60;
|
||||
|
||||
export interface Brief {
|
||||
title: string;
|
||||
summary: string;
|
||||
subtasks: string[];
|
||||
openQuestions: string[];
|
||||
/** A project area this belongs to, or "". Feeds the size band. */
|
||||
area: string;
|
||||
/** True when the model's output was unusable and this came from the answers. */
|
||||
degraded: boolean;
|
||||
}
|
||||
|
||||
function asString(value: unknown, max: number): string {
|
||||
if (typeof value !== "string") return "";
|
||||
return value.trim().slice(0, max);
|
||||
}
|
||||
|
||||
function asStringArray(value: unknown, maxItems: number, maxEach: number): string[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
const out: string[] = [];
|
||||
for (const entry of value) {
|
||||
if (typeof entry !== "string") continue;
|
||||
const text = entry.trim().slice(0, maxEach);
|
||||
if (!text) continue;
|
||||
out.push(text);
|
||||
if (out.length >= maxItems) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The first balanced JSON object in the text, ignoring fences and prose. */
|
||||
function extractJson(raw: string): unknown {
|
||||
const text = (raw ?? "").trim();
|
||||
if (!text) return null;
|
||||
|
||||
const start = text.indexOf("{");
|
||||
const end = text.lastIndexOf("}");
|
||||
if (start === -1 || end === -1 || end < start) return null;
|
||||
|
||||
try {
|
||||
return JSON.parse(text.slice(start, end + 1));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** A usable brief built from nothing but the client's own words. */
|
||||
function fromAnswers(turns: Turn[]): Brief {
|
||||
const answers = turns.map((turn) => turn.answer).filter(Boolean);
|
||||
const first = answers[0] ?? "";
|
||||
|
||||
return {
|
||||
title: asString(first, MAX_TITLE) || "Feature request",
|
||||
summary: answers.join("\n\n").slice(0, MAX_SUMMARY) || "No details were given.",
|
||||
subtasks: [],
|
||||
openQuestions: [],
|
||||
area: "",
|
||||
degraded: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseBrief(raw: string, turns: Turn[]): Brief {
|
||||
const parsed = extractJson(raw);
|
||||
|
||||
// Arrays and null are objects to typeof but are not a brief.
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
return fromAnswers(turns);
|
||||
}
|
||||
|
||||
const record = parsed as Record<string, unknown>;
|
||||
|
||||
const title =
|
||||
asString(record.title, MAX_TITLE) ||
|
||||
asString(turns[0]?.answer ?? "", MAX_TITLE) ||
|
||||
"Feature request";
|
||||
|
||||
const summary =
|
||||
asString(record.summary, MAX_SUMMARY) ||
|
||||
turns.map((t) => t.answer).join("\n\n").slice(0, MAX_SUMMARY);
|
||||
|
||||
// Only these five keys are read. Anything else in the object — priority,
|
||||
// accepted, column — is discarded here and cannot travel further.
|
||||
return {
|
||||
title,
|
||||
summary,
|
||||
subtasks: asStringArray(record.subtasks, MAX_SUBTASKS, MAX_SUBTASK),
|
||||
openQuestions: asStringArray(record.openQuestions, MAX_QUESTIONS_OUT, MAX_SUBTASK),
|
||||
area: asString(record.area, MAX_AREA),
|
||||
degraded: false,
|
||||
};
|
||||
}
|
||||
Loading…
Reference in a new issue