Skip to content

Commit 50336f5

Browse files
Use event titles for contact enrichment
Use calendar event titles when extracting contacts and avoid duplicate or stale event-derived contact data.
1 parent 78c36e4 commit 50336f5

2 files changed

Lines changed: 349 additions & 16 deletions

File tree

apps/desktop/src/session/components/outer-header/metadata/participants/event-contact-extraction.test.ts

Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,263 @@ describe("event contact extraction", () => {
242242
);
243243
});
244244

245+
test("keeps model email and company when inferred title contact appears first", async () => {
246+
const store = createStore();
247+
store.setRow("sessions", "session-1", {
248+
user_id: "user-1",
249+
created_at: "2026-06-16T05:00:00.000Z",
250+
title: "Tom Yang <> john",
251+
raw_md: "",
252+
event_json: JSON.stringify({
253+
tracking_id: "event-tracking-1",
254+
calendar_id: "calendar-1",
255+
title: "Tom Yang <> john",
256+
started_at: "2026-06-16T05:00:00.000Z",
257+
ended_at: "2026-06-16T05:20:00.000Z",
258+
is_all_day: false,
259+
has_recurrence_rules: false,
260+
description: "What:\nTom Yang <> john\n\nWho:\nJohn Jeong - Organizer",
261+
}),
262+
});
263+
store.setRow("humans", "human-1", {
264+
user_id: "user-1",
265+
created_at: "2026-06-16T05:00:00.000Z",
266+
name: "Tom Yang",
267+
email: "",
268+
phone: "",
269+
org_id: "",
270+
job_title: "",
271+
linkedin_username: "",
272+
memo: "",
273+
pinned: false,
274+
});
275+
store.setRow("mapping_session_participant", "mapping-1", {
276+
user_id: "user-1",
277+
session_id: "session-1",
278+
human_id: "human-1",
279+
source: "manual",
280+
});
281+
282+
const context = buildEventContactExtractionContext(store, "session-1", {
283+
tracking_id: "event-tracking-1",
284+
calendar_id: "calendar-1",
285+
title: "Tom Yang <> john",
286+
started_at: "2026-06-16T05:00:00.000Z",
287+
ended_at: "2026-06-16T05:20:00.000Z",
288+
is_all_day: false,
289+
has_recurrence_rules: false,
290+
description: "What:\nTom Yang <> john\n\nWho:\nJohn Jeong - Organizer",
291+
});
292+
293+
vi.mocked(generateText).mockResolvedValue({
294+
text: JSON.stringify({
295+
contacts: [
296+
{
297+
name: "Tom Yang",
298+
email: "tom@kestroll.com",
299+
companyName: "Kestroll",
300+
},
301+
],
302+
}),
303+
} as any);
304+
305+
const extraction = await extractEventContacts({
306+
model: {} as any,
307+
context,
308+
});
309+
const result = applyExtractedContactToHuman(
310+
store,
311+
"session-1",
312+
"human-1",
313+
extraction.contacts,
314+
{ userId: "user-1" },
315+
);
316+
317+
expect(extraction.contacts).toEqual([
318+
{
319+
name: "Tom Yang",
320+
email: "tom@kestroll.com",
321+
companyName: "Kestroll",
322+
},
323+
]);
324+
expect(result).toMatchObject({
325+
updated: 1,
326+
matched: true,
327+
});
328+
expect(store.getCell("humans", "human-1", "email")).toBe(
329+
"tom@kestroll.com",
330+
);
331+
const organizationEntry = Object.entries(
332+
store.getTable("organizations"),
333+
).find(([, organization]) => organization.name === "Kestroll");
334+
expect(store.getCell("humans", "human-1", "org_id")).toBe(
335+
organizationEntry?.[0],
336+
);
337+
});
338+
339+
test("replaces an inferred email when the model returns a different email for the same person", async () => {
340+
const context = {
341+
title: "Tom Yang <> john",
342+
description: "What:\nTom Yang <> john",
343+
candidates: [
344+
{
345+
name: "John Jeong",
346+
email: "john@example.com",
347+
isCurrentUser: true,
348+
},
349+
{
350+
name: "Tom Yang",
351+
email: "tom@old.example",
352+
},
353+
],
354+
};
355+
356+
vi.mocked(generateText).mockResolvedValue({
357+
text: JSON.stringify({
358+
contacts: [
359+
{
360+
name: "Tom Yang",
361+
email: "tom@kestroll.com",
362+
companyName: "Kestroll",
363+
},
364+
],
365+
}),
366+
} as any);
367+
368+
await expect(
369+
extractEventContacts({ model: {} as any, context }),
370+
).resolves.toEqual({
371+
source: "model",
372+
contacts: [
373+
{
374+
name: "Tom Yang",
375+
email: "tom@kestroll.com",
376+
companyName: "Kestroll",
377+
},
378+
],
379+
});
380+
});
381+
382+
test("falls back to event title names when the model misses an email-only participant", async () => {
383+
const store = createStore();
384+
store.setRow("sessions", "session-1", {
385+
user_id: "user-1",
386+
created_at: "2026-06-26T00:00:00.000Z",
387+
title: "30 Min Meeting between Gonzalo Soto and Yujong Lee",
388+
raw_md: "",
389+
event_json: JSON.stringify({
390+
tracking_id: "event-tracking-1",
391+
calendar_id: "calendar-1",
392+
title: "30 Min Meeting between Gonzalo Soto and Yujong Lee",
393+
started_at: "2026-06-26T08:30:00.000Z",
394+
ended_at: "2026-06-26T09:00:00.000Z",
395+
is_all_day: false,
396+
has_recurrence_rules: false,
397+
description:
398+
"What:\n30 Min Meeting between Gonzalo Soto and Yujong Lee\n\nInvitee timezone:\nAsia/Seoul",
399+
}),
400+
});
401+
store.setRow("humans", "human-1", {
402+
user_id: "user-1",
403+
created_at: "2026-06-26T00:00:00.000Z",
404+
name: "gonzalo@gumloop.com",
405+
email: "gonzalo@gumloop.com",
406+
phone: "",
407+
org_id: "",
408+
job_title: "",
409+
linkedin_username: "",
410+
memo: "",
411+
pinned: false,
412+
});
413+
store.setRow("mapping_session_participant", "mapping-1", {
414+
user_id: "user-1",
415+
session_id: "session-1",
416+
human_id: "human-1",
417+
source: "auto",
418+
});
419+
420+
const context = buildEventContactExtractionContext(store, "session-1", {
421+
tracking_id: "event-tracking-1",
422+
calendar_id: "calendar-1",
423+
title: "30 Min Meeting between Gonzalo Soto and Yujong Lee",
424+
started_at: "2026-06-26T08:30:00.000Z",
425+
ended_at: "2026-06-26T09:00:00.000Z",
426+
is_all_day: false,
427+
has_recurrence_rules: false,
428+
description:
429+
"What:\n30 Min Meeting between Gonzalo Soto and Yujong Lee\n\nInvitee timezone:\nAsia/Seoul",
430+
});
431+
432+
vi.mocked(generateText).mockResolvedValue({
433+
text: JSON.stringify({ contacts: [] }),
434+
} as any);
435+
436+
const extraction = await extractEventContacts({
437+
model: {} as any,
438+
context,
439+
});
440+
const result = applyExtractedContactToHuman(
441+
store,
442+
"session-1",
443+
"human-1",
444+
extraction.contacts,
445+
{ userId: "user-1" },
446+
);
447+
448+
expect(extraction.contacts).toContainEqual({
449+
name: "Gonzalo Soto",
450+
email: "gonzalo@gumloop.com",
451+
});
452+
expect(result).toMatchObject({
453+
updated: 1,
454+
matched: true,
455+
});
456+
expect(store.getCell("humans", "human-1", "name")).toBe("Gonzalo Soto");
457+
});
458+
459+
test("does not infer a title prefix from a between line with delimiters", async () => {
460+
const context = {
461+
title: "30 Min Meeting between Gonzalo Soto and Yujong Lee <> john",
462+
description:
463+
"What:\n30 Min Meeting between Gonzalo Soto and Yujong Lee <> john",
464+
candidates: [
465+
{
466+
name: "John Jeong",
467+
email: "john@example.com",
468+
isCurrentUser: true,
469+
},
470+
{
471+
name: "Gonzalo Soto",
472+
email: "gonzalo@gumloop.com",
473+
},
474+
{
475+
name: "Yujong Lee",
476+
email: "yujong@example.com",
477+
},
478+
],
479+
};
480+
481+
vi.mocked(generateText).mockResolvedValue({
482+
text: JSON.stringify({ contacts: [] }),
483+
} as any);
484+
485+
await expect(
486+
extractEventContacts({ model: {} as any, context }),
487+
).resolves.toEqual({
488+
source: "model",
489+
contacts: [
490+
{
491+
name: "Gonzalo Soto",
492+
email: "gonzalo@gumloop.com",
493+
},
494+
{
495+
name: "Yujong Lee",
496+
email: "yujong@example.com",
497+
},
498+
],
499+
});
500+
});
501+
245502
test("reuses an existing organization when applying extracted company", () => {
246503
const store = createStore();
247504
store.setRow("organizations", "org-1", {

0 commit comments

Comments
 (0)