From 5ab1c06a73688fb764a1a8bd56049c2dd3672e26 Mon Sep 17 00:00:00 2001 From: pallaoro Date: Sat, 10 Oct 2026 14:55:39 +0200 Subject: [PATCH] Highlights: a dropped take of a kept line isn't taught as a drop When the same line is picked from two cameras and the person keeps one take and drops the other, the taste carried the same words as both kept and dropped. The dropped take is now left out of what a reading learns from (words compared without case or punctuation); it still counts as reviewed, so it is never picked again. --- src/server/highlights.ts | 56 ++++++++++++++++++++++++++++------------ test/e2e/run.mjs | 18 ++++++++++--- test/highlights.test.ts | 34 ++++++++++++++++++++++++ 3 files changed, 88 insertions(+), 20 deletions(-) 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) });