Harsh200415 commited on
Commit
372e8bd
Β·
1 Parent(s): 299fcfb

Fix multi-speaker OOM + talk ratio inflation + add backend logging

Browse files
Files changed (1) hide show
  1. backend/main.py +260 -45
backend/main.py CHANGED
@@ -17,6 +17,17 @@ logging.getLogger("pytorch_lightning").setLevel(logging.ERROR)
17
  logging.getLogger("lightning_fabric").setLevel(logging.ERROR)
18
  logging.getLogger("pyannote").setLevel(logging.ERROR)
19
 
 
 
 
 
 
 
 
 
 
 
 
20
  from pipeline.transcriber import Transcriber
21
  from pipeline.diarizer import Diarizer
22
  from pipeline.signal_extractor import SignalExtractor
@@ -544,10 +555,44 @@ async def start_prepare_session(
544
 
545
 
546
  def _fill_speaker_gaps(diarization: list, total_duration: float, user_label: str = "SPEAKER_00") -> list:
547
- """Fill gaps between remote participant segments with the user's label."""
 
 
 
 
 
 
 
 
 
 
548
  if not diarization:
 
549
  return [{"speaker": user_label, "start": 0.0, "end": total_duration}]
550
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
551
  filled = []
552
  prev_end = 0.0
553
  for seg in sorted(diarization, key=lambda x: x["start"]):
@@ -572,55 +617,100 @@ def _run_prepare_job(session_id, audio_path, user_id, filename, audio_duration_s
572
  try:
573
  if session_id in _cancelled:
574
  _cancelled.discard(session_id)
575
- print(f"[{_sid(session_id)}] βœ• Cancelled before start")
576
  return
577
 
578
  if speaker_timeline:
579
- # WebRTC path: skip pyannote, use provided timeline + transcribe only
580
- print(f"[{_sid(session_id)}] 1/3 Transcribing (WebRTC diarization provided)...")
 
 
 
 
 
 
 
 
 
 
 
581
  emit("transcribing", "Transcribing audio with Whisper…")
582
  transcript = transcriber.transcribe(audio_path)
 
 
 
 
 
583
  emit("diarizing", "Using meeting speaker data…")
584
 
585
  total_dur = audio_duration_s or (
586
- max(s["end"] for s in transcript["segments"]) if transcript["segments"] else 0
587
  )
 
588
  diarization = _fill_speaker_gaps(speaker_timeline, total_dur)
589
- print(f"[{_sid(session_id)}] WebRTC timeline gap-filled: {len(diarization)} segments, "
590
- f"{len({s['speaker'] for s in diarization})} speakers")
 
 
 
591
  else:
592
- # Standard path: parallel transcription + pyannote diarization
593
- print(f"[{_sid(session_id)}] 1/3 Transcribing + diarizing...")
594
  emit("transcribing", "Transcribing audio with Whisper…")
595
 
596
  with ThreadPoolExecutor(max_workers=2) as pool:
597
  f_transcript = pool.submit(transcriber.transcribe, audio_path)
598
  f_diarization = pool.submit(diarizer.diarize, audio_path)
599
  transcript = f_transcript.result()
 
 
 
 
600
  emit("diarizing", "Identifying speakers…")
601
  diarization = f_diarization.result()
 
 
 
 
 
602
 
603
  if session_id in _cancelled:
604
  _cancelled.discard(session_id)
605
  if os.path.exists(audio_path): os.remove(audio_path)
606
- print(f"[{_sid(session_id)}] βœ• Cancelled after transcription")
607
  return
608
 
609
  merged = diarizer.merge_transcript_with_speakers(
610
  transcript["segments"], diarization
611
  )
 
612
 
613
- print(f"[{_sid(session_id)}] 2/3 Detecting voice ({len({s['speaker'] for s in diarization})} speakers found)...")
 
614
  emit("detecting", "Detecting your voice…")
615
 
616
  voiceprint_match = None
617
  voiceprint_confidence = None
618
- speaker_ids = sorted({s["speaker"] for s in diarization if s["speaker"] != "UNKNOWN"})
619
 
620
  if speaker_timeline:
621
- # WebRTC path: user is always the gap-filled "SPEAKER_00" label β€” skip voiceprint
622
  detected_speaker = "SPEAKER_00"
623
- print(f"[{_sid(session_id)}] WebRTC mode β€” user auto-assigned to SPEAKER_00")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
624
  else:
625
  # Standard path: run voiceprint matching to identify the user
626
  res = supabase_admin.table("user_voiceprints").select("embedding_json").eq(
@@ -659,7 +749,7 @@ def _run_prepare_job(session_id, audio_path, user_id, filename, audio_duration_s
659
  if best_score >= 0.55:
660
  voiceprint_match = best_vp
661
  voiceprint_confidence = best_score
662
- print(f"[{_sid(session_id)}] Voice matched: {best_vp} (confidence {best_score:.2f})")
663
  else:
664
  combined = {
665
  sp: 0.6 * vp_scores[sp] + 0.4 * (talk_times.get(sp, 0) / total_time)
@@ -668,7 +758,15 @@ def _run_prepare_job(session_id, audio_path, user_id, filename, audio_duration_s
668
  best_combined = max(combined, key=combined.get)
669
  voiceprint_match = best_combined
670
  voiceprint_confidence = best_score
671
- print(f"[{_sid(session_id)}] Voice low-confidence ({best_score:.2f}) β†’ blended to {best_combined}")
 
 
 
 
 
 
 
 
672
 
673
  detected_speaker = voiceprint_match or (speaker_ids[0] if speaker_ids else "SPEAKER_00")
674
 
@@ -694,12 +792,13 @@ def _run_prepare_job(session_id, audio_path, user_id, filename, audio_duration_s
694
 
695
  if session_id in _jobs:
696
  _jobs[session_id].put({"event": "done", "data": result_data})
697
- print(f"[{_sid(session_id)}] βœ“ Prepare done β€” ready for speaker confirmation")
698
 
699
  except Exception as e:
 
700
  if os.path.exists(audio_path):
701
  os.remove(audio_path)
702
- print(f"[{_sid(session_id)}] βœ• Prepare error: {e}")
703
  if session_id in _jobs:
704
  _jobs[session_id].put({"event": "error", "message": str(e)})
705
 
@@ -778,11 +877,12 @@ async def start_finalize_session(
778
  daemon=True
779
  ).start()
780
 
781
- print(f"[{_sid(session_id)}] β–Ά Speaker confirmed ({confirmed_speaker}) β€” starting analysis")
782
  return {"job_id": session_id}
783
 
784
 
785
  def _run_finalize_job(session_id, confirmed_speaker):
 
786
  job_key = f"finalize_{session_id}"
787
 
788
  def emit(step, message):
@@ -791,6 +891,7 @@ def _run_finalize_job(session_id, confirmed_speaker):
791
 
792
  cached = _prepare_cache.get(session_id)
793
  if not cached:
 
794
  if job_key in _jobs:
795
  _jobs[job_key].put({"event": "error", "message": "Session expired."})
796
  return
@@ -805,59 +906,134 @@ def _run_finalize_job(session_id, confirmed_speaker):
805
  try:
806
  if session_id in _cancelled:
807
  _cancelled.discard(session_id)
808
- print(f"[{_sid(session_id)}] βœ• Cancelled before finalize")
809
  return
810
 
811
- print(f"[{_sid(session_id)}] 1/4 Extracting behavioral signals...")
 
812
  emit("extracting", "Extracting behavioral signals…")
 
813
  all_speaker_ids = list({s["speaker"] for s in diarization if s["speaker"] != "UNKNOWN"})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
814
  all_speakers_signals = {}
815
- for sp in all_speaker_ids:
816
- all_speakers_signals[sp] = SignalExtractor(audio_path, merged, sp).extract_all()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
817
 
818
  signals = all_speakers_signals.get(confirmed_speaker)
819
  if signals is None:
820
- signals = SignalExtractor(audio_path, merged, confirmed_speaker).extract_all()
 
 
 
 
 
 
 
 
 
 
 
 
 
821
 
822
  if session_id in _cancelled:
823
  _cancelled.discard(session_id)
824
- if os.path.exists(audio_path): os.remove(audio_path)
825
- print(f"[{_sid(session_id)}] βœ• Cancelled after signal extraction")
 
826
  return
827
 
828
- print(f"[{_sid(session_id)}] 2/4 Scoring dimensions...")
 
829
  emit("scoring", "Scoring behavioral dimensions…")
830
  dimensions = dimension_scorer.score_all(signals)
 
831
 
832
- print(f"[{_sid(session_id)}] 3/4 Detecting context + generating insights...")
 
833
  emit("generating", "Generating insights with AI…")
834
  sample_text = _sample_transcript(merged)
835
  full_text = _full_transcript(merged)
 
 
 
 
836
  conversation_types = context_detector.detect(sample_text)
837
  primary_context = conversation_types[0]
838
- print(f"[{_sid(session_id)}] Context: {', '.join(conversation_types)}")
839
 
840
  baseline = _get_context_baseline(user_id, primary_context)
841
  session_history = _get_user_session_history(user_id)
842
  resonance_calibration = _get_resonance_calibration(user_id)
843
 
 
844
  insights = insight_gen.generate(
845
  signals, primary_context, baseline, full_text, dimensions,
846
  session_history=session_history,
847
  resonance_calibration=resonance_calibration,
848
  conversation_types=conversation_types,
849
  )
 
850
 
851
  fingerprint = insights.pop("fingerprint", None)
852
 
853
- print(f"[{_sid(session_id)}] 4/4 Generating reflection questions...")
 
854
  emit("reflecting", "Generating reflection questions…")
855
  reflection_questions = insight_gen.generate_reflection_questions(
856
  signals, insights, full_text, primary_context
857
  )
858
  if reflection_questions:
859
  insights["reflection_questions"] = reflection_questions
 
 
 
860
 
 
861
  _update_voiceprint(user_id, audio_path, diarization, confirmed_speaker)
862
  _save_session(
863
  session_id, user_id, signals, insights, dimensions,
@@ -866,6 +1042,7 @@ def _run_finalize_job(session_id, confirmed_speaker):
866
  fingerprint=fingerprint,
867
  all_speakers_signals=all_speakers_signals
868
  )
 
869
 
870
  # Trigger background consolidation if user has enough sessions
871
  try:
@@ -886,7 +1063,15 @@ def _run_finalize_job(session_id, confirmed_speaker):
886
  os.remove(audio_path)
887
  del _prepare_cache[session_id]
888
 
889
- print(f"[{_sid(session_id)}] βœ“ Analysis complete β€” saved to Supabase")
 
 
 
 
 
 
 
 
890
  result_data = {
891
  "session_id": session_id,
892
  "signals": signals,
@@ -906,7 +1091,7 @@ def _run_finalize_job(session_id, confirmed_speaker):
906
  if os.path.exists(audio_path):
907
  os.remove(audio_path)
908
  _prepare_cache.pop(session_id, None)
909
- print(f"[{_sid(session_id)}] βœ• Finalize error: {e}")
910
  if job_key in _jobs:
911
  _jobs[job_key].put({"event": "error", "message": str(e)})
912
 
@@ -1276,33 +1461,63 @@ def get_profile(user_id: str = Depends(get_current_user)):
1276
  if mf_res.data:
1277
  row = mf_res.data[0]
1278
  if row.get("mirror_feed_session_count") == n * _MF_VERSION and row.get("mirror_feed_json"):
1279
- mirror_feed = json.loads(row["mirror_feed_json"])
 
 
1280
  except Exception:
1281
  pass
1282
 
1283
  if mirror_feed is None:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1284
  feed_sessions = [
1285
  {
1286
  "context": p["context"],
1287
  "date": p["date"][:10],
1288
- "fingerprint": p.get("fingerprint") or "",
1289
- "notable_pattern": p["ins"].get("notable_pattern", ""),
1290
  }
1291
  for p in parsed
1292
  ]
1293
  user_summary = _get_user_summary(user_id)
1294
  mirror_feed = mirror_feed_synth.synthesize(feed_sessions, user_summary=user_summary)
1295
- try:
1296
- supabase_admin.table("user_profiles").upsert({
1297
- "user_id": user_id,
1298
- "mirror_feed_json": json.dumps(mirror_feed),
1299
- "mirror_feed_session_count": n * _MF_VERSION,
1300
- "updated_at": _utcnow(),
1301
- }).execute()
1302
- except Exception:
1303
- pass
1304
 
1305
- _mirror_feed_cache[mf_cache_key] = mirror_feed
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1306
 
1307
  return {
1308
  "insufficient_data": False,
 
17
  logging.getLogger("lightning_fabric").setLevel(logging.ERROR)
18
  logging.getLogger("pyannote").setLevel(logging.ERROR)
19
 
20
+ logger = logging.getLogger("mirror")
21
+ logging.basicConfig(
22
+ level=logging.INFO,
23
+ format="%(asctime)s [%(levelname)s] %(message)s",
24
+ datefmt="%H:%M:%S",
25
+ )
26
+
27
+ # Maximum speakers to extract detailed signals for per session.
28
+ # Prevents OOM on HF Spaces when a large meeting produces many speaker labels.
29
+ MAX_SPEAKERS_TO_ANALYZE = 4
30
+
31
  from pipeline.transcriber import Transcriber
32
  from pipeline.diarizer import Diarizer
33
  from pipeline.signal_extractor import SignalExtractor
 
555
 
556
 
557
  def _fill_speaker_gaps(diarization: list, total_duration: float, user_label: str = "SPEAKER_00") -> list:
558
+ """Merge WebRTC speaker events into a continuous timeline.
559
+
560
+ Two modes:
561
+ - Explicit user VAD present (user_label already in timeline): the injector captured
562
+ the local mic, so we have ground-truth user speaking segments. Just sort the timeline
563
+ and return β€” gaps are silence, NOT the user. Gap-filling would inflate the user's
564
+ talk ratio by attributing silence between remote speakers to the user.
565
+ - No explicit user VAD (user_label absent): fall back to the original gap-fill
566
+ behaviour β€” attribute all gaps between remote speakers to the user (heuristic for
567
+ when getUserMedia was not intercepted by the injector).
568
+ """
569
  if not diarization:
570
+ logger.warning("[gap-fill] Empty diarization β€” attributing entire duration to %s", user_label)
571
  return [{"speaker": user_label, "start": 0.0, "end": total_duration}]
572
 
573
+ has_user_vad = any(s["speaker"] == user_label for s in diarization)
574
+
575
+ if has_user_vad:
576
+ # User's mic VAD events are explicit. Trust them β€” don't add synthetic user segments.
577
+ # Gaps between events are silence periods; merge_transcript_with_speakers will default
578
+ # unmatched transcript segments to SPEAKER_00 anyway for the actual analysis.
579
+ sorted_segs = sorted(diarization, key=lambda x: x["start"])
580
+ logger.info(
581
+ "[gap-fill] Explicit %s VAD found β€” returning sorted timeline (%d segs, no gap-fill). "
582
+ "Speakers: %s",
583
+ user_label,
584
+ len(sorted_segs),
585
+ sorted({s["speaker"] for s in sorted_segs}),
586
+ )
587
+ return sorted_segs
588
+
589
+ # ── No explicit user VAD β€” gap-fill with user_label (heuristic) ──────────────
590
+ logger.warning(
591
+ "[gap-fill] No %s events in timeline β€” gaps between remote speakers will be attributed to %s. "
592
+ "This means getUserMedia was NOT intercepted (mic joined before injector ran). "
593
+ "Talk ratio for user may be inflated.",
594
+ user_label, user_label,
595
+ )
596
  filled = []
597
  prev_end = 0.0
598
  for seg in sorted(diarization, key=lambda x: x["start"]):
 
617
  try:
618
  if session_id in _cancelled:
619
  _cancelled.discard(session_id)
620
+ logger.info("[%s] βœ• Cancelled before start", _sid(session_id))
621
  return
622
 
623
  if speaker_timeline:
624
+ # ── WebRTC path: skip pyannote, use provided timeline + transcribe only ────
625
+ speaker_labels = sorted({e["speaker"] for e in speaker_timeline})
626
+ has_user_vad = "SPEAKER_00" in speaker_labels
627
+ logger.info(
628
+ "[%s] 1/3 Transcribing (WebRTC timeline provided: %d events, speakers: %s, has_user_vad: %s)",
629
+ _sid(session_id), len(speaker_timeline), speaker_labels, has_user_vad,
630
+ )
631
+ if not has_user_vad:
632
+ logger.warning(
633
+ "[%s] WARNING: No SPEAKER_00 in WebRTC timeline β€” local mic was NOT captured by injector. "
634
+ "Gap-fill will be used (may inflate user talk ratio).",
635
+ _sid(session_id),
636
+ )
637
  emit("transcribing", "Transcribing audio with Whisper…")
638
  transcript = transcriber.transcribe(audio_path)
639
+ logger.info(
640
+ "[%s] Transcription done: %d segments, ~%d words",
641
+ _sid(session_id), len(transcript.get("segments", [])),
642
+ sum(len(s.get("words", [])) for s in transcript.get("segments", [])),
643
+ )
644
  emit("diarizing", "Using meeting speaker data…")
645
 
646
  total_dur = audio_duration_s or (
647
+ max(s["end"] for s in transcript["segments"]) if transcript.get("segments") else 0
648
  )
649
+ logger.info("[%s] Audio duration: %.1fs", _sid(session_id), total_dur)
650
  diarization = _fill_speaker_gaps(speaker_timeline, total_dur)
651
+ unique_speakers = {s["speaker"] for s in diarization}
652
+ logger.info(
653
+ "[%s] WebRTC timeline processed: %d segments, %d unique speakers: %s",
654
+ _sid(session_id), len(diarization), len(unique_speakers), sorted(unique_speakers),
655
+ )
656
  else:
657
+ # ── Standard path: parallel transcription + pyannote diarization ────────
658
+ logger.info("[%s] 1/3 Transcribing + diarizing (pyannote path)...", _sid(session_id))
659
  emit("transcribing", "Transcribing audio with Whisper…")
660
 
661
  with ThreadPoolExecutor(max_workers=2) as pool:
662
  f_transcript = pool.submit(transcriber.transcribe, audio_path)
663
  f_diarization = pool.submit(diarizer.diarize, audio_path)
664
  transcript = f_transcript.result()
665
+ logger.info(
666
+ "[%s] Transcription done: %d segments",
667
+ _sid(session_id), len(transcript.get("segments", [])),
668
+ )
669
  emit("diarizing", "Identifying speakers…")
670
  diarization = f_diarization.result()
671
+ logger.info(
672
+ "[%s] Pyannote done: %d segments, speakers: %s",
673
+ _sid(session_id), len(diarization),
674
+ sorted({s["speaker"] for s in diarization}),
675
+ )
676
 
677
  if session_id in _cancelled:
678
  _cancelled.discard(session_id)
679
  if os.path.exists(audio_path): os.remove(audio_path)
680
+ logger.info("[%s] βœ• Cancelled after transcription", _sid(session_id))
681
  return
682
 
683
  merged = diarizer.merge_transcript_with_speakers(
684
  transcript["segments"], diarization
685
  )
686
+ logger.info("[%s] Merged transcript: %d segments", _sid(session_id), len(merged))
687
 
688
+ unique_speakers = {s["speaker"] for s in diarization if s["speaker"] != "UNKNOWN"}
689
+ logger.info("[%s] 2/3 Detecting voice (%d speakers found)...", _sid(session_id), len(unique_speakers))
690
  emit("detecting", "Detecting your voice…")
691
 
692
  voiceprint_match = None
693
  voiceprint_confidence = None
694
+ speaker_ids = sorted(unique_speakers)
695
 
696
  if speaker_timeline:
697
+ # WebRTC path: user is always SPEAKER_00 β€” skip voiceprint
698
  detected_speaker = "SPEAKER_00"
699
+ logger.info("[%s] WebRTC mode β€” user auto-assigned to SPEAKER_00", _sid(session_id))
700
+
701
+ # Validate that SPEAKER_00 segments exist in merged transcript
702
+ user_merged_segs = [s for s in merged if s.get("speaker") == "SPEAKER_00"]
703
+ logger.info(
704
+ "[%s] SPEAKER_00 segments in merged transcript: %d",
705
+ _sid(session_id), len(user_merged_segs),
706
+ )
707
+ if len(user_merged_segs) == 0:
708
+ logger.warning(
709
+ "[%s] WARNING: No SPEAKER_00 transcript segments found! "
710
+ "User's speech may not have been correctly attributed. "
711
+ "Analysis will run but signals may be zero/empty.",
712
+ _sid(session_id),
713
+ )
714
  else:
715
  # Standard path: run voiceprint matching to identify the user
716
  res = supabase_admin.table("user_voiceprints").select("embedding_json").eq(
 
749
  if best_score >= 0.55:
750
  voiceprint_match = best_vp
751
  voiceprint_confidence = best_score
752
+ logger.info("[%s] Voice matched: %s (confidence %.2f)", _sid(session_id), best_vp, best_score)
753
  else:
754
  combined = {
755
  sp: 0.6 * vp_scores[sp] + 0.4 * (talk_times.get(sp, 0) / total_time)
 
758
  best_combined = max(combined, key=combined.get)
759
  voiceprint_match = best_combined
760
  voiceprint_confidence = best_score
761
+ logger.warning(
762
+ "[%s] Voice low-confidence (%.2f) β†’ blended to %s",
763
+ _sid(session_id), best_score, best_combined,
764
+ )
765
+ else:
766
+ if not stored_vp:
767
+ logger.info("[%s] No voiceprint enrolled β€” defaulting to first speaker", _sid(session_id))
768
+ elif not voiceprint_matcher.available:
769
+ logger.warning("[%s] Voiceprint matcher unavailable β€” defaulting to first speaker", _sid(session_id))
770
 
771
  detected_speaker = voiceprint_match or (speaker_ids[0] if speaker_ids else "SPEAKER_00")
772
 
 
792
 
793
  if session_id in _jobs:
794
  _jobs[session_id].put({"event": "done", "data": result_data})
795
+ logger.info("[%s] βœ“ Prepare done β€” detected_speaker: %s", _sid(session_id), detected_speaker)
796
 
797
  except Exception as e:
798
+ import traceback
799
  if os.path.exists(audio_path):
800
  os.remove(audio_path)
801
+ logger.error("[%s] βœ• Prepare error: %s\n%s", _sid(session_id), e, traceback.format_exc())
802
  if session_id in _jobs:
803
  _jobs[session_id].put({"event": "error", "message": str(e)})
804
 
 
877
  daemon=True
878
  ).start()
879
 
880
+ logger.info("[%s] β–Ά Speaker confirmed (%s) β€” starting finalize job", _sid(session_id), confirmed_speaker)
881
  return {"job_id": session_id}
882
 
883
 
884
  def _run_finalize_job(session_id, confirmed_speaker):
885
+ import traceback as _tb
886
  job_key = f"finalize_{session_id}"
887
 
888
  def emit(step, message):
 
891
 
892
  cached = _prepare_cache.get(session_id)
893
  if not cached:
894
+ logger.error("[%s] βœ• Finalize: session not in cache (expired?)", _sid(session_id))
895
  if job_key in _jobs:
896
  _jobs[job_key].put({"event": "error", "message": "Session expired."})
897
  return
 
906
  try:
907
  if session_id in _cancelled:
908
  _cancelled.discard(session_id)
909
+ logger.info("[%s] βœ• Cancelled before finalize", _sid(session_id))
910
  return
911
 
912
+ # ── Step 1: Signal extraction ─────────────────────────────────
913
+ logger.info("[%s] 1/4 Extracting behavioral signals...", _sid(session_id))
914
  emit("extracting", "Extracting behavioral signals…")
915
+
916
  all_speaker_ids = list({s["speaker"] for s in diarization if s["speaker"] != "UNKNOWN"})
917
+ logger.info("[%s] Diarization speakers (%d): %s", _sid(session_id), len(all_speaker_ids), all_speaker_ids)
918
+
919
+ if confirmed_speaker not in all_speaker_ids:
920
+ logger.warning(
921
+ "[%s] WARNING: confirmed_speaker '%s' not in diarization β€” will extract anyway",
922
+ _sid(session_id), confirmed_speaker
923
+ )
924
+
925
+ # Cap extraction to MAX_SPEAKERS_TO_ANALYZE to prevent OOM on multi-speaker meetings.
926
+ # Always keep confirmed_speaker (= user) and SPEAKER_00; fill remaining slots with
927
+ # speakers ranked by total talk time so the most active participants are included.
928
+ def _speaker_talk_time(sp):
929
+ return sum(s["end"] - s["start"] for s in diarization if s["speaker"] == sp)
930
+
931
+ must_have = {confirmed_speaker, "SPEAKER_00"} & set(all_speaker_ids)
932
+ remaining = [sp for sp in all_speaker_ids if sp not in must_have]
933
+ remaining.sort(key=_speaker_talk_time, reverse=True)
934
+ speakers_to_extract = list(must_have) + remaining
935
+ if len(speakers_to_extract) > MAX_SPEAKERS_TO_ANALYZE:
936
+ dropped = speakers_to_extract[MAX_SPEAKERS_TO_ANALYZE:]
937
+ speakers_to_extract = speakers_to_extract[:MAX_SPEAKERS_TO_ANALYZE]
938
+ logger.warning(
939
+ "[%s] Capping extraction at %d speakers (dropping %d to prevent OOM): %s",
940
+ _sid(session_id), MAX_SPEAKERS_TO_ANALYZE, len(dropped), dropped
941
+ )
942
+ else:
943
+ logger.info(
944
+ "[%s] Will extract signals for %d speaker(s): %s",
945
+ _sid(session_id), len(speakers_to_extract), speakers_to_extract
946
+ )
947
+
948
  all_speakers_signals = {}
949
+ for sp in speakers_to_extract:
950
+ sp_segs = [s for s in diarization if s["speaker"] == sp]
951
+ sp_talk = _speaker_talk_time(sp)
952
+ logger.info(
953
+ "[%s] Extracting signals for %s (%d segments, %.1fs talk time)…",
954
+ _sid(session_id), sp, len(sp_segs), sp_talk
955
+ )
956
+ try:
957
+ all_speakers_signals[sp] = SignalExtractor(audio_path, merged, sp).extract_all()
958
+ logger.info("[%s] βœ“ %s extraction complete", _sid(session_id), sp)
959
+ except Exception as sp_err:
960
+ logger.error(
961
+ "[%s] βœ• Signal extraction FAILED for %s: %s\n%s",
962
+ _sid(session_id), sp, sp_err, _tb.format_exc()
963
+ )
964
+ # Continue β€” don't let one speaker's failure abort the whole job
965
 
966
  signals = all_speakers_signals.get(confirmed_speaker)
967
  if signals is None:
968
+ logger.warning(
969
+ "[%s] confirmed_speaker %s not in extracted signals β€” extracting now",
970
+ _sid(session_id), confirmed_speaker
971
+ )
972
+ try:
973
+ signals = SignalExtractor(audio_path, merged, confirmed_speaker).extract_all()
974
+ all_speakers_signals[confirmed_speaker] = signals
975
+ logger.info("[%s] βœ“ Fallback extraction for %s complete", _sid(session_id), confirmed_speaker)
976
+ except Exception as fb_err:
977
+ logger.error(
978
+ "[%s] βœ• Fallback extraction FAILED for %s: %s\n%s",
979
+ _sid(session_id), confirmed_speaker, fb_err, _tb.format_exc()
980
+ )
981
+ raise
982
 
983
  if session_id in _cancelled:
984
  _cancelled.discard(session_id)
985
+ if os.path.exists(audio_path):
986
+ os.remove(audio_path)
987
+ logger.info("[%s] βœ• Cancelled after signal extraction", _sid(session_id))
988
  return
989
 
990
+ # ── Step 2: Dimension scoring ─────────────────────────────────
991
+ logger.info("[%s] 2/4 Scoring behavioral dimensions...", _sid(session_id))
992
  emit("scoring", "Scoring behavioral dimensions…")
993
  dimensions = dimension_scorer.score_all(signals)
994
+ logger.info("[%s] Dimensions: %s", _sid(session_id), {k: round(v, 3) for k, v in (dimensions or {}).items()})
995
 
996
+ # ── Step 3: Context detection + insight generation ────────────
997
+ logger.info("[%s] 3/4 Detecting context + generating insights...", _sid(session_id))
998
  emit("generating", "Generating insights with AI…")
999
  sample_text = _sample_transcript(merged)
1000
  full_text = _full_transcript(merged)
1001
+
1002
+ if not sample_text.strip():
1003
+ logger.warning("[%s] WARNING: sample_text is EMPTY β€” transcript may be blank", _sid(session_id))
1004
+
1005
  conversation_types = context_detector.detect(sample_text)
1006
  primary_context = conversation_types[0]
1007
+ logger.info("[%s] Context: %s (primary: %s)", _sid(session_id), conversation_types, primary_context)
1008
 
1009
  baseline = _get_context_baseline(user_id, primary_context)
1010
  session_history = _get_user_session_history(user_id)
1011
  resonance_calibration = _get_resonance_calibration(user_id)
1012
 
1013
+ logger.info("[%s] Calling insight_gen.generate...", _sid(session_id))
1014
  insights = insight_gen.generate(
1015
  signals, primary_context, baseline, full_text, dimensions,
1016
  session_history=session_history,
1017
  resonance_calibration=resonance_calibration,
1018
  conversation_types=conversation_types,
1019
  )
1020
+ logger.info("[%s] βœ“ Insights generated (%d keys)", _sid(session_id), len(insights))
1021
 
1022
  fingerprint = insights.pop("fingerprint", None)
1023
 
1024
+ # ── Step 4: Reflection questions ──────────────────────────────
1025
+ logger.info("[%s] 4/4 Generating reflection questions...", _sid(session_id))
1026
  emit("reflecting", "Generating reflection questions…")
1027
  reflection_questions = insight_gen.generate_reflection_questions(
1028
  signals, insights, full_text, primary_context
1029
  )
1030
  if reflection_questions:
1031
  insights["reflection_questions"] = reflection_questions
1032
+ logger.info("[%s] βœ“ %d reflection questions generated", _sid(session_id), len(reflection_questions))
1033
+ else:
1034
+ logger.warning("[%s] WARNING: No reflection questions returned", _sid(session_id))
1035
 
1036
+ # ── Persist ───────────────────────────────────────────────────
1037
  _update_voiceprint(user_id, audio_path, diarization, confirmed_speaker)
1038
  _save_session(
1039
  session_id, user_id, signals, insights, dimensions,
 
1042
  fingerprint=fingerprint,
1043
  all_speakers_signals=all_speakers_signals
1044
  )
1045
+ logger.info("[%s] βœ“ Session saved to Supabase", _sid(session_id))
1046
 
1047
  # Trigger background consolidation if user has enough sessions
1048
  try:
 
1063
  os.remove(audio_path)
1064
  del _prepare_cache[session_id]
1065
 
1066
+ # Evict profile caches so the next GET /api/profile regenerates with the new session
1067
+ for key in list(_personality_cache.keys()):
1068
+ if key.startswith(f"{user_id}:"):
1069
+ del _personality_cache[key]
1070
+ for key in list(_mirror_feed_cache.keys()):
1071
+ if key.startswith(f"{user_id}:"):
1072
+ del _mirror_feed_cache[key]
1073
+
1074
+ logger.info("[%s] βœ“ Finalize complete β€” confirmed_speaker: %s", _sid(session_id), confirmed_speaker)
1075
  result_data = {
1076
  "session_id": session_id,
1077
  "signals": signals,
 
1091
  if os.path.exists(audio_path):
1092
  os.remove(audio_path)
1093
  _prepare_cache.pop(session_id, None)
1094
+ logger.error("[%s] βœ• Finalize error: %s\n%s", _sid(session_id), e, _tb.format_exc())
1095
  if job_key in _jobs:
1096
  _jobs[job_key].put({"event": "error", "message": str(e)})
1097
 
 
1461
  if mf_res.data:
1462
  row = mf_res.data[0]
1463
  if row.get("mirror_feed_session_count") == n * _MF_VERSION and row.get("mirror_feed_json"):
1464
+ parsed_feed = json.loads(row["mirror_feed_json"])
1465
+ if parsed_feed: # discard any previously-cached empty lists
1466
+ mirror_feed = parsed_feed
1467
  except Exception:
1468
  pass
1469
 
1470
  if mirror_feed is None:
1471
+ def _session_text(p: dict) -> str:
1472
+ """Best available behavioral text for this session, richest first."""
1473
+ ins = p.get("ins", {})
1474
+ for val in [
1475
+ p.get("fingerprint"),
1476
+ ins.get("summary_sentence"),
1477
+ ins.get("conversation_summary"),
1478
+ ]:
1479
+ if val and isinstance(val, str) and val.strip() not in ("", "Session analyzed."):
1480
+ return val.strip()
1481
+ # Last resort: compact signal summary so the session still contributes data
1482
+ try:
1483
+ sig = p["sig"]
1484
+ talk = round(sig["talk_ratio"]["user_ratio"] * 100)
1485
+ wpm = round(sig["speech_rate"]["overall_wpm"])
1486
+ filler = round(sig["filler_words"]["rate_per_100_words"], 1)
1487
+ return f"Talk ratio {talk}%, speech rate {wpm} wpm, filler rate {filler}/100 words."
1488
+ except Exception:
1489
+ return ""
1490
+
1491
  feed_sessions = [
1492
  {
1493
  "context": p["context"],
1494
  "date": p["date"][:10],
1495
+ "fingerprint": _session_text(p),
1496
+ "notable_pattern": p["ins"].get("notable_pattern") or "",
1497
  }
1498
  for p in parsed
1499
  ]
1500
  user_summary = _get_user_summary(user_id)
1501
  mirror_feed = mirror_feed_synth.synthesize(feed_sessions, user_summary=user_summary)
 
 
 
 
 
 
 
 
 
1502
 
1503
+ # Only cache a successful result β€” never persist an empty feed,
1504
+ # so a Groq failure or sparse-data session doesn't suppress the feed permanently.
1505
+ if mirror_feed:
1506
+ try:
1507
+ supabase_admin.table("user_profiles").upsert({
1508
+ "user_id": user_id,
1509
+ "mirror_feed_json": json.dumps(mirror_feed),
1510
+ "mirror_feed_session_count": n * _MF_VERSION,
1511
+ "updated_at": _utcnow(),
1512
+ }).execute()
1513
+ except Exception:
1514
+ pass
1515
+ _mirror_feed_cache[mf_cache_key] = mirror_feed
1516
+ else:
1517
+ mirror_feed = [] # return empty to caller but don't cache it
1518
+
1519
+ if mirror_feed and mf_cache_key not in _mirror_feed_cache:
1520
+ _mirror_feed_cache[mf_cache_key] = mirror_feed
1521
 
1522
  return {
1523
  "insufficient_data": False,