Files
recap-relay/server/test/meeting-speaker-edits.test.js
T

270 lines
11 KiB
JavaScript

// Unit tests for post-hoc speaker edits (merge + re-cluster) on saved
// internal-meeting records.
// Run via: node --test server/test/meeting-speaker-edits.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import {
mergeSpeakersInRecord,
reclusterMeetingRecord,
backfillEntrySpeakers,
applyPolishedSummaries,
} from "../meeting-speaker-edits.js";
// Distinct synthetic voice fingerprints (mirror speaker-clustering.test.js).
const FP_A = (j = 0) => [1.0 + j * 0.01, 0.05 * j, 0];
const FP_B = (j = 0) => [0.05 * j, 1.0 + j * 0.01, 0];
// A 3-speaker record with labels spread across all four sync points.
function makeMergeRecord() {
return {
id: "m1",
transcript_segments: [
{ start: 0, end: 9, text: "a", speaker: "Speaker_A" },
{ start: 10, end: 19, text: "b", speaker: "Speaker_B" },
{ start: 20, end: 29, text: "c", speaker: "Speaker_C" },
{ start: 30, end: 39, text: "c2", speaker: "Speaker_C" },
],
chunks: [
{
title: "t",
summary: "s",
startTime: 0,
entries: [
{ offset: 0, text: "a", speaker: "Speaker_A" },
{ offset: 10, text: "b", speaker: "Speaker_B" },
{ offset: 20, text: "c", speaker: "Speaker_C", speaker_override: "Speaker_C" },
{ offset: 30, text: "c2", speaker: "Speaker_A", speaker_override: "Speaker_C" },
],
},
],
speakers: {
Speaker_A: { turns: 4, total_speaking_seconds: 40, mean_confidence: 0.8, chunks_appeared_in: 2, fingerprint_count: 2 },
Speaker_B: { turns: 2, total_speaking_seconds: 20, mean_confidence: 0.9, chunks_appeared_in: 1, fingerprint_count: 1 },
Speaker_C: { turns: 6, total_speaking_seconds: 18, mean_confidence: 0.6, chunks_appeared_in: 3, fingerprint_count: 3 },
},
speaker_names: { Speaker_A: "Matt", Speaker_B: "John" },
extras: {
tldr: { summary: "x", primary_speakers: ["Speaker_A", "Speaker_C"] },
decisions: [{ statement: "d", agreed_by: ["Speaker_C", "Speaker_A"], supporting_offset: 5 }],
action_items: [{ description: "do", owner: "Speaker_C", supporting_offset: 6 }],
key_quotes: [{ speaker: "Speaker_C", offset: 7, quote: "q" }],
},
meta: {},
};
}
test("merge: collapses absorbed speaker across all four label locations", () => {
const rec = makeMergeRecord();
const out = mergeSpeakersInRecord(rec, "Speaker_A", ["Speaker_C"]);
// transcript_segments
assert.deepEqual(
rec.transcript_segments.map((s) => s.speaker),
["Speaker_A", "Speaker_B", "Speaker_A", "Speaker_A"]
);
// entries + per-line overrides
assert.deepEqual(
rec.chunks[0].entries.map((e) => e.speaker),
["Speaker_A", "Speaker_B", "Speaker_A", "Speaker_A"]
);
assert.equal(rec.chunks[0].entries[2].speaker_override, "Speaker_A");
assert.equal(rec.chunks[0].entries[3].speaker_override, "Speaker_A");
// stats merged, Speaker_C gone
assert.ok(!("Speaker_C" in rec.speakers));
assert.equal(rec.speakers.Speaker_A.turns, 10); // 4 + 6
assert.equal(rec.speakers.Speaker_A.total_speaking_seconds, 58); // 40 + 18
assert.equal(rec.speakers.Speaker_A.fingerprint_count, 5); // 2 + 3
// turn-weighted mean confidence: (0.8*4 + 0.6*6) / 10 = 0.68
assert.ok(Math.abs(rec.speakers.Speaker_A.mean_confidence - 0.68) < 1e-9);
// names: survivor keeps its own, absorbed dropped
assert.equal(rec.speaker_names.Speaker_A, "Matt");
assert.ok(!("Speaker_C" in rec.speaker_names));
// extras remapped + deduped
assert.deepEqual(rec.extras.tldr.primary_speakers, ["Speaker_A"]);
assert.deepEqual(rec.extras.decisions[0].agreed_by, ["Speaker_A"]);
assert.equal(rec.extras.action_items[0].owner, "Speaker_A");
assert.equal(rec.extras.key_quotes[0].speaker, "Speaker_A");
assert.ok(rec.meta.speakers_merged_at > 0);
assert.equal(out.changed > 0, true);
});
test("merge: survivor with no name inherits the absorbed name", () => {
const rec = makeMergeRecord();
// Speaker_B has a name; clear it so it can inherit Speaker_C's.
delete rec.speaker_names.Speaker_B;
rec.speaker_names.Speaker_C = "Carol";
mergeSpeakersInRecord(rec, "Speaker_B", ["Speaker_C"]);
assert.equal(rec.speaker_names.Speaker_B, "Carol");
assert.ok(!("Speaker_C" in rec.speaker_names));
});
test("merge: rejects invalid input", () => {
const rec = makeMergeRecord();
assert.throws(() => mergeSpeakersInRecord(rec, "Speaker_Z", ["Speaker_A"]), /survivor/);
assert.throws(() => mergeSpeakersInRecord(rec, "Speaker_A", ["Speaker_A"]), /itself/);
assert.throws(() => mergeSpeakersInRecord(rec, "Speaker_A", ["Speaker_Z"]), /unknown/);
assert.throws(() => mergeSpeakersInRecord(rec, "Speaker_A", []), /at least one/);
});
// A record carrying per-chunk fingerprints so re-clustering can run
// fully offline. Two distinct voices (FP_A first, FP_B second) →
// Speaker_A / Speaker_B by first-appearance order.
function makeReclusterRecord() {
return {
id: "r1",
transcript_segments: [
{ start: 0, end: 9, text: "a", speaker: "STALE" },
{ start: 10, end: 19, text: "b", speaker: "STALE" },
{ start: 20, end: 29, text: "c", speaker: "STALE" },
],
chunks: [
{
title: "t",
summary: "s",
startTime: 0,
entries: [
{ offset: 0, text: "a", speaker: "STALE", speaker_override: "STALE" },
{ offset: 10, text: "b", speaker: "STALE" },
{ offset: 20, text: "c", speaker: "STALE" },
],
},
],
speakers: { STALE: { turns: 3, total_speaking_seconds: 30, mean_confidence: 0.5, chunks_appeared_in: 2, fingerprint_count: 3 } },
speaker_names: { STALE: "Wrong" },
extras: {
tldr: { summary: "x", primary_speakers: ["STALE"] },
decisions: [{ statement: "d", agreed_by: ["STALE"], supporting_offset: 5 }],
action_items: [{ description: "do", owner: "STALE", supporting_offset: 6 }],
key_quotes: [{ speaker: "STALE", offset: 7, quote: "q" }],
},
diarization: [
{
ok: true,
chunkIndex: 0,
segments: [
{ start: 0, end: 10, speaker_local: "Speaker_0", confidence: 0.9 },
{ start: 10, end: 20, speaker_local: "Speaker_1", confidence: 0.9 },
],
fingerprints: { Speaker_0: FP_A(1), Speaker_1: FP_B(1) },
},
{
ok: true,
chunkIndex: 1,
segments: [{ start: 20, end: 30, speaker_local: "Speaker_0", confidence: 0.8 }],
fingerprints: { Speaker_0: FP_A(2) },
},
],
meta: { polish_done: true },
};
}
test("recluster: re-stamps segments + entries and resets stale data", () => {
const rec = makeReclusterRecord();
const out = reclusterMeetingRecord(rec, { threshold: 70 });
// Two distinct voices recovered.
assert.equal(out.speakers ? Object.keys(out.speakers).filter((k) => k !== "Speaker_Unknown").length : 0, 2);
// Segments re-stamped: FP_A group = Speaker_A (first), FP_B = Speaker_B.
assert.deepEqual(
rec.transcript_segments.map((s) => s.speaker),
["Speaker_A", "Speaker_B", "Speaker_A"]
);
// Entries re-derived to match.
assert.deepEqual(
rec.chunks[0].entries.map((e) => e.speaker),
["Speaker_A", "Speaker_B", "Speaker_A"]
);
// Per-line override cleared.
assert.ok(!("speaker_override" in rec.chunks[0].entries[0]));
// Stale attribution data reset.
assert.deepEqual(rec.speaker_names, {});
assert.deepEqual(rec.extras.tldr.primary_speakers, []);
assert.deepEqual(rec.extras.decisions[0].agreed_by, []);
assert.equal(rec.extras.action_items[0].owner, null);
assert.equal(rec.extras.key_quotes[0].speaker, null);
// Decision text preserved.
assert.equal(rec.extras.decisions[0].statement, "d");
assert.ok(rec.meta.reclustered_at > 0);
assert.equal(rec.meta.recluster_threshold, 70);
assert.equal(rec.meta.polish_done, false);
});
test("recluster: throws NO_FINGERPRINTS when none are saved", () => {
const rec = makeReclusterRecord();
rec.diarization = null;
assert.throws(() => reclusterMeetingRecord(rec, { threshold: 70 }), (e) => e.code === "NO_FINGERPRINTS");
const rec2 = makeReclusterRecord();
rec2.diarization = [{ ok: true, chunkIndex: 0, segments: [], fingerprints: {} }];
assert.throws(() => reclusterMeetingRecord(rec2, { threshold: 70 }), (e) => e.code === "NO_FINGERPRINTS");
});
test("applyPolishedSummaries: writes summaries to analysis + chunks, leaves entries", () => {
const rec = {
analysis: { sections: [
{ title: "Intro", summary: "OLD intro", startIndex: 0, endIndex: 1 },
{ title: "Plan", summary: "OLD plan", startIndex: 2, endIndex: 3 },
] },
chunks: [
{ title: "Intro", summary: "OLD intro", entries: [{ offset: 0, speaker: "Speaker_A", speaker_override: "Speaker_B" }] },
{ title: "Plan", summary: "OLD plan", entries: [{ offset: 20, speaker: "Speaker_B" }] },
],
meta: {},
};
const polished = [
{ title: "Intro", summary: "Matt opens the standup", startIndex: 0, endIndex: 1 },
{ title: "Plan", summary: "John lays out the Q3 plan", startIndex: 2, endIndex: 3 },
];
const changed = applyPolishedSummaries(rec, polished);
assert.equal(changed, 2);
// analysis store updated
assert.equal(rec.analysis.sections[0].summary, "Matt opens the standup");
// chunk cards updated by title
assert.equal(rec.chunks[0].summary, "Matt opens the standup");
assert.equal(rec.chunks[1].summary, "John lays out the Q3 plan");
// entries + per-line override untouched
assert.equal(rec.chunks[0].entries[0].speaker, "Speaker_A");
assert.equal(rec.chunks[0].entries[0].speaker_override, "Speaker_B");
});
test("applyPolishedSummaries: duplicate titles map in order", () => {
const rec = {
analysis: { sections: [] },
chunks: [
{ title: "Discussion", summary: "old1", entries: [] },
{ title: "Discussion", summary: "old2", entries: [] },
],
};
const polished = [
{ title: "Discussion", summary: "new1" },
{ title: "Discussion", summary: "new2" },
];
applyPolishedSummaries(rec, polished);
assert.equal(rec.chunks[0].summary, "new1");
assert.equal(rec.chunks[1].summary, "new2");
});
test("backfillEntrySpeakers force re-stamps already-labeled entries", () => {
const rec = {
transcript_segments: [
{ start: 0, end: 9, text: "a", speaker: "Speaker_A" },
{ start: 10, end: 19, text: "b", speaker: "Speaker_B" },
],
chunks: [{ entries: [{ offset: 0, speaker: "OLD" }, { offset: 10, speaker: "OLD" }] }],
};
// Without force, existing speakers are left alone.
backfillEntrySpeakers(rec);
assert.deepEqual(rec.chunks[0].entries.map((e) => e.speaker), ["OLD", "OLD"]);
// With force, they are re-derived from the segments.
backfillEntrySpeakers(rec, { force: true });
assert.deepEqual(rec.chunks[0].entries.map((e) => e.speaker), ["Speaker_A", "Speaker_B"]);
});