From ac72738fc89c55e4a687d88adf2809c8671081ba Mon Sep 17 00:00:00 2001 From: mkbk Date: Thu, 18 Jun 2026 13:51:35 +0800 Subject: [PATCH] =?UTF-8?q?[=E6=8C=81=E7=BB=AD=E5=AF=B9=E8=AF=9D=E5=88=A4?= =?UTF-8?q?=E5=AE=9A]=EF=BC=9A=E5=AE=8C=E6=88=90=E6=81=A2=E5=A4=8D?= =?UTF-8?q?=E5=BE=85=E6=9C=BA=E5=BB=B6=E8=BF=9F=E4=BF=AE=E5=A4=8D=EF=BC=8C?= =?UTF-8?q?=E5=8C=85=E5=90=AB=E6=9C=AC=E5=9C=B0=E8=A7=84=E5=88=99=E6=94=B6?= =?UTF-8?q?=E6=95=9B=E5=92=8C=E5=88=86=E7=B1=BB=E5=99=A8=E8=B7=B3=E8=BF=87?= =?UTF-8?q?=E5=9B=9E=E5=BD=92=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/owner_voice_pet/continuation.py | 36 +++++++++++++++++------------ tests/test_continuation.py | 18 +++++++++++++++ tests/test_live_runtime.py | 32 +++++++++++++++++++++++++ 3 files changed, 71 insertions(+), 15 deletions(-) diff --git a/src/owner_voice_pet/continuation.py b/src/owner_voice_pet/continuation.py index 3fa65f1..ad90156 100644 --- a/src/owner_voice_pet/continuation.py +++ b/src/owner_voice_pet/continuation.py @@ -35,28 +35,27 @@ class ContinuationDecisionProvider(Protocol): class RuleContinuationDecisionProvider: _continue_patterns = ( - "你想", - "你要", - "你需要", - "需要我", - "要不要", - "是否需要", "可以告诉我", - "告诉我", "请告诉我", - "你希望", - "你更想", - "哪一个", - "哪一部分", - "哪种", - "哪个", "请选择", "选一个", "请补充", - "需要补充", "补充一下", "继续吗", ) + _ambiguous_continue_patterns = ( + "我还可以", + "我可以继续", + "还可以继续", + "可以继续", + "继续展开", + "继续讲", + "继续聊", + "两个方向", + "三个方向", + "几个方向", + "几个部分", + ) _standby_patterns = ( "这是", "已经", @@ -70,6 +69,11 @@ class RuleContinuationDecisionProvider: "抱歉", "出错", "失败", + "先这样", + "到这里", + "随时叫我", + "有需要再叫我", + "需要时再叫我", ) def decide( @@ -88,7 +92,9 @@ class RuleContinuationDecisionProvider: return ContinuationDecision("standby", 0.82, "assistant appears to finish the answer", "rule") if len(text) <= 18 and not text.endswith(("?", "?", "吗", "呢")): return ContinuationDecision("standby", 0.72, "short non-question reply", "rule") - return ContinuationDecision("unknown", 0.0, "rule uncertain", "rule") + if any(pattern in text for pattern in self._ambiguous_continue_patterns): + return ContinuationDecision("unknown", 0.0, "ambiguous continuation offer", "rule") + return ContinuationDecision("standby", 0.78, "assistant did not ask for immediate input", "rule") class LlmContinuationDecisionProvider: diff --git a/tests/test_continuation.py b/tests/test_continuation.py index fb0c88e..af4bd54 100644 --- a/tests/test_continuation.py +++ b/tests/test_continuation.py @@ -58,6 +58,24 @@ class ContinuationDecisionTests(unittest.TestCase): self.assertEqual(decision.action, "standby") self.assertEqual(len(llm.calls), 1) + def test_hybrid_generic_completed_reply_does_not_call_llm_classifier(self) -> None: + llm = FakeClassifierLlm('{"action":"continue","confidence":0.99,"reason":"不应调用"}') + provider = HybridContinuationDecisionProvider( + RuleContinuationDecisionProvider(), + LlmContinuationDecisionProvider(llm, threshold=0.65), + threshold=0.65, + ) + + decision = provider.decide( + user_text="没有呢", + assistant_text="明白了,我先保持待机。有需要再叫我就行。", + history=[], + ) + + self.assertEqual(decision.action, "standby") + self.assertEqual(decision.provider, "rule") + self.assertEqual(llm.calls, []) + def test_hybrid_high_confidence_llm_can_continue(self) -> None: llm = FakeClassifierLlm('{"action":"continue","confidence":0.91,"reason":"等待用户选择"}') provider = HybridContinuationDecisionProvider( diff --git a/tests/test_live_runtime.py b/tests/test_live_runtime.py index afbaf08..5ead38f 100644 --- a/tests/test_live_runtime.py +++ b/tests/test_live_runtime.py @@ -594,6 +594,38 @@ class LiveRuntimeTests(unittest.TestCase): self.assertEqual(event_types[-1], STANDBY_RESUMED) self.assertEqual(len(llm.calls), 1) + def test_completed_reply_returns_to_standby_without_cloud_classifier_delay(self) -> None: + frames = [wake_frame(0, 0)] + frames.extend(segment_frames(1, 20, partials=["没有呢", "没有呢"])) + transport = MemoryAudioTransport(frames, flush_clears_input=False) + llm = QueueLlmProvider([["明白了,我先保持待机。有需要再叫我就行。"]]) + runtime = VoiceAssistantPipeline( + config=AppConfig( + llm_api_key="secret", + speech_provider="cloud", + wake_ack_text="", + followup_listen_timeout_ms=3000, + ), + transport=transport, + wakeword=KeywordWakeWordProvider(), + vad_recorder=VadRecorder(EnergyVadProvider(), min_duration_ms=40, end_silence_ms=40), + stt=QueueSttProvider(["没有呢"]), + realtime_stt=MetadataSttProvider(), + llm=llm, + tts=SineTtsProvider(), + context=ConversationContext(), + reporter=RecordingReporter(), + event_bus=PipelineEventBus(), + ) + + summary = runtime.run(max_turns=1) + event_types = [event.type for event in runtime.event_bus.events] + + self.assertEqual(summary.completed_turns, 1) + self.assertNotIn(FOLLOWUP_LISTENING, event_types) + self.assertEqual(event_types[-1], STANDBY_RESUMED) + self.assertEqual(len(llm.calls), 1) + def test_barge_in_interrupts_playback_and_keeps_only_spoken_context(self) -> None: first_question_frames = [wake_frame(0, 0)] first_question_frames.extend(segment_frames(1, 20, partials=["第一问", "第一问"]))