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=["第一问", "第一问"]))