Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions apps/desktop/src/services/enhancer/speaker-attribution.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,24 @@ import { inferAutomaticSpeakerAssignments } from "./speaker-attribution";

import type { SessionContentSnapshot } from "~/session/content-queries";

function createOneOnOneSnapshot(): SessionContentSnapshot {
const snapshot = createSnapshot();
snapshot.participants = [
{ humanId: "self", name: "John Jeong", jobTitle: "Host" },
{ humanId: "human-marco", name: "Marco Bambini", jobTitle: "Founder" },
];
snapshot.transcripts[0]!.words = snapshot.transcripts[0]!.words.slice(2);
snapshot.transcripts[0]!.speaker_hints =
snapshot.transcripts[0]!.speaker_hints.filter((hint) =>
hint.word_id?.startsWith("george-"),
);
snapshot.transcripts[0]!.wordsJson = "remote words";
snapshot.transcripts[0]!.speakerHintsJson = JSON.stringify(
snapshot.transcripts[0]!.speaker_hints,
);
return snapshot;
}

function createSnapshot(channel = 1): SessionContentSnapshot {
const speakerHints = [
{
Expand Down Expand Up @@ -149,6 +167,79 @@ describe("inferAutomaticSpeakerAssignments", () => {
vi.clearAllMocks();
});

it("assigns the only other participant to the only remote speaker", async () => {
const updates = await inferAutomaticSpeakerAssignments({
generatedSummary:
"Marco (Speaker 1) confirmed he had already relaxed all limitations.",
model: {} as LanguageModel,
snapshot: createOneOnOneSnapshot(),
signal: new AbortController().signal,
});

expect(mocks.generateText).not.toHaveBeenCalled();
expect(updates).toEqual([
expect.objectContaining({
id: "transcript-1",
expectedParticipantHumanIdsJson: '["human-marco"]',
}),
]);
expect(automaticHumanIds(updates[0]!)).toEqual(["human-marco"]);
});

it("treats a calendar copy of the current user as the same 1:1", async () => {
const snapshot = createOneOnOneSnapshot();
snapshot.ownerEmail = "john@example.com";
snapshot.participants = [
{
humanId: "self",
name: "John Jeong",
email: "john@example.com",
jobTitle: "Host",
},
{
humanId: "john-cal",
name: "John Jeong",
email: "john@example.com",
jobTitle: "",
},
{
humanId: "human-marco",
name: "Marco Bambini",
email: "marco@example.com",
jobTitle: "Founder",
},
];

const updates = await inferAutomaticSpeakerAssignments({
generatedSummary:
"Marco (Speaker 1) confirmed he had already relaxed all limitations.",
model: {} as LanguageModel,
snapshot,
signal: new AbortController().signal,
});

expect(mocks.generateText).not.toHaveBeenCalled();
expect(automaticHumanIds(updates[0]!)).toEqual(["human-marco"]);
expect(updates[0]?.expectedParticipantHumanIdsJson).toBe('["human-marco"]');
});

it("does not guess when one other participant has two unassigned speakers", async () => {
const snapshot = createSnapshot();
snapshot.participants = [
{ humanId: "self", name: "John Jeong", jobTitle: "Host" },
{ humanId: "human-marco", name: "Marco Bambini", jobTitle: "Founder" },
];
await expect(
inferAutomaticSpeakerAssignments({
generatedSummary: "Marco discussed device limits.",
model: {} as LanguageModel,
snapshot,
signal: new AbortController().signal,
}),
).resolves.toEqual([]);
expect(mocks.generateText).not.toHaveBeenCalled();
});

it("creates guarded automatic hints from direct candidate matches", async () => {
mockDirectCandidateMatches();

Expand Down
41 changes: 34 additions & 7 deletions apps/desktop/src/services/enhancer/speaker-attribution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,19 @@ export async function inferAutomaticSpeakerAssignments({
[...clustersByTranscript.entries()].map(
async ([transcriptId, clusters]) => {
const directMappings: SpeakerAttributionMapping[] = [];
const isClosedOneOnOne =
context.candidates.length === 1 && clusters.length === 1;
if (isClosedOneOnOne) {
return {
transcriptId,
mappings: completeClosedCandidateSet(
clusters,
context.candidates,
directMappings,
),
};
}
Comment thread
cursor[bot] marked this conversation as resolved.

const usePublicEvidenceFallback =
context.candidates.length === 2 &&
clusters.length === 2 &&
Expand Down Expand Up @@ -243,11 +256,8 @@ function buildSpeakerAttributionContext(
const candidates = Array.from(
new Map(
snapshot.participants
.filter(
(participant) =>
participant.humanId &&
participant.humanId !== snapshot.ownerUserId &&
participant.name.trim(),
.filter((participant) =>
isRemoteAttributionCandidate(participant, snapshot),
)
.map((participant) => [
participant.humanId,
Expand All @@ -261,7 +271,7 @@ function buildSpeakerAttributionContext(
).sort((left, right) => left.humanId.localeCompare(right.humanId));

if (
candidates.length < 2 ||
candidates.length === 0 ||
candidates.length > MAX_ATTRIBUTION_ITEMS ||
new Set(candidates.map((candidate) => candidate.name.toLocaleLowerCase()))
.size !== candidates.length
Expand Down Expand Up @@ -374,7 +384,7 @@ function buildSpeakerAttributionContext(
});

if (
transcriptClusters.length < 2 ||
transcriptClusters.length === 0 ||
transcriptClusters.length > MAX_ATTRIBUTION_ITEMS ||
candidates.length < transcriptClusters.length ||
transcriptClusters.some(
Expand Down Expand Up @@ -847,6 +857,23 @@ function attributionTokens(value: string) {
});
}

function isRemoteAttributionCandidate(
participant: SessionContentSnapshot["participants"][number],
snapshot: SessionContentSnapshot,
): boolean {
if (
!participant.humanId ||
participant.humanId === snapshot.ownerUserId ||
!participant.name.trim()
) {
return false;
}

const ownerEmail = snapshot.ownerEmail?.trim().toLowerCase();
const participantEmail = participant.email?.trim().toLowerCase();
return !ownerEmail || !participantEmail || ownerEmail !== participantEmail;
}

function completeClosedCandidateSet(
clusters: SpeakerAttributionContext["clusters"],
candidates: SpeakerAttributionContext["candidates"],
Expand Down
3 changes: 3 additions & 0 deletions apps/desktop/src/session/content-mutations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,7 @@ describe("session content SQLite corrections", () => {
expect(transcriptStatement?.sql).toContain("words_json = ?");
expect(transcriptStatement?.sql).toContain("speaker_hints_json = ?");
expect(transcriptStatement?.sql).toContain("session_participants");
expect(transcriptStatement?.sql).toContain("self_human.email");
expect(transcriptStatement?.sql).toContain("json_each(?)");
expect(transcriptStatement?.params).toEqual([
'[{"type":"automatic_speaker_assignment"}]',
Expand All @@ -236,10 +237,12 @@ describe("session content SQLite corrections", () => {
"[]",
"session-1",
"user-1",
"user-1",
'["human-1","human-2"]',
'["human-1","human-2"]',
"session-1",
"user-1",
"user-1",
]);
expect(consoleWarn).toHaveBeenCalledOnce();
consoleWarn.mockRestore();
Expand Down
24 changes: 24 additions & 0 deletions apps/desktop/src/session/content-mutations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,17 @@ export function persistGeneratedEnhancedNote({
NULLIF(human.name, ''),
participant.display_name
)) <> ''
AND (
NULLIF(lower(human.email), '') IS NULL
OR NOT EXISTS (
SELECT 1
FROM humans AS self_human
WHERE self_human.id = ?
AND self_human.deleted_at IS NULL
AND NULLIF(lower(self_human.email), '') IS NOT NULL
AND lower(self_human.email) = lower(human.email)
)
)
Comment thread
cursor[bot] marked this conversation as resolved.
) = json_array_length(?)
AND NOT EXISTS (
SELECT 1
Expand All @@ -296,6 +307,17 @@ export function persistGeneratedEnhancedNote({
NULLIF(human.name, ''),
participant.display_name
)) <> ''
AND (
NULLIF(lower(human.email), '') IS NULL
OR NOT EXISTS (
SELECT 1
FROM humans AS self_human
WHERE self_human.id = ?
AND self_human.deleted_at IS NULL
AND NULLIF(lower(self_human.email), '') IS NOT NULL
AND lower(self_human.email) = lower(human.email)
)
)
)
)
`,
Expand All @@ -308,10 +330,12 @@ export function persistGeneratedEnhancedNote({
transcript.currentSpeakerHintsJson,
sessionId,
ownerUserId,
ownerUserId,
transcript.expectedParticipantHumanIdsJson,
transcript.expectedParticipantHumanIdsJson,
sessionId,
ownerUserId,
ownerUserId,
],
expectedRowsAffected: 1,
}),
Expand Down
11 changes: 10 additions & 1 deletion apps/desktop/src/session/content-queries.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ describe("session content SQLite snapshots", () => {
{
id: "session-1",
owner_user_id: "user-1",
owner_email: "user@example.com",
title: "Planning",
created_at: "2026-07-10T09:00:00.000Z",
event_json: JSON.stringify({ title: "Weekly planning" }),
Expand Down Expand Up @@ -77,6 +78,7 @@ describe("session content SQLite snapshots", () => {
{
human_id: "human-1",
name: "Alice",
email: "alice@example.com",
job_title: "Engineer",
},
]),
Expand All @@ -88,6 +90,7 @@ describe("session content SQLite snapshots", () => {
expect(snapshot).toMatchObject({
sessionId: "session-1",
ownerUserId: "user-1",
ownerEmail: "user@example.com",
title: "Planning",
createdAt: "2026-07-10T09:00:00.000Z",
event: { title: "Weekly planning" },
Expand All @@ -109,13 +112,19 @@ describe("session content SQLite snapshots", () => {
},
],
participants: [
{ humanId: "human-1", name: "Alice", jobTitle: "Engineer" },
{
humanId: "human-1",
name: "Alice",
email: "alice@example.com",
jobTitle: "Engineer",
},
],
});
expect(snapshot?.rawMarkdown).toContain("Raw note");
expect(mocks.execute).toHaveBeenCalledWith(expect.any(String), [
"session-1",
]);
expect(mocks.execute.mock.calls[0][0]).toContain("self_human.email");
});

it("lists only active SQLite session ids", async () => {
Expand Down
25 changes: 25 additions & 0 deletions apps/desktop/src/session/content-queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { SpeakerHintWithId, WordWithId } from "~/stt/types";
type SessionContentSqlRow = {
id: string;
owner_user_id: string;
owner_email: string | null;
title: string;
created_at: string;
event_json: string;
Expand Down Expand Up @@ -40,12 +41,14 @@ type TranscriptJson = {
type ParticipantJson = {
human_id: string;
name: string;
email?: string;
job_title: string;
};

export type SessionContentSnapshot = {
sessionId: string;
ownerUserId: string;
ownerEmail?: string | null;
title: string;
createdAt: string;
event: unknown;
Expand Down Expand Up @@ -77,6 +80,7 @@ export type SessionContentSnapshot = {
participants: Array<{
humanId: string;
name: string;
email?: string;
jobTitle: string;
}>;
};
Expand All @@ -85,6 +89,12 @@ const SESSION_CONTENT_SQL = `
SELECT
session.id,
session.owner_user_id,
(
SELECT NULLIF(lower(self_human.email), '')
FROM humans AS self_human
WHERE self_human.id = session.owner_user_id
AND self_human.deleted_at IS NULL
) AS owner_email,
session.title,
session.created_at,
session.event_json,
Expand Down Expand Up @@ -124,6 +134,7 @@ const SESSION_CONTENT_SQL = `
SELECT json_group_array(json_object(
'human_id', participant.human_id,
'name', COALESCE(NULLIF(human.name, ''), participant.display_name),
'email', COALESCE(NULLIF(human.email, ''), participant.email),
'job_title', COALESCE(human.job_title, '')
))
FROM session_participants AS participant
Expand All @@ -134,6 +145,18 @@ const SESSION_CONTENT_SQL = `
AND participant.human_id <> ''
AND participant.source <> 'excluded'
AND participant.deleted_at IS NULL
AND (
participant.human_id = session.owner_user_id
OR NULLIF(lower(human.email), '') IS NULL
OR NOT EXISTS (
SELECT 1
FROM humans AS self_human
WHERE self_human.id = session.owner_user_id
AND self_human.deleted_at IS NULL
AND NULLIF(lower(self_human.email), '') IS NOT NULL
AND lower(self_human.email) = lower(human.email)
)
)
), '[]') AS participants_json
FROM sessions AS session
LEFT JOIN session_documents AS note
Expand Down Expand Up @@ -228,6 +251,7 @@ function mapSessionContentRow(
.map((participant) => ({
humanId: participant.human_id,
name: participant.name,
email: participant.email,
jobTitle: participant.job_title,
}))
.sort(
Expand All @@ -239,6 +263,7 @@ function mapSessionContentRow(
return {
sessionId: row.id,
ownerUserId: row.owner_user_id,
ownerEmail: row.owner_email,
title: row.title,
createdAt: row.created_at,
event: parseJson(row.event_json),
Expand Down
Loading
Loading