fix(bridge): gate STT on real speech so noise doesn't trigger replies

The bridge transcribe path joined every Whisper segment unconditionally, so a
brief loud sound or background noise that momentarily opened the mic gate (no
real speech) still produced a transcript, and Whisper's noise hallucinations
("감사합니다", "MBC 뉴스", ...) made the bot reply to nothing.

Add bridge/stt_filter.py mirroring the desktop listener's _filter_noisy_segments
policy: a hard no_speech_prob cutoff (whisper_no_speech_threshold) plus an
avg_logprob confidence floor (whisper_min_confidence), both config-driven. Apply
it in transcribe() so only segments that look like human speech survive; a
noise-only turn yields an empty transcript and the existing empty-transcript
guard drops it with no reply. Add unit tests for the gate.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
javis-bot
2026-06-13 14:45:22 +09:00
parent 568a1ae50b
commit 39c7a22a12
3 changed files with 196 additions and 1 deletions

View File

@@ -0,0 +1,102 @@
"""Unit tests for the bridge STT speech gate.
The gate decides whether a Whisper segment is real human speech or just noise /
a brief loud blip that Whisper hallucinated text from. Only speech should reach
the reply engine, so a noisy mic that momentarily opens without anyone speaking
produces no transcript and no reply. Thresholds are config-driven, so the tests
pass explicit references rather than hardcoding the production defaults.
"""
import pytest
from bridge.stt_filter import (
filter_speech_segments,
is_non_speech,
segment_confidence,
)
class Seg:
"""Minimal stand-in for a faster-whisper segment."""
def __init__(self, text, no_speech_prob=0.0, avg_logprob=0.0):
self.text = text
self.no_speech_prob = no_speech_prob
self.avg_logprob = avg_logprob
@pytest.mark.unit
def test_real_speech_is_kept():
seg = Seg("오늘 일정 알려줘", no_speech_prob=0.02, avg_logprob=-0.2)
assert filter_speech_segments(
[seg], no_speech_threshold=0.5, min_confidence=0.3
) == [seg]
@pytest.mark.unit
def test_noise_with_high_no_speech_prob_is_dropped():
# A mic blip Whisper hallucinated "감사합니다" from: not speech.
seg = Seg("감사합니다", no_speech_prob=0.92, avg_logprob=0.5)
assert filter_speech_segments(
[seg], no_speech_threshold=0.5, min_confidence=0.3
) == []
@pytest.mark.unit
def test_no_speech_cutoff_runs_before_the_confidence_check():
# Confident hallucination: high avg_logprob but also high no_speech_prob.
# The no-speech cutoff must catch it regardless of confidence.
seg = Seg("MBC 뉴스", no_speech_prob=0.8, avg_logprob=0.9)
assert filter_speech_segments(
[seg], no_speech_threshold=0.5, min_confidence=0.3
) == []
@pytest.mark.unit
def test_low_confidence_decode_is_dropped():
# avg_logprob -0.8 -> confidence 0.2, below the 0.3 floor.
seg = Seg("어버버", no_speech_prob=0.1, avg_logprob=-0.8)
assert filter_speech_segments(
[seg], no_speech_threshold=0.5, min_confidence=0.3
) == []
@pytest.mark.unit
def test_order_preserved_dropping_only_non_speech():
a = Seg("진짜 말한 문장", no_speech_prob=0.05, avg_logprob=-0.1)
noise = Seg("", no_speech_prob=0.95, avg_logprob=0.4)
b = Seg("두 번째 문장", no_speech_prob=0.05, avg_logprob=-0.1)
kept = filter_speech_segments(
[a, noise, b], no_speech_threshold=0.5, min_confidence=0.3
)
assert kept == [a, b]
@pytest.mark.unit
def test_segments_missing_metadata_are_kept():
# No no_speech_prob / avg_logprob -> we can't prove it's noise, so keep it.
class Bare:
text = "메타데이터 없는 문장"
seg = Bare()
assert filter_speech_segments(
[seg], no_speech_threshold=0.5, min_confidence=0.3
) == [seg]
@pytest.mark.unit
def test_is_non_speech_uses_an_inclusive_threshold():
assert is_non_speech(0.5, 0.5) is True
assert is_non_speech(0.49, 0.5) is False
@pytest.mark.unit
def test_segment_confidence_prefers_avg_logprob():
assert segment_confidence(Seg("x", avg_logprob=-0.2)) == pytest.approx(0.8)
# Falls back to 1 - no_speech_prob when avg_logprob is absent.
class NoLogprob:
text = "x"
no_speech_prob = 0.25
assert segment_confidence(NoLogprob()) == pytest.approx(0.75)