Spaces:
Sleeping
Sleeping
Commit Β·
372e8bd
1
Parent(s): 299fcfb
Fix multi-speaker OOM + talk ratio inflation + add backend logging
Browse files- 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 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 576 |
return
|
| 577 |
|
| 578 |
if speaker_timeline:
|
| 579 |
-
# WebRTC path: skip pyannote, use provided timeline + transcribe only
|
| 580 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 587 |
)
|
|
|
|
| 588 |
diarization = _fill_speaker_gaps(speaker_timeline, total_dur)
|
| 589 |
-
|
| 590 |
-
|
|
|
|
|
|
|
|
|
|
| 591 |
else:
|
| 592 |
-
# Standard path: parallel transcription + pyannote diarization
|
| 593 |
-
|
| 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 |
-
|
| 607 |
return
|
| 608 |
|
| 609 |
merged = diarizer.merge_transcript_with_speakers(
|
| 610 |
transcript["segments"], diarization
|
| 611 |
)
|
|
|
|
| 612 |
|
| 613 |
-
|
|
|
|
| 614 |
emit("detecting", "Detecting your voiceβ¦")
|
| 615 |
|
| 616 |
voiceprint_match = None
|
| 617 |
voiceprint_confidence = None
|
| 618 |
-
speaker_ids = sorted(
|
| 619 |
|
| 620 |
if speaker_timeline:
|
| 621 |
-
# WebRTC path: user is always
|
| 622 |
detected_speaker = "SPEAKER_00"
|
| 623 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 698 |
|
| 699 |
except Exception as e:
|
|
|
|
| 700 |
if os.path.exists(audio_path):
|
| 701 |
os.remove(audio_path)
|
| 702 |
-
|
| 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 |
-
|
| 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 |
-
|
| 809 |
return
|
| 810 |
|
| 811 |
-
|
|
|
|
| 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
|
| 816 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 817 |
|
| 818 |
signals = all_speakers_signals.get(confirmed_speaker)
|
| 819 |
if signals is None:
|
| 820 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 821 |
|
| 822 |
if session_id in _cancelled:
|
| 823 |
_cancelled.discard(session_id)
|
| 824 |
-
if os.path.exists(audio_path):
|
| 825 |
-
|
|
|
|
| 826 |
return
|
| 827 |
|
| 828 |
-
|
|
|
|
| 829 |
emit("scoring", "Scoring behavioral dimensionsβ¦")
|
| 830 |
dimensions = dimension_scorer.score_all(signals)
|
|
|
|
| 831 |
|
| 832 |
-
|
|
|
|
| 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 |
-
|
| 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 |
-
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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 |
-
|
|
|
|
|
|
|
| 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":
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,
|