forked from BasedHardware/omi
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathprocess_conversation.py
More file actions
2958 lines (2644 loc) · 130 KB
/
Copy pathprocess_conversation.py
File metadata and controls
2958 lines (2644 loc) · 130 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
import os
import random
import re
import uuid
import logging
import asyncio
from datetime import timezone, timedelta, datetime
from collections.abc import Mapping, Sequence
from enum import Enum
from typing import Any, Callable, Dict, List, Optional, Set, Tuple, Union, cast
from fastapi import HTTPException
import database._client as db_client_module
from database import redis_db
from database.firestore_read_metrics import FirestoreReadSite
from database.auth import get_user_name
from utils.conversations.transcript_for_llm import (
conversation_transcript_and_speaker_map,
conversation_transcript_for_llm,
conversation_transcripts_for_llm,
)
from utils.conversations.wake_word import has_structural_wake_word_marker
import database.conversations as conversations_db
import database.notifications as notification_db
import database.users as users_db
import database.tasks as tasks_db
import database.goals as goals_db
import database.action_items as action_items_db
import database.folders as folders_db
import database.calendar_meetings as calendar_db
import database.screen_activity as screen_activity_db
from database.vector_db import (
find_similar_action_items,
upsert_action_item_vectors_batch,
delete_action_item_vectors_batch,
)
from database.apps import record_app_usage, get_omi_personas_by_uid_db, get_app_by_id_db
from database.vector_db import upsert_vector2, update_vector_metadata, upsert_transcript_chunk_vectors
from utils.conversations.transcript_chunks import build_transcript_chunks
from models.app import App, UsageHistoryType
from models.memories import MemoryDB, Memory, MemoryCategory, SubjectAttribution
from models.action_item import EvidenceKind, EvidenceRef, EvidenceScope
from models.memory_contracts import L1MemoryArchiveClass, deterministic_contract_id
from models.workstream_association import AssociationEvidence
from models.product_memory import MemoryTier
from utils.memory.belief_model import (
belief_model_enabled,
horizon_from_extraction,
subject_scope_from_extraction,
)
from models.calendar_context import CalendarMeetingContext
from models.client_processing import ClientProcessing
from models.conversation import (
AppResult,
Conversation,
CreateConversation,
ExternalIntegrationCreateConversation,
)
from models.conversation_enums import (
ConversationProcessingState,
ConversationSource,
ConversationStatus,
ExternalIntegrationConversationSource,
)
from utils.conversations.deterministic_minimum import build_deterministic_minimum_structured
from utils.conversations.duration import conversation_duration_seconds
from utils.conversations.factory import deserialize_conversation
from utils.conversations.projection_payload import (
client_processing_mutation,
omit_null_processing_state,
sanitize_untrusted_provenance_field,
strip_client_processing,
)
from utils.conversations import lifecycle as lifecycle_service
from utils.conversations.subjects import infer_subject_from_segments
from utils.memory.memory_service import MemoryService
from utils.memory.decision_path_telemetry import (
classify_model_about,
count_speaker_ids,
emit_memory_capture_decision,
model_about_disagrees_with_attribution,
)
from utils.memory.rejected_memory_feedback import get_recent_rejected_memory_examples
from testing.parity_pack_v0.live_capture import SurfaceParityCapture
from utils.memory.canonical_memory_adapter import extraction_memory_id
from utils.observability.fallback import record_fallback
from utils.metrics import record_jit_first_open
from utils.observability.finalization import FinalizationFailureReason, record_finalization_failure
from utils.product_telemetry import emit_product_event
from utils.task_intelligence.workstream_association import associate_canonical_evidence
from utils.subscription import is_trial_paywalled, should_defer_desktop_processing
from utils.free_tier_memory_policy import (
free_tier_memory_suppression_enabled,
memory_formation_verdict,
)
from utils.free_tier_processing_policy import (
FreeTierProcessingPlan,
free_tier_local_processing_enabled,
minimum_processing_state,
resolve_free_tier_processing_plan,
)
# The injected ``decision_for`` closure and its funding-owner resolution live in
# ``utils/managed_compute`` (next to ``authorize_managed_compute`` and the BYOK
# lookup they compose) and are shared with the app-integration, X-connector and
# twitter-persona memory producers (flip-review F-3). Imported under the historic
# private name so the coordinator's call sites and this module's tests read
# unchanged.
from utils.managed_compute import (
managed_compute_decision_for as _managed_compute_decision_for,
)
from models.other import Person
from models.structured import Structured # type: ignore[reportAttributeAccessIssue] # SDK/fallback export is runtime-complete.
from utils.notifications import send_important_conversation_message
from models.task import Task, TaskStatus, TaskAction, TaskActionProvider
from models.notification_message import NotificationMessage
from utils.apps import get_available_app_model_by_id, get_available_apps, update_persona_prompt
from utils.executors import llm_executor, postprocess_executor, submit_with_context
from utils.llm.conversation_processing import (
get_transcript_structure,
get_app_result,
should_discard_conversation,
get_suggested_apps_for_conversation,
get_reprocess_transcript_structure,
extract_action_items,
get_conversation_notes,
)
from utils.llm.conversation_prompt_prefix import ConversationPromptPrefix, build_conversation_prompt_prefix
from utils.llm.gateway_error_contract import conversation_processing_http_exception
from utils.llm.conversation_folder import assign_conversation_to_folder
from utils.analytics import record_usage
from utils.llm.usage_tracker import track_usage, Features
from models.memory_contracts import MemoryExtractionError
from utils.llm.memories import (
extract_canonical_l1_memory_candidates,
extract_memories_from_text,
)
from utils.llm.temporal import date_in_tz
from utils.conversations.memory_extraction_telemetry import (
PATH_CANONICAL,
ConversationMemoryExtractionResult,
emit_conversation_memories_extracted,
source_for_conversation,
)
from utils.llm.external_integrations import summarize_experience_text
from utils.llm.goals import extract_and_update_goal_progress
from utils.llm.chat import (
retrieve_metadata_from_text,
retrieve_metadata_from_message,
retrieve_metadata_fields_from_transcript,
retrieve_metadata_fields_from_structured,
obtain_emotional_message,
)
from utils.llm.external_integrations import get_message_structure
from utils.llm.clients import generate_embedding
from utils.notifications import send_notification
from utils.other.hume import (
get_hume,
HumeJobCallbackModel,
HumeJobModelPredictionResponseModel,
HumePredictionEmotionResponseModel,
)
from utils.retrieval.rag import retrieve_rag_conversation_context
from utils.webhooks import conversation_created_webhook
from utils.notifications import send_action_item_data_message
from utils.task_sync import auto_sync_action_items_batch
from utils.task_intelligence import conversation_capture
from utils.conversations.calendar_linking import (
get_overlapping_calendar_event,
write_conversation_link_to_calendar_event,
)
from utils.conversations.meeting_treatment import (
MIN_MEETING_DURATION_SECONDS,
MIN_TRANSCRIBED_SPEECH_SECONDS,
deduplicated_transcribed_speech_seconds,
)
from utils.conversations.meeting_context import (
MAX_SCREEN_CONTEXT_ROWS,
MEETING_SEARCH_TOLERANCE_MINUTES,
context_from_calendar_link,
context_from_screen_activity,
resolve_meeting_context,
select_overlapping_meeting,
)
from utils.cloud_tasks import is_audio_merge_dispatch_enabled
from utils.jit_first_open_policy import resolve_authorized_first_open_plan
from utils.other.storage import (
compute_audio_files_fingerprint,
enqueue_conversation_artifact_build,
precache_conversation_audio,
)
logger = logging.getLogger(__name__)
def _calendar_auto_link_enabled() -> bool:
return os.getenv('GOOGLE_CALENDAR_AUTO_LINK_ENABLED', '').strip().lower() in {'1', 'true', 'yes', 'on'}
def _flag_enabled(name: str, *, default: bool = False) -> bool:
value = os.getenv(name)
if value is None:
return default
return value.strip().casefold() in {'1', 'true', 'yes', 'on'}
class SummaryPipelineMode(str, Enum):
"""The two configurations of the summary pipeline that are actually safe to run.
Independent booleans for "notes v2" and "apps are opt-in" describe four states, but only
two of them are coherent. The missing pair is what makes this an enum rather than two
flags: legacy notes + opt-in apps would take the app summary away and fall back to the
short first-party overview, which is worse than either whole configuration — a regression
reachable purely by flag misconfiguration.
"""
# Retire 2026-09-29: legacy path survives only a four-week prod bake of notes v2
# (prod-on 2026-09-01). After that date a follow-up PR deletes LEGACY_APP_PRIMARY,
# the legacy writers, and CONVERSATION_NOTES_V2_ENABLED itself — v2 becomes the
# only path. Do not build on this mode.
LEGACY_APP_PRIMARY = 'legacy_app_primary'
NOTES_V2_APPS_OPT_IN = 'notes_v2_primary_apps_opt_in'
class AppUsageAttribution(str, Enum):
"""Why an app execution is eligible (or ineligible) for usage history."""
AUTOMATIC_PROCESSING = 'automatic_processing'
EXPLICIT_SELECTION = 'explicit_selection'
NON_USER_REPROCESS = 'non_user_reprocess'
class DerivedEffectsDisposition(str, Enum):
"""What the durable finalizer should do after the coordinator persists.
RUN is the paid/legacy bundle (or the empty-bundle memory-extraction
fallback). TERMINAL_NO_DERIVED_EFFECTS is a successful persist that must
not extract memories, fan out apps, or run any other derived effect.
Reporting persistence True alone is unsafe: the finalizer treats an empty
bundle as "extract memories now". The terminal value is also written onto
the Firestore document (see ``TERMINAL_NO_DERIVED_EFFECTS_FIELD``) so a
Cloud Tasks retry after a completed minimum still suppresses the bundle.
"""
RUN = 'run'
TERMINAL_NO_DERIVED_EFFECTS = 'terminal_no_derived_effects'
# Unmodeled Firestore field. Same precedent as ``jit_first_open``: Conversation
# does not declare it, so the persist dict is the only write path. A Cloud Tasks
# retry after a completed minimum must still see this marker; otherwise the
# finalizer defaults disposition to RUN and extracts memories.
TERMINAL_NO_DERIVED_EFFECTS_FIELD = 'terminal_no_derived_effects'
class ExplicitAppSelectionFailedError(RuntimeError):
"""A reprocess that named one summarization app ended without its result.
Raised by `trigger_conversation_apps` when an explicit `app_id` selection leaves no
non-empty result for that app — the execution failed (the executor loop
already logged the exception) or the model returned empty content.
First-party notes are a display fallback, not a substitute for the
selection the user made, so the reprocess boundary must surface a real
error instead of returning success with empty `apps_results` (SCA-359).
"""
def summary_pipeline_mode() -> SummaryPipelineMode:
"""Resolve the pipeline mode once. `CONVERSATION_NOTES_V2_ENABLED` is the only switch.
Rollback is turning notes v2 off, which restores the previous behaviour wholesale rather
than leaving a half-migrated combination running.
"""
if _flag_enabled('CONVERSATION_NOTES_V2_ENABLED'):
return SummaryPipelineMode.NOTES_V2_APPS_OPT_IN
return SummaryPipelineMode.LEGACY_APP_PRIMARY
def _conversation_notes_v2_enabled() -> bool:
return summary_pipeline_mode() is SummaryPipelineMode.NOTES_V2_APPS_OPT_IN
def conversation_apps_opt_in_only() -> bool:
# Derived, never independently configured — see SummaryPipelineMode.
return summary_pipeline_mode() is SummaryPipelineMode.NOTES_V2_APPS_OPT_IN
def _calendar_context_read_enabled() -> bool:
return _flag_enabled('CONVERSATION_CALENDAR_CONTEXT_READ_ENABLED')
def _ocr_meeting_context_enabled() -> bool:
return _flag_enabled('CONVERSATION_OCR_CONTEXT_ENABLED')
def _stored_meeting_lookup_enabled() -> bool:
# Defaults ON: this is a bounded, read-only query of the user's own stored
# meetings, wrapped in try/except, and it is the only identity source that
# does not require a Google OAuth grant or a Redis mapping that may never
# have been written. The env var exists as a kill switch.
return _flag_enabled('CONVERSATION_STORED_MEETING_CONTEXT_ENABLED', default=True)
def _dedup_excluded_conversation_ids(conversation: Any) -> set:
"""The conversation's own id plus any merge-source ids. Items from these
conversations must never be dedup candidates: on reprocess/merge they are
this conversation's previous items — the LLM would suppress re-extracting
them, and the save step then deletes them, silently losing the tasks."""
excluded = {getattr(conversation, 'id', None)}
external_data = getattr(conversation, 'external_data', None) or {}
merge_metadata = external_data.get('merge_metadata') or {}
excluded.update(merge_metadata.get('source_conversation_ids') or [])
excluded.discard(None)
return excluded
def _fetch_dedup_candidates_for_query(uid: str, query: str, conversation: Any = None) -> List[Dict[str, Any]]:
if not query.strip():
return []
excluded_conversation_ids = _dedup_excluded_conversation_ids(conversation) if conversation else set()
try:
similar = find_similar_action_items(uid, query, threshold=0.6, limit=10)
if not similar:
return []
items = action_items_db.get_action_items_by_ids(uid, [s['action_item_id'] for s in similar])
cutoff = datetime.now(timezone.utc) - timedelta(days=7)
eligible: List[Dict[str, Any]] = []
for item in items:
if item.get('completed', False):
continue
if item.get('conversation_id') in excluded_conversation_ids:
continue
last_active = item.get('updated_at') or item.get('created_at')
if last_active is None or last_active < cutoff:
continue
eligible.append(item)
logger.info(
f'dedup_candidates uid={uid} similar={len(similar)} '
f'eligible={len(eligible)} top_score={similar[0]["score"]}'
)
return eligible
except Exception as e:
logger.exception(f'_fetch_dedup_candidates failed uid={uid}: {e}')
return []
def _fetch_dedup_candidates(uid: str, structured: Structured, conversation: Any = None) -> List[Dict[str, Any]]:
"""Fetch recently active open tasks related to a generated overview."""
if not structured or not structured.overview:
return []
return _fetch_dedup_candidates_for_query(uid, structured.overview, conversation)
def _primary_user_name(uid: str) -> Optional[str]:
raw_name = get_user_name(uid, use_default=False)
return raw_name.strip() if isinstance(raw_name, str) and raw_name.strip() else None
def _proposes_task_candidates(conversation: Any) -> bool:
"""Whether this conversation's action items become Candidates instead of tasks.
Desktop has a Suggested surface to review them on. Every other client — phone,
pendant, watch — has none, so a proposal there is invisible and expires unseen:
what the extractor admits is a task.
"""
return getattr(conversation, 'source', None) == ConversationSource.desktop
def _get_structured(
uid: str,
language_code: str,
conversation: Union[Conversation, CreateConversation, ExternalIntegrationCreateConversation],
force_process: bool = False,
people: Optional[List[Person]] = None,
conversation_id: Optional[str] = None,
) -> Tuple[Structured, bool]:
try:
task_intelligence_capture = _proposes_task_candidates(conversation)
tz: Optional[str] = notification_db.get_user_time_zone(uid)
tz_str: str = tz or ''
user_language = users_db.get_user_language_preference(uid) or language_code
prompt_conversation_id = (
conversation_id
or getattr(conversation, 'id', None)
or getattr(conversation, 'processing_conversation_id', None)
or str(uuid.uuid4())
)
# Extract calendar context from external_data
direct_calendar_context = getattr(conversation, 'calendar_meeting_context', None)
calendar_context: Optional[CalendarMeetingContext] = (
direct_calendar_context
if isinstance(direct_calendar_context, CalendarMeetingContext)
else (
CalendarMeetingContext(**direct_calendar_context)
if isinstance(direct_calendar_context, dict) and direct_calendar_context
else None
)
)
if hasattr(conversation, 'external_data'):
external_data_value = cast(Optional[Dict[str, Any]], getattr(conversation, 'external_data', None))
if external_data_value:
calendar_data = external_data_value.get('calendar_meeting_context')
if calendar_data:
calendar_context = CalendarMeetingContext(**calendar_data)
if (
conversation.source == ConversationSource.workflow
or conversation.source == ConversationSource.external_integration
):
ext_conv = cast(ExternalIntegrationCreateConversation, conversation)
started_at = cast(datetime, ext_conv.started_at)
if ext_conv.text_source == ExternalIntegrationConversationSource.audio:
if _conversation_notes_v2_enabled():
prefix = build_conversation_prompt_prefix(
conversation_id=prompt_conversation_id,
transcript=ext_conv.text,
started_at=started_at,
timezone_name=tz_str,
language_code=language_code,
calendar_context=calendar_context,
)
with track_usage(uid, Features.CONVERSATION_STRUCTURE):
structured = get_conversation_notes(
prefix,
started_at=started_at,
language_code=language_code,
output_language_code=user_language,
tz=tz_str,
task_intelligence_capture=task_intelligence_capture,
existing_action_items=_fetch_dedup_candidates_for_query(uid, ext_conv.text, conversation),
)
return structured, False
with track_usage(uid, Features.CONVERSATION_STRUCTURE):
structured = get_transcript_structure(
ext_conv.text,
started_at,
language_code,
tz_str,
uid,
calendar_meeting_context=calendar_context,
output_language_code=user_language,
)
with track_usage(uid, Features.CONVERSATION_ACTION_ITEMS):
structured.action_items = extract_action_items(
ext_conv.text,
started_at,
language_code,
tz_str,
existing_action_items=_fetch_dedup_candidates(uid, structured, conversation),
calendar_meeting_context=calendar_context,
output_language_code=user_language,
task_intelligence_capture=task_intelligence_capture,
primary_user_name=_primary_user_name(uid),
)
return structured, False
if ext_conv.text_source == ExternalIntegrationConversationSource.message:
with track_usage(uid, Features.CONVERSATION_STRUCTURE):
structured = get_message_structure(
ext_conv.text,
started_at,
language_code,
tz_str,
ext_conv.text_source_spec,
output_language_code=user_language,
)
return structured, False
if ext_conv.text_source == ExternalIntegrationConversationSource.other:
with track_usage(uid, Features.CONVERSATION_STRUCTURE):
structured = summarize_experience_text(ext_conv.text, ext_conv.text_source_spec, tz=tz)
return structured, False
# not supported conversation source
raise HTTPException(status_code=400, detail=f'Invalid conversation source: {ext_conv.text_source}')
main_conv = cast(Union[Conversation, CreateConversation], conversation)
transcript_text, action_items_transcript, speaker_map = conversation_transcripts_for_llm(uid, main_conv, people)
has_wake_word_marker = has_structural_wake_word_marker(action_items_transcript)
# For re-processing, we don't discard, just re-structure.
if force_process:
conv_started_at = cast(datetime, main_conv.started_at)
if _conversation_notes_v2_enabled():
prefix = build_conversation_prompt_prefix(
conversation_id=prompt_conversation_id,
transcript=action_items_transcript,
started_at=conv_started_at,
timezone_name=tz_str,
language_code=language_code,
calendar_context=calendar_context,
photos=main_conv.photos,
speaker_map=speaker_map,
)
with track_usage(uid, Features.CONVERSATION_STRUCTURE):
structured = get_conversation_notes(
prefix,
started_at=conv_started_at,
language_code=language_code,
output_language_code=user_language,
tz=tz_str,
task_intelligence_capture=task_intelligence_capture,
existing_action_items=_fetch_dedup_candidates_for_query(uid, transcript_text, conversation),
trusted_wake_word_markers=has_wake_word_marker,
)
return structured, False
# reprocess endpoint
with track_usage(uid, Features.CONVERSATION_STRUCTURE):
structured = get_reprocess_transcript_structure(
transcript_text,
conv_started_at,
language_code,
tz_str,
photos=main_conv.photos,
output_language_code=user_language,
)
with track_usage(uid, Features.CONVERSATION_ACTION_ITEMS):
structured.action_items = extract_action_items(
action_items_transcript,
conv_started_at,
language_code,
tz_str,
photos=main_conv.photos,
existing_action_items=_fetch_dedup_candidates(uid, structured, conversation),
output_language_code=user_language,
task_intelligence_capture=task_intelligence_capture,
trusted_wake_word_markers=has_wake_word_marker,
primary_user_name=_primary_user_name(uid),
)
return structured, False
# Transcript span, not the wall window: `started_at` is the streaming-session
# origin, so `finished_at - started_at` read an 8s scrap as 42 minutes (#4056).
duration_seconds: Optional[float] = conversation_duration_seconds(main_conv)
# Determine whether to discard the conversation based on its content (transcript and/or photos).
discard_transcript = action_items_transcript if has_wake_word_marker else transcript_text
with track_usage(uid, Features.CONVERSATION_DISCARD):
discarded = should_discard_conversation(
discard_transcript,
main_conv.photos,
duration_seconds,
trusted_wake_word_markers=has_wake_word_marker,
)
if discarded:
# Calendar overlap outranks discard (SCA-381): a scrap recorded
# inside a booked meeting is evidence, never noise. Only a positive
# overlap hit keeps it; a disconnected calendar, a missing token, or
# a failed lookup leaves the discard verdict standing.
if _calendar_overlap_retains_conversation(uid, main_conv.started_at, main_conv.finished_at):
logger.info(
'Calendar overlap overrides discard for uid=%s conversation=%s window=[%s, %s]',
uid,
getattr(conversation, 'id', '?'),
main_conv.started_at,
main_conv.finished_at,
)
else:
return Structured(emoji=random.choice(['🧠', '🎉'])), True
# If not discarded, proceed to generate the structured summary from transcript and/or photos.
conv_started_at = cast(datetime, main_conv.started_at)
if _conversation_notes_v2_enabled():
prefix = build_conversation_prompt_prefix(
conversation_id=prompt_conversation_id,
transcript=action_items_transcript,
started_at=conv_started_at,
timezone_name=tz_str,
language_code=language_code,
calendar_context=calendar_context,
photos=main_conv.photos,
speaker_map=speaker_map,
)
with track_usage(uid, Features.CONVERSATION_STRUCTURE):
structured = get_conversation_notes(
prefix,
started_at=conv_started_at,
language_code=language_code,
output_language_code=user_language,
tz=tz_str,
task_intelligence_capture=task_intelligence_capture,
existing_action_items=_fetch_dedup_candidates_for_query(uid, transcript_text, conversation),
trusted_wake_word_markers=has_wake_word_marker,
)
return structured, False
with track_usage(uid, Features.CONVERSATION_STRUCTURE):
structured = get_transcript_structure(
transcript_text,
conv_started_at,
language_code,
tz_str,
uid,
photos=main_conv.photos,
calendar_meeting_context=calendar_context,
output_language_code=user_language,
)
with track_usage(uid, Features.CONVERSATION_ACTION_ITEMS):
structured.action_items = extract_action_items(
action_items_transcript,
conv_started_at,
language_code,
tz_str,
photos=main_conv.photos,
existing_action_items=_fetch_dedup_candidates(uid, structured, conversation),
calendar_meeting_context=calendar_context,
output_language_code=user_language,
task_intelligence_capture=task_intelligence_capture,
trusted_wake_word_markers=has_wake_word_marker,
primary_user_name=_primary_user_name(uid),
)
return structured, False
except Exception as e:
raise conversation_processing_http_exception(e) from e
def _get_conversation_obj(
uid: str,
structured: Structured,
conversation: Union[Conversation, CreateConversation, ExternalIntegrationCreateConversation],
conversation_id: Optional[str] = None,
) -> Conversation:
discarded = structured.title == ''
if isinstance(conversation, CreateConversation):
conversation_dict = conversation.dict()
# Store calendar context in external_data if available
calendar_context = conversation_dict.pop('calendar_meeting_context', None)
# Use started_at as created_at for imported conversations to preserve original timestamp
created_at = conversation.started_at if conversation.started_at else datetime.now(timezone.utc)
result: Conversation = Conversation(
id=conversation_id or str(uuid.uuid4()),
uid=uid,
structured=structured,
created_at=created_at,
discarded=discarded,
**conversation_dict,
)
# Add calendar metadata to external_data
if calendar_context:
if not result.external_data:
result.external_data = {}
result.external_data['calendar_meeting_context'] = calendar_context
if result.photos:
conversations_db.store_conversation_photos(uid, result.id, result.photos)
return result
elif isinstance(conversation, ExternalIntegrationCreateConversation):
create_conversation = conversation
# Use started_at as created_at for external integrations to preserve original timestamp
created_at = conversation.started_at if conversation.started_at else datetime.now(timezone.utc)
result = Conversation(
id=conversation_id or str(uuid.uuid4()),
**conversation.dict(),
created_at=created_at,
structured=structured,
discarded=discarded,
)
result.external_data = create_conversation.dict()
result.app_id = create_conversation.app_id
return result
else:
main_conv = conversation
main_conv.structured = structured
main_conv.discarded = discarded
return main_conv
# Function to get conversation summary apps from Redis
def get_default_conversation_summarized_apps() -> List[App]:
"""
Get conversation summary apps from Redis.
Falls back to environment variable if Redis is empty.
"""
default_apps: List[App] = []
# Try to get from Redis first
redis_app_ids = redis_db.get_conversation_summary_app_ids()
if redis_app_ids:
# Use apps from Redis
for app_id in redis_app_ids:
app_data = get_app_by_id_db(app_id.strip())
if app_data:
default_apps.append(App(**app_data))
else:
# Fallback to environment variable for backward compatibility
env_app_ids = os.getenv(
'CONVERSATION_SUMMARIZED_APP_IDS', 'summary_assistant,action_item_extractor,insight_analyzer'
).split(',')
for app_id in env_app_ids:
app_data = get_app_by_id_db(app_id.strip())
if app_data:
default_apps.append(App(**app_data))
return default_apps
def trigger_conversation_apps(
uid: str,
conversation: Conversation,
is_reprocess: bool = False,
app_id: Optional[str] = None,
explicit_app: Optional[App] = None,
usage_attribution: Optional[AppUsageAttribution] = None,
language_code: str = 'en',
people: Optional[List[Person]] = None,
preserve_existing_results: bool = False,
resumable_result_commit: Optional[Callable[[str, Mapping[str, Any]], bool]] = None,
resumable_usage_commit: Optional[Callable[[str, UsageHistoryType], bool]] = None,
resumable_effect_authorizer: Optional[Callable[[], None]] = None,
) -> bool:
if usage_attribution is None:
usage_attribution = (
AppUsageAttribution.NON_USER_REPROCESS if is_reprocess else AppUsageAttribution.AUTOMATIC_PROCESSING
)
# Get default apps for auto-selection
opt_in_only = conversation_apps_opt_in_only()
default_apps = [] if opt_in_only else get_default_conversation_summarized_apps()
default_apps_dict = {app.id: app for app in default_apps}
# Also get user's installed apps (only used for preferred app lookup and reprocessing)
apps: List[App] = get_available_apps(uid)
conversation_apps = [app for app in apps if app.works_with_memories() and app.enabled]
# Combined dict for looking up preferred apps or specific app_id requests
all_apps_dict = {app.id: app for app in conversation_apps}
all_apps_dict.update(default_apps_dict)
# Combined list for suggestions: default apps + user's installed apps (no duplicates)
all_suggestion_apps = list(all_apps_dict.values())
app_to_run: Optional[App] = None
# If a specific app_id is provided (for reprocessing), find and use it.
if app_id:
if explicit_app is None or explicit_app.id != app_id:
raise ValueError('explicit app selection must be validated before conversation processing')
app_to_run = explicit_app
else:
# Check preferred app first — skip the suggestion LLM call if user has one
preferred_app_id = redis_db.get_user_preferred_app(uid)
if preferred_app_id and preferred_app_id in all_apps_dict:
app_to_run = cast(App, all_apps_dict.get(preferred_app_id))
logger.info(f"Using user's preferred app: {app_to_run.name} (id: {preferred_app_id})")
elif preferred_app_id:
# The set-preferred route admits any app `get_available_app_by_id`
# can see (routers/users.py); it never requires the enabled-installed
# slice this dict is built from. A default whose enablement never
# landed (e.g. the template create flow's enable call failed) was
# therefore accepted by the setter and silently ignored here (#10074).
# Resolve through the setter's own authority instead of re-deciding.
candidate = get_available_app_model_by_id(preferred_app_id, uid)
if candidate and candidate.works_with_memories():
app_to_run = candidate
logger.info(
f"Using user's preferred app outside the installed slice: {candidate.name} (id: {preferred_app_id})"
)
else:
logger.warning(
f"Preferred app {preferred_app_id} is set but unusable "
f"(missing={candidate is None}); falling back to suggestions {uid}"
)
if app_to_run is None and not opt_in_only:
# Only run suggestion LLM call when no usable preferred app is set
if not conversation.suggested_summarization_apps:
if resumable_effect_authorizer is not None:
resumable_effect_authorizer()
with track_usage(uid, Features.CONVERSATION_APPS):
suggested_apps, _reasoning = get_suggested_apps_for_conversation(conversation, all_suggestion_apps)
conversation.suggested_summarization_apps = suggested_apps
logger.info(f"Generated suggested apps for conversation {conversation.id}: {suggested_apps}")
if conversation.suggested_summarization_apps:
first_suggested_app_id = conversation.suggested_summarization_apps[0]
app_to_run = all_apps_dict.get(first_suggested_app_id)
if app_to_run:
logger.info(f"Using first suggested app: {app_to_run.name}")
else:
logger.warning(f"First suggested app '{first_suggested_app_id}' not found in apps.")
elif app_to_run is None:
logger.info('Summarization apps are opt-in only; skipping automatic app selection')
completed_app_ids = {result.app_id for result in conversation.apps_results} if preserve_existing_results else set()
filtered_apps: List[App] = [app_to_run] if app_to_run and app_to_run.id not in completed_app_ids else []
if not filtered_apps:
logger.info(f"No summarization app selected for conversation {conversation.id} {uid}")
if not preserve_existing_results:
conversation.apps_results = []
def execute_app(app: App) -> None:
if resumable_effect_authorizer is not None:
resumable_effect_authorizer()
with track_usage(uid, Features.CONVERSATION_APPS):
transcript = conversation_transcript_for_llm(uid, conversation, people)
prompt_prefix = None
if _conversation_notes_v2_enabled() and conversation.started_at:
app_transcript, app_speaker_map = conversation_transcript_and_speaker_map(uid, conversation, people)
prompt_prefix = build_conversation_prompt_prefix(
conversation_id=conversation.id,
transcript=app_transcript,
started_at=conversation.started_at,
timezone_name=notification_db.get_user_time_zone(uid) or '',
language_code=language_code,
calendar_context=_stored_meeting_context(conversation),
photos=conversation.photos,
speaker_map=app_speaker_map,
)
result = get_app_result(
transcript,
conversation.photos,
app,
language_code=language_code,
prompt_prefix=prompt_prefix,
).strip()
conversation.apps_results.append(AppResult(app_id=app.id, content=result))
if preserve_existing_results:
# Persist the generated app result before any later telemetry or
# aggregate effect receipt. A process crash can then resume from
# this durable per-app output instead of paying for the same LLM
# mutation again.
result_patch = {
'apps_results': [item.dict() for item in conversation.apps_results],
'suggested_summarization_apps': conversation.suggested_summarization_apps,
}
persisted = (
resumable_result_commit(app.id, result_patch)
if resumable_result_commit is not None
else conversations_db.update_conversation(uid, conversation.id, result_patch)
)
if not persisted:
raise RuntimeError('conversation disappeared while persisting app result')
if usage_attribution in {
AppUsageAttribution.AUTOMATIC_PROCESSING,
AppUsageAttribution.EXPLICIT_SELECTION,
}:
usage_type = UsageHistoryType.memory_created_prompt
if resumable_usage_commit is not None:
recorded = resumable_usage_commit(app.id, usage_type)
else:
record_app_usage(uid, app.id, usage_type, conversation_id=conversation.id)
recorded = True
if not recorded:
raise RuntimeError('first-open authority lost while recording app usage')
futures = [submit_with_context(llm_executor, execute_app, app) for app in filtered_apps]
succeeded = True
for future in futures:
try:
future.result()
except Exception as e:
succeeded = False
logger.error(f"Error executing app: {e}")
if app_id:
# Explicit selection is fail-closed: the client asked for THIS app's summary, so a
# missing result (execution failed above) or empty content must not masquerade as
# success while first-party notes shadow the selection the user made (SCA-359).
selected_result = next((r for r in conversation.apps_results if r.app_id == app_id), None)
if selected_result is None or not selected_result.content.strip():
raise ExplicitAppSelectionFailedError(f'Selected app {app_id} produced no summary content')
return succeeded
def update_goal_progress(
uid: str,
conversation: Conversation,
*,
idempotency_key_prefix: Optional[str] = None,
) -> bool:
"""Extract and update goal progress from conversation text."""
try:
# Legacy eager processing uses the bounded Redis lock. First-open work
# instead uses durable per-goal events below, so TTL expiry cannot
# duplicate a committed goal mutation.
if idempotency_key_prefix is None and not redis_db.try_acquire_conversation_goal_lock(uid, conversation.id):
logger.info(f"[GOAL] Skipping already-processed conversation {conversation.id}")
return True
# Get conversation text
text = ""
if conversation.structured and conversation.structured.overview:
text = conversation.structured.overview
elif conversation.transcript_segments:
text = " ".join([s.text for s in conversation.transcript_segments[:20]])
if not text or len(text) < 10:
return True
# Use utility function to extract and update goal progress
with track_usage(uid, Features.GOALS):
account_generation = (
goals_db.get_task_workflow_account_generation(uid) if idempotency_key_prefix is not None else None
)
extract_and_update_goal_progress(
uid,
text,
idempotency_key_prefix=idempotency_key_prefix,
account_generation=account_generation,
)
return True
except Exception as e:
logger.error(f"[GOAL] Error updating progress: {e}")
if idempotency_key_prefix is None:
redis_db.release_conversation_goal_lock(uid, conversation.id)
return False
def _parity_transcript_segments(conversation: Conversation) -> list[dict[str, Any]]:
segments = getattr(conversation, "transcript_segments", None) or []
return [
{
"start": segment.start,
"end": segment.end,
"speaker": segment.speaker,
"text": (segment.text or "")[:8192],
}
for segment in segments[:1000]
]
def _parity_accepted_memories(memories: List[MemoryDB]) -> list[dict[str, Any]]:
return [
{
"id": memory.id,
"content": (memory.content or "")[:8192],
"category": memory.category.value,
"visibility": memory.visibility,
}
for memory in memories[:100]
]
def _sweep_owned_writer_mode(uid: str) -> Optional[str]:
"""Writer mode when a non-compatibility authority owns memory formation.
A ledger-cutover (or transitioning) user must not pay for eager
per-conversation extraction: writer admission would refuse the
compatibility write AFTER the model call was already spent, failing the
whole finalization, and the daily sweep owns those users' memory
formation. Only a positively-read non-compatibility mode is reported;
any control-state read failure returns None so the legacy eager path is
preserved.
"""
try:
from models.memory_apply import WriterMode
from utils.memory.memory_system import ensure_canonical_apply_control_state
db_client = getattr(db_client_module, 'db', None)
control = ensure_canonical_apply_control_state(uid, db_client=db_client)
writer_mode = getattr(control, 'writer_mode', WriterMode.compatibility)
if writer_mode != WriterMode.compatibility:
return getattr(writer_mode, 'value', str(writer_mode))
except Exception:
return None
return None
def extract_memories(uid: str, conversation: Conversation) -> None:
"""Extract one conversation's memories through the selected memory system.
Finalization workers use this public boundary while holding their durable
lease. Keep the private helper below for existing in-module async callers.
"""
# The deployment-wide capability fence is mandatory even when this
# account's non-compatibility writer delegates formation to the sweep.
# Otherwise a reused release-probe principal could skip the exact static
# configuration contract that Pusher qualification is meant to exercise.
db_client = getattr(db_client_module, 'db', None)
MemoryService(db_client=db_client).ensure_canonical_mutation_ready(uid)
sweep_owned_mode = _sweep_owned_writer_mode(uid)
if sweep_owned_mode is not None:
logger.info(
'memory extraction skipped: writer_mode=%s owns formation uid=%s conv=%s',
sweep_owned_mode,
uid,
conversation.id,
)
return
# §1.8: plan denial is a second early return in this same boundary, not a
# parallel branch. Everything below spends `get_llm('memories')`, so the
# gate has to sit above it rather than inside the extractor.
if free_tier_memory_suppression_enabled():
verdict = memory_formation_verdict(decision_for=_managed_compute_decision_for(uid))
if verdict.suppressed:
logger.info(
'memory extraction skipped: plan denies managed formation uid=%s conv=%s reason=%s',
uid,