Add internal-meetings pipeline and post-hoc speaker tools

This commit is contained in:
Keysat
2026-06-13 13:35:53 -05:00
parent 9a2dbf69df
commit 705807e286
15 changed files with 7375 additions and 0 deletions
+269
View File
@@ -0,0 +1,269 @@
// 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"]);
});
+60
View File
@@ -0,0 +1,60 @@
// Re-polish bug fix: the summary-polish pass must label each transcript
// line with the operator's CORRECTED speaker name, so a re-polish after a
// legend rename actually re-attributes statements to the new name (rather
// than echoing the stale name baked into the original summaries).
import { test, describe } from "node:test";
import assert from "node:assert/strict";
import { formatSpeakerLabeledTranscript } from "../post-cluster-polish.js";
const SEGMENTS = [
{ start: 5, speaker: "Speaker_A", text: "Let's get started." },
{ start: 12, speaker: "Speaker_B", text: "Sounds good." },
{ start: 20, speaker: "Speaker_C", text: "One more thing." }, // unnamed
{ start: 30, speaker: "", text: "(crosstalk)" }, // no speaker
];
describe("formatSpeakerLabeledTranscript", () => {
test("without speakerNames: labels by chip letter (name-inference pass)", () => {
const out = formatSpeakerLabeledTranscript(SEGMENTS);
assert.match(out, /\[A 0:05\] Let's get started\./);
assert.match(out, /\[B 0:12\] Sounds good\./);
assert.match(out, /\[C 0:20\] One more thing\./);
// Segment with no speaker → "?" label.
assert.match(out, /\[\? 0:30\] \(crosstalk\)/);
});
test("with speakerNames: named speakers labeled by NAME, unnamed fall back to letter", () => {
const out = formatSpeakerLabeledTranscript(SEGMENTS, {
speakerNames: { Speaker_A: "Matt", Speaker_B: "Grant" },
});
assert.match(out, /\[Matt 0:05\] Let's get started\./);
assert.match(out, /\[Grant 0:12\] Sounds good\./);
// Speaker_C has no name → still the letter.
assert.match(out, /\[C 0:20\] One more thing\./);
// Crucially, the OLD letter labels for the named speakers are gone.
assert.doesNotMatch(out, /\[A 0:05\]/);
assert.doesNotMatch(out, /\[B 0:12\]/);
});
test("respects the time window (startSec/endSec)", () => {
const out = formatSpeakerLabeledTranscript(SEGMENTS, {
startSec: 10,
endSec: 25,
speakerNames: { Speaker_A: "Matt" },
});
assert.doesNotMatch(out, /Let's get started/); // 0:05, before window
assert.match(out, /Sounds good/); // 0:12, in window
assert.match(out, /One more thing/); // 0:20, in window
assert.doesNotMatch(out, /crosstalk/); // 0:30, after window
});
test("strips brackets from a name so the [label] frame can't break", () => {
const out = formatSpeakerLabeledTranscript(
[{ start: 0, speaker: "Speaker_A", text: "hi" }],
{ speakerNames: { Speaker_A: "Ma[t]t" } },
);
assert.match(out, /\[Matt 0:00\] hi/);
});
});
+203
View File
@@ -0,0 +1,203 @@
// Unit tests for the Phase 1D speaker-clustering module.
// Run via: node --test server/test/speaker-clustering.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import {
cosineSimilarity,
clusterSpeakers,
assignSpeakersToSegments,
} from "../speaker-clustering.js";
// Synthetic fingerprints — easier to reason about than 192-dim vectors.
// "Person A" embeddings all point roughly toward [+1, 0, 0]
// "Person B" embeddings all point roughly toward [0, +1, 0]
// "Person C" embeddings (when present) point toward [0, 0, +1]
const FP_A = (jitter = 0) => [1.0 + jitter * 0.01, 0.05 * jitter, 0];
const FP_B = (jitter = 0) => [0.05 * jitter, 1.0 + jitter * 0.01, 0];
const FP_C = (jitter = 0) => [0, 0, 1.0 + jitter * 0.01];
test("cosineSimilarity: identical vectors = 1", () => {
assert.equal(cosineSimilarity([1, 0, 0], [1, 0, 0]), 1);
});
test("cosineSimilarity: orthogonal vectors = 0", () => {
assert.equal(cosineSimilarity([1, 0, 0], [0, 1, 0]), 0);
});
test("cosineSimilarity: zero-magnitude input returns 0 (no NaN)", () => {
assert.equal(cosineSimilarity([0, 0, 0], [1, 1, 1]), 0);
});
test("clusterSpeakers: two distinct speakers across 3 chunks → 2 clusters", () => {
const chunkDiar = [
{
ok: true,
chunkIndex: 0,
segments: [],
fingerprints: { Speaker_0: FP_A(1), Speaker_1: FP_B(1) },
},
{
ok: true,
chunkIndex: 1,
segments: [],
fingerprints: { Speaker_0: FP_A(2), Speaker_1: FP_B(2) },
},
{
ok: true,
chunkIndex: 2,
segments: [],
fingerprints: { Speaker_0: FP_B(3), Speaker_1: FP_A(3) }, // labels flipped this chunk
},
];
const { clusterCount, globalMap, speakers } = clusterSpeakers(chunkDiar, 70);
assert.equal(clusterCount, 2, "should identify 2 distinct speakers");
// First speaker seen (chunk 0, Speaker_0 = FP_A) becomes Speaker_A
assert.equal(globalMap.get("0:Speaker_0"), "Speaker_A");
assert.equal(globalMap.get("0:Speaker_1"), "Speaker_B");
// Chunk 1 (same physical voices, same label assignment by SC)
assert.equal(globalMap.get("1:Speaker_0"), "Speaker_A");
assert.equal(globalMap.get("1:Speaker_1"), "Speaker_B");
// Chunk 2 has labels flipped — clustering should recover the truth
assert.equal(globalMap.get("2:Speaker_0"), "Speaker_B");
assert.equal(globalMap.get("2:Speaker_1"), "Speaker_A");
// Summary should report each speaker appearing in 3 chunks
assert.equal(speakers.Speaker_A.fingerprint_count, 3);
assert.equal(speakers.Speaker_B.fingerprint_count, 3);
});
test("clusterSpeakers: three distinct speakers → 3 clusters", () => {
const chunkDiar = [
{
ok: true,
chunkIndex: 0,
segments: [],
fingerprints: { Speaker_0: FP_A(1), Speaker_1: FP_B(1) },
},
{
ok: true,
chunkIndex: 1,
segments: [],
fingerprints: { Speaker_0: FP_C(2), Speaker_1: FP_B(2) },
},
];
const { clusterCount } = clusterSpeakers(chunkDiar, 70);
assert.equal(clusterCount, 3);
});
test("clusterSpeakers: empty input returns empty result", () => {
const out = clusterSpeakers([], 70);
assert.equal(out.clusterCount, 0);
assert.equal(out.globalMap.size, 0);
assert.deepEqual(out.speakers, {});
});
test("clusterSpeakers: all-failed-chunks input returns empty result", () => {
const out = clusterSpeakers([{ ok: false }, { ok: false }], 70);
assert.equal(out.clusterCount, 0);
});
test("clusterSpeakers: threshold clamped to 50..95", () => {
const chunkDiar = [
{
ok: true,
chunkIndex: 0,
segments: [],
fingerprints: { Speaker_0: FP_A(1), Speaker_1: FP_B(1) },
},
];
const lo = clusterSpeakers(chunkDiar, 0); // clamps to 50
assert.equal(lo.thresholdSimilarity, 0.5);
const hi = clusterSpeakers(chunkDiar, 200); // clamps to 95
assert.equal(hi.thresholdSimilarity, 0.95);
});
test("clusterSpeakers: very strict threshold (95%) splits tightly-grouped voices", () => {
// FP_A with significant jitter — at 70% they cluster as one, at 95% they may split.
const chunkDiar = [
{
ok: true,
chunkIndex: 0,
segments: [],
fingerprints: {
Speaker_0: [1.0, 0.0, 0.0],
// Same general direction but ~0.93 similarity — borderline.
Speaker_1: [0.93, 0.36, 0.06],
},
},
];
const lenient = clusterSpeakers(chunkDiar, 70);
const strict = clusterSpeakers(chunkDiar, 95);
assert.equal(lenient.clusterCount, 1, "lenient should merge");
assert.equal(strict.clusterCount, 2, "strict should split");
});
test("clusterSpeakers: summary stats aggregate turns + speaking time", () => {
const chunkDiar = [
{
ok: true,
chunkIndex: 0,
segments: [
{ start: 0, end: 10, speaker_local: "Speaker_0", confidence: 0.9 },
{ start: 10, end: 25, speaker_local: "Speaker_1", confidence: 0.8 },
{ start: 25, end: 30, speaker_local: "Speaker_0", confidence: 0.95 },
],
fingerprints: { Speaker_0: FP_A(1), Speaker_1: FP_B(1) },
},
];
const { speakers } = clusterSpeakers(chunkDiar, 70);
assert.equal(speakers.Speaker_A.turns, 2);
assert.equal(speakers.Speaker_A.total_speaking_seconds, 15);
assert.equal(speakers.Speaker_B.turns, 1);
assert.equal(speakers.Speaker_B.total_speaking_seconds, 15);
assert.ok(Math.abs(speakers.Speaker_A.mean_confidence - 0.925) < 0.001);
});
test("assignSpeakersToSegments: midpoint inside diar segment wins", () => {
const segments = [
{ start: 0, end: 5, text: "hello" },
{ start: 5, end: 10, text: "world" },
];
const chunkDiar = [
{
ok: true,
chunkIndex: 0,
segments: [
{ start: 0, end: 5, speaker_local: "Speaker_0", confidence: 0.9 },
{ start: 5, end: 10, speaker_local: "Speaker_1", confidence: 0.85 },
],
fingerprints: { Speaker_0: FP_A(1), Speaker_1: FP_B(1) },
},
];
const { globalMap } = clusterSpeakers(chunkDiar, 70);
assignSpeakersToSegments(segments, chunkDiar, globalMap);
assert.equal(segments[0].speaker, "Speaker_A");
assert.equal(segments[1].speaker, "Speaker_B");
assert.equal(segments[0].speaker_confidence, 0.9);
});
test("assignSpeakersToSegments: nearest-fallback within 5s window", () => {
const segments = [
{ start: 8, end: 12, text: "in between" }, // gap with no covering diar seg
];
const chunkDiar = [
{
ok: true,
chunkIndex: 0,
segments: [
{ start: 0, end: 5, speaker_local: "Speaker_0", confidence: 0.9 },
],
fingerprints: { Speaker_0: FP_A(1) },
},
];
const { globalMap } = clusterSpeakers(chunkDiar, 70);
assignSpeakersToSegments(segments, chunkDiar, globalMap);
// Diar segment ends at 5, transcript mid is 10 → distance 7.5 > 5s → speaker stays null
assert.equal(segments[0].speaker, null);
});
test("assignSpeakersToSegments: no diar data leaves segments unchanged", () => {
const segments = [{ start: 0, end: 5, text: "hello" }];
assignSpeakersToSegments(segments, [], new Map());
assert.equal(segments[0].speaker, undefined);
});