diff --git a/src/server/highlights.ts b/src/server/highlights.ts index d82a257..707118f 100644 --- a/src/server/highlights.ts +++ b/src/server/highlights.ts @@ -84,6 +84,44 @@ export interface TasteExample { /** How many of each a prompt carries: enough to show a taste, bounded however long the review. */ export const TASTE_EXAMPLES = 15; +/** A reviewed pick as the database holds it. */ +export interface ReviewedPick { + pick: "keep" | "drop"; + kind: HighlightKind; + text: string; + speaker: string; + score: number; + origin: string; +} + +/** The words of a pick, without case or punctuation: what two takes of one line share. */ +function sameWords(text: string): string { + return text + .normalize("NFKC") + .toLowerCase() + .replace(/[\p{P}\p{S}]+/gu, " ") + .replace(/\s+/g, " ") + .trim(); +} + +/** + * The taste a reading learns from, `reviewed` latest first. A dropped pick + * that says what a kept one says is left out: it is another camera's take of + * a line the person kept, not a line they didn't want. + */ +export function tasteFrom(reviewed: ReviewedPick[]): Taste { + const example = (r: ReviewedPick): TasteExample => ({ kind: r.kind, text: r.text, speaker: r.speaker, score: r.score, own: r.origin === "person" }); + const kept = reviewed.filter((r) => r.pick === "keep"); + const keptWords = new Set(kept.map((r) => sameWords(r.text)).filter(Boolean)); + return { + kept: kept.slice(0, TASTE_EXAMPLES).map(example), + dropped: reviewed + .filter((r) => r.pick === "drop" && !keptWords.has(sameWords(r.text))) + .slice(0, TASTE_EXAMPLES) + .map(example), + }; +} + export interface ClipVerdict { id: string; /** Null when the clip is worth using; why not, otherwise. */ @@ -493,17 +531,7 @@ export async function stepHighlights(cfg: MediaConfig, key: string | undefined, const taken = new Map(); let taste: Taste | undefined; if (rows.length) { - const reviewed = await query<{ - footage_id: string; - src_in: number; - src_out: number; - text: string; - pick: "keep" | "drop"; - kind: HighlightKind; - speaker: string; - score: number; - origin: string; - }>( + const reviewed = await query( `SELECT footage_id, src_in, src_out, text, pick, kind, speaker, score, origin FROM footage_highlights WHERE project_id = ? AND pick IS NOT NULL ORDER BY updated_at DESC, id`, [projectId], @@ -511,11 +539,7 @@ export async function stepHighlights(cfg: MediaConfig, key: string | undefined, for (const r of reviewed) { taken.set(r.footage_id, [...(taken.get(r.footage_id) ?? []), { start: r.src_in, end: r.src_out, text: r.text, pick: r.pick }]); } - const example = (r: (typeof reviewed)[number]): TasteExample => ({ kind: r.kind, text: r.text, speaker: r.speaker, score: r.score, own: r.origin === "person" }); - taste = { - kept: reviewed.filter((r) => r.pick === "keep").slice(0, TASTE_EXAMPLES).map(example), - dropped: reviewed.filter((r) => r.pick === "drop").slice(0, TASTE_EXAMPLES).map(example), - }; + taste = tasteFrom(reviewed); } // The rate and size a timeline for the editor's own software needs, for diff --git a/test/e2e/run.mjs b/test/e2e/run.mjs index 8216e54..9ece0e3 100644 --- a/test/e2e/run.mjs +++ b/test/e2e/run.mjs @@ -489,7 +489,11 @@ assert.ok(csv.slice(1).every((l) => l.includes(",Kept,"))); console.log("11c ok: sheet export,", total, "rows by default, 2 kept"); // Find again: unreviewed picks are replaced, a person's calls stay, and the -// model is told what was already reviewed. +// model is told what was already reviewed. A b-roll shot is dropped too, so +// the taste holds a dropped pick whose words no kept pick has. +const droppedShot = (await call("GET", `/api/projects/${pid}/highlights?kind=broll&pick=open`)).data.highlights[0]; +r = await call("PATCH", `/api/projects/${pid}/highlights/${droppedShot.id}`, { pick: "drop" }); +assert.equal(r.status, 200, JSON.stringify(r.data)); const promptsBefore = world.prompts.length; r = await call("POST", `/api/projects/${pid}/highlights`, { again: true }); assert.equal(r.data.pending, logged); @@ -501,12 +505,18 @@ assert.match(again, /\] kept: /, "each reviewed stretch says whether it was kept assert.match(again, /\] dropped: /, "or dropped"); assert.match(again, /Learn their taste from it/, "the reading learns from the calls"); assert.ok(again.includes(`"${first.text.slice(0, 160)}" (scored ${first.score} when proposed)`), "a kept pick is shown as kept, with the score it had"); -assert.ok(again.includes(`"${second.text.slice(0, 160)}" (scored`), "a dropped one too"); assert.ok(again.includes('"Used anyway" (added by them)'), "and a person's own pick as theirs"); -assert.ok(again.indexOf("Dropped:") > again.indexOf("Kept:")); +assert.ok(again.indexOf("Dropped:") > again.indexOf("Kept:"), "dropped picks follow kept ones"); +const droppedList = again.slice(again.indexOf("Dropped:")).split("\n\n")[0]; +assert.ok(droppedList.includes(`"${droppedShot.text}" (scored`), "a dropped pick is shown as dropped"); +// The two best picks are two takes of one line (the stub says the same words +// on every clip with speech): one kept, one dropped. The dropped take is not +// taught as a line the person didn't want. +assert.equal(second.text, first.text); +assert.ok(!droppedList.includes(`"${second.text}"`), "a dropped take of a kept line is left out of the taste"); assert.equal(db.prepare("SELECT pick FROM footage_highlights WHERE id = ?").get(first.id).pick, "keep", "a kept pick survives finding again"); assert.equal(db.prepare("SELECT pick FROM footage_highlights WHERE id = ?").get(second.id).pick, "drop", "so does a dropped one"); -console.log("11d ok: finding again keeps a person's calls, tells the model about them, and passes kept and dropped as the taste to learn"); +console.log("11d ok: finding again keeps a person's calls, tells the model about them, and passes kept and dropped as the taste to learn, without another take of a kept line"); // Stop: a find-again in line is stopped before any delivery reads it. Clips // read before keep their picks and count as read; a clip never read goes back diff --git a/test/highlights.test.ts b/test/highlights.test.ts index dc08fd7..91a9882 100644 --- a/test/highlights.test.ts +++ b/test/highlights.test.ts @@ -1,14 +1,17 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { HIGHLIGHTS_SCHEMA, + TASTE_EXAMPLES, batchClips, findHighlights, highlightsPrompt, readHighlights, snapToCues, stamp, + tasteFrom, vetSpeaker, type HighlightClip, + type ReviewedPick, } from "../src/server/highlights"; import type { ClipLog } from "../src/server/footage"; import { parseVtt } from "../src/shared/transcript"; @@ -130,6 +133,37 @@ describe("readHighlights", () => { }); }); +describe("tasteFrom", () => { + const pick = (p: "keep" | "drop", text: string, extra: Partial = {}): ReviewedPick => ({ + pick: p, + kind: "soundbite", + text, + speaker: "", + score: 3, + origin: "ai", + ...extra, + }); + + it("leaves out a dropped take of a line that was kept, and keeps the rest in order", () => { + const t = tasteFrom([ + pick("keep", "Shipping fast is the whole point."), + pick("drop", "shipping fast — is the whole point"), + pick("drop", "Um, so, yeah."), + pick("keep", "Logo wall", { kind: "broll", origin: "person" }), + ]); + expect(t.kept.map((e) => [e.text, e.own])).toEqual([["Shipping fast is the whole point.", false], ["Logo wall", true]]); + expect(t.dropped.map((e) => e.text)).toEqual(["Um, so, yeah."]); + }); + + it("never matches two picks on empty words, and stops at the bound", () => { + expect(tasteFrom([pick("keep", "..."), pick("drop", "!!")]).dropped).toHaveLength(1); + const many = Array.from({ length: 40 }, (_, i) => pick(i % 2 ? "drop" : "keep", `line ${i}`)); + const t = tasteFrom(many); + expect([t.kept.length, t.dropped.length]).toEqual([TASTE_EXAMPLES, TASTE_EXAMPLES]); + expect(t.kept[0].text).toBe("line 0"); + }); +}); + describe("batchClips", () => { it("keeps a folder's neighbours together and puts a long interview on its own", () => { const b = (id: string, folder: string, chars = 100): HighlightClip => ({ ...broll, id, folder, transcript: "x".repeat(chars) });