@@ -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