270 lines
11 KiB
JavaScript
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"]);
|
|
});
|