diff --git a/VoiceCommand/agent/llm_provider.py b/VoiceCommand/agent/llm_provider.py index ac25e09..c32b994 100644 --- a/VoiceCommand/agent/llm_provider.py +++ b/VoiceCommand/agent/llm_provider.py @@ -95,6 +95,7 @@ def role_model_default(selected): self.max_history = 10 self.max_context_tokens = self._load_int_setting("max_context_tokens", 8000) self._tool_streaming_support: dict[str, bool] = {} + self._ollama_reasoning_effort_supported = True self.client = None self.planner_client = None # None = 기본 client 사용 self.execution_client = None # None = 기본 client 사용 @@ -598,6 +599,28 @@ def _error_response(error: Exception) -> str: return _("(걱정) 서버에 연결할 수 없어요. 네트워크 상태를 확인해주세요.") return _("(걱정) 요청을 처리하지 못했어요. 잠시 후 다시 시도해주세요.") + def _create_chat_completion(self, client, provider, **kwargs): + """제공자별 추론 옵션을 붙여 요청한다. 옛 Ollama가 추론 끄기 값을 거절하면 값을 빼고 한 번 다시 보낸다.""" + try: + return client.chat.completions.create( + **kwargs, extra_body=self._reasoning_extra_body(provider), + ) + except Exception as exc: + if not ( + provider == "ollama" + and self._ollama_reasoning_effort_supported + and getattr(exc, "status_code", None) == 400 + and "reasoning" in str(exc).lower() + ): + raise + self._ollama_reasoning_effort_supported = False + logging.warning( + "[LLMProvider] Ollama가 reasoning_effort=none을 거부해 이번 실행에서는 값을 생략합니다." + ) + return client.chat.completions.create( + **kwargs, extra_body=self._reasoning_extra_body(provider), + ) + def _create_completion_with_fallback( self, client, @@ -624,9 +647,7 @@ def _create_completion_with_fallback( continue attempted.add(backend) try: - response = candidate.chat.completions.create( - model=selected, **kwargs, extra_body=self._reasoning_extra_body(backend), - ) + response = self._create_chat_completion(candidate, backend, model=selected, **kwargs) if kwargs.get("stream") and self._is_custom_provider(backend): response = self._sanitize_custom_stream(response, backend) if _return_target: @@ -918,7 +939,9 @@ def extract_memory_suggestions(self, user_message: str) -> str: if self._response_field(block, "text", "") ) - response = client.chat.completions.create( + response = self._create_chat_completion( + client, + provider, model=model, messages=[ {"role": "system", "content": instructions}, @@ -926,7 +949,6 @@ def extract_memory_suggestions(self, user_message: str) -> str: ], temperature=0, max_tokens=700, - extra_body=self._reasoning_extra_body(provider), ) choices = self._response_field(response, "choices", []) or [] if not choices: @@ -956,6 +978,9 @@ def _reasoning_extra_body(self, provider: str) -> dict: """Groq의 reasoning 계열 모델(gpt-oss, qwen3 등)이 사고 과정(체인 오브 소트)을 그대로 최종 응답 content에 섞어 보내는 것을 막는다. reasoning_format=hidden 이면 최종 답변만 content에 담겨 온다.""" + if provider == "ollama" and self._ollama_reasoning_effort_supported: + # 추론 토큰이 응답 한도를 모두 써 빈 본문을 보내는 Ollama 모델을 위한 설정이다. + return {"reasoning_effort": "none"} if provider == "groq": return {"reasoning_format": "hidden"} if provider == "nvidia_nim": @@ -1322,13 +1347,14 @@ def stream_chat( finally: self._clear_active_stream(stream) else: - stream = client.chat.completions.create( + stream = self._create_chat_completion( + client, + provider, model=model, messages=messages, temperature=float(kwargs.get("temperature", 0.7)), max_tokens=int(kwargs.get("max_tokens", 1000)), stream=True, - extra_body=self._reasoning_extra_body(provider), ) self._set_active_stream(stream, cancel_event) try: @@ -1406,7 +1432,9 @@ def analyze_image(self, image_path_or_b64: str, prompt: str = "") -> str: return self._clean_response(" ".join([b.text for b in resp.content if getattr(b, "type", "") == "text"])) if provider in {"openai", "groq", "mistral", "gemini", "openrouter", "nvidia_nim", "ollama"}: image_url = f"data:{media_type};base64,{data}" - resp = client.chat.completions.create( + resp = self._create_chat_completion( + client, + provider, model=model, messages=[{ "role": "user", @@ -1416,7 +1444,6 @@ def analyze_image(self, image_path_or_b64: str, prompt: str = "") -> str: ], }], max_tokens=800, - extra_body=self._reasoning_extra_body(provider), ) return self._clean_response(resp.choices[0].message.content or "") except Exception as exc: @@ -1527,10 +1554,10 @@ def feed_tool_result( messages.append({"role": "assistant", "content": None, "tool_calls": assistant_tool_calls}) messages.extend(tool_result_messages) - response = client.chat.completions.create( + response = self._create_chat_completion( + client, provider, model=model, messages=messages, temperature=0.7, max_tokens=self._estimate_max_tokens(original_msg), - extra_body=self._reasoning_extra_body(provider), ) if cancel_event is not None and cancel_event.is_set(): return "" diff --git a/VoiceCommand/commands/ai_command.py b/VoiceCommand/commands/ai_command.py index c48f859..457ca1c 100644 --- a/VoiceCommand/commands/ai_command.py +++ b/VoiceCommand/commands/ai_command.py @@ -2060,6 +2060,16 @@ def emit_stream(delta: str) -> None: return interrupted_response return "" + if ( + not str(response or "").strip() + and not tool_calls + and not streamed_parts + ): + logging.warning("[AICommand] 최종 응답 본문이 비어 있습니다.") + self._emit_user_message( + _("(걱정) 응답이 비어 있었어요. 다시 말씀해 주시거나 다른 모델로 바꿔 보세요.") + ) + if response: if direct_skill_escalation: history_recorder = getattr(self.ai_assistant, "add_to_history", None) diff --git a/VoiceCommand/i18n/locales/en/LC_MESSAGES/ari.po b/VoiceCommand/i18n/locales/en/LC_MESSAGES/ari.po index ac31568..2fcbd2c 100644 --- a/VoiceCommand/i18n/locales/en/LC_MESSAGES/ari.po +++ b/VoiceCommand/i18n/locales/en/LC_MESSAGES/ari.po @@ -3404,6 +3404,9 @@ msgstr "No microphone was found, so voice recognition is unavailable. Please sel msgid "아직 이전 요청을 처리하고 있어요." msgstr "I'm still processing the previous request." +msgid "(걱정) 응답이 비어 있었어요. 다시 말씀해 주시거나 다른 모델로 바꿔 보세요." +msgstr "(Worried) The response was empty. Please try saying that again or switch to another model." + msgid "Version: {version} · Commit: {commit}" msgstr "Version: {version} · Commit: {commit}" diff --git a/VoiceCommand/i18n/locales/ja/LC_MESSAGES/ari.po b/VoiceCommand/i18n/locales/ja/LC_MESSAGES/ari.po index 23ea7b3..f59de12 100644 --- a/VoiceCommand/i18n/locales/ja/LC_MESSAGES/ari.po +++ b/VoiceCommand/i18n/locales/ja/LC_MESSAGES/ari.po @@ -3404,6 +3404,9 @@ msgstr "マイクが見つからないため、音声認識を利用できませ msgid "아직 이전 요청을 처리하고 있어요." msgstr "前のリクエストを処理中です。" +msgid "(걱정) 응답이 비어 있었어요. 다시 말씀해 주시거나 다른 모델로 바꿔 보세요." +msgstr "(心配) 応答が空でした。もう一度話すか、別のモデルに切り替えてみてください。" + msgid "Version: {version} · Commit: {commit}" msgstr "バージョン {version} · コミット {commit}" diff --git a/VoiceCommand/i18n/locales/ko/LC_MESSAGES/ari.po b/VoiceCommand/i18n/locales/ko/LC_MESSAGES/ari.po index aea4260..bc3f9bb 100644 --- a/VoiceCommand/i18n/locales/ko/LC_MESSAGES/ari.po +++ b/VoiceCommand/i18n/locales/ko/LC_MESSAGES/ari.po @@ -3404,6 +3404,9 @@ msgstr "마이크를 찾을 수 없어 음성 인식을 사용할 수 없습니 msgid "아직 이전 요청을 처리하고 있어요." msgstr "아직 이전 요청을 처리하고 있어요." +msgid "(걱정) 응답이 비어 있었어요. 다시 말씀해 주시거나 다른 모델로 바꿔 보세요." +msgstr "(걱정) 응답이 비어 있었어요. 다시 말씀해 주시거나 다른 모델로 바꿔 보세요." + msgid "Version: {version} · Commit: {commit}" msgstr "버전 {version} · 커밋 {commit}" diff --git a/VoiceCommand/tests/test_ai_command.py b/VoiceCommand/tests/test_ai_command.py index b2c8fef..c8cc925 100644 --- a/VoiceCommand/tests/test_ai_command.py +++ b/VoiceCommand/tests/test_ai_command.py @@ -474,6 +474,55 @@ def chat_with_tools(self, text, include_context=True): self.assertEqual(response, "The time is 23:09.") + def test_empty_response_notice_is_suppressed_after_cancel_tool_or_stream(self): + class _Assistant: + def __init__(self, *, cancel=False, stream_text="", tool_calls=None): + self.cancel = cancel + self.stream_text = stream_text + self.tool_calls = tool_calls or [] + + def chat_with_tools( + self, text, include_context=True, stream_callback=None, cancel_event=None + ): + del text, include_context + if self.stream_text and stream_callback: + stream_callback(self.stream_text) + if self.cancel: + cancel_event.set() + return "", self.tool_calls + + def run_case(assistant): + command = AICommand(assistant, lambda _message: None, {"enabled": False}) + spoken = [] + streamed = [] + with ( + patch.object(command, "try_fast_path", return_value=None), + patch.object(command, "_get_skill_context", return_value={}), + patch.object(command, "_start_instant_ack", return_value=None), + patch.object(command, "_execute_tool_calls", return_value=[None]), + patch.object(command, "_should_skip_tool_followup", return_value=False), + ): + command._run_interaction( + "빈 응답 테스트", spoken.append, stream_callback=streamed.append + ) + return spoken, streamed + + notice = _("(걱정) 응답이 비어 있었어요. 다시 말씀해 주시거나 다른 모델로 바꿔 보세요.") + self.assertEqual(run_case(_Assistant()), ([notice], [])) + self.assertEqual(run_case(_Assistant(cancel=True)), ([], [])) + self.assertEqual( + run_case(_Assistant(stream_text="이미 표시한 문장")), + ([], ["이미 표시한 문장"]), + ) + self.assertEqual( + run_case(_Assistant(tool_calls=[{ + "id": "tool-1", + "name": "unknown_tool", + "arguments": {}, + }])), + ([], []), + ) + def test_fast_path_records_conversation_through_memory_manager(self): class _Assistant: def __init__(self): diff --git a/VoiceCommand/tests/test_request_failures.py b/VoiceCommand/tests/test_request_failures.py index 1052a70..99586c9 100644 --- a/VoiceCommand/tests/test_request_failures.py +++ b/VoiceCommand/tests/test_request_failures.py @@ -62,6 +62,72 @@ def test_non_server_errors_do_not_switch_connections(self): self.assertIs(caught.exception, error) secondary.chat.completions.create.assert_not_called() + def test_ollama_requests_disable_reasoning(self): + provider = LLMProvider(provider="ollama", model="gemma4:12b") + client = Mock() + response = object() + client.chat.completions.create.return_value = response + + result = provider._create_completion_with_fallback( + client, "ollama", "gemma4:12b", messages=[] + ) + + self.assertIs(result, response) + self.assertEqual( + client.chat.completions.create.call_args.kwargs["extra_body"], + {"reasoning_effort": "none"}, + ) + + def test_ollama_retries_without_reasoning_after_bad_request(self): + provider = LLMProvider(provider="ollama", model="gemma4:12b") + client = Mock() + error = RuntimeError("invalid reasoning value") + error.status_code = 400 + response = object() + client.chat.completions.create.side_effect = [error, response] + + result = provider._create_completion_with_fallback( + client, "ollama", "gemma4:12b", messages=[] + ) + + self.assertIs(result, response) + calls = client.chat.completions.create.call_args_list + self.assertEqual(len(calls), 2) + self.assertEqual( + [request.kwargs["extra_body"] for request in calls], + [{"reasoning_effort": "none"}, {}], + ) + self.assertEqual(provider._reasoning_extra_body("ollama"), {}) + + def test_create_chat_completion_retries_only_ollama_reasoning_rejection(self): + provider = LLMProvider(provider="ollama", model="gemma4:12b") + client = Mock() + error = RuntimeError("invalid reasoning value") + error.status_code = 400 + response = object() + client.chat.completions.create.side_effect = [error, response] + + result = provider._create_chat_completion(client, "ollama", model="gemma4:12b", messages=[]) + + self.assertIs(result, response) + self.assertEqual( + [request.kwargs["extra_body"] for request in client.chat.completions.create.call_args_list], + [{"reasoning_effort": "none"}, {}], + ) + for provider_name, message in ( + ("ollama", "bad request"), + ("nvidia_nim", "invalid reasoning value"), + ): + with self.subTest(provider=provider_name, message=message): + provider = LLMProvider(provider=provider_name, model="m") + other_error = RuntimeError(message) + other_error.status_code = 400 + client = Mock() + client.chat.completions.create.side_effect = other_error + with self.assertRaises(RuntimeError): + provider._create_chat_completion(client, provider_name, model="m", messages=[]) + self.assertEqual(client.chat.completions.create.call_count, 1) + def test_server_error_switches_connection_without_duplicate_history(self): for with_tools in (False, True): with self.subTest(with_tools=with_tools): diff --git a/docs/README.md b/docs/README.md index 69dfe76..4c19a6f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -14,6 +14,7 @@ README가 프로젝트의 첫인상이라면, 이 문서는 필요한 문서를 v1.4.0에서는 설치 중 개인정보 안내와 선택 항목, 말풍선 글꼴 설정, Edge TTS 주소 변경 대응을 더했습니다. v1.2.2~v1.3.3의 변경도 아래에 함께 정리돼 있습니다. v1.4.0 이후 병합된 변경은 목록 앞쪽에 있습니다. +- Ollama에서 Gemma 4 같은 추론 모델을 쓸 때 추론을 끄고 요청해 답이 비지 않게 한 동작(Ollama 0.12.4 이상 권장), 응답이 비었을 때 아무 표시 없이 끝나지 않고 안내 문구를 보여 주는 동작 - 아리의 기분이 응답에 붙은 감정 표현에 따라 조금씩 변하고 말투에 반영되는 동작, 기본으로 꺼져 있는 `한동안 대화가 없으면 먼저 말 걸기`(설정 → `RP 설정` 맨 위, `spontaneous_talk_enabled`) - Telegram에서 보낸 요청의 위험 작업을 같은 대화방의 버튼으로 승인·거부하는 원격 승인(`telegram_remote_approval_enabled`, 기본 꺼짐) - Telegram 음성 메모(로컬 Whisper일 때만)와 사진을 명령으로 받는 선택 설정 `telegram_voice_enabled`·`telegram_photo_enabled`(기본 꺼짐, 파일 10MB·음성 120초 제한) diff --git a/docs/USAGE.md b/docs/USAGE.md index 761c6f9..2b8e208 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -404,6 +404,7 @@ Python 3.12나 3.14 등 다른 버전만 있으면 설치를 시작하지 않고 ## 9. Ollama 로컬 LLM 사용 시 Ollama를 쓰면 인터넷 연결이나 API 비용 없이 로컬에서 LLM을 돌릴 수 있습니다. +Gemma 4처럼 답하기 전에 추론을 하는 모델은 추론을 끄고 요청합니다. 추론이 응답 길이 한도를 다 써서 답이 비는 것을 막기 위해서이며, Ollama 0.12.4 이상에서 동작하므로 최신 버전을 권장합니다. 1. 설정창 → **TTS 설정 → 로컬 설치 → Ollama 설치/모델 받기** 버튼으로 설치하거나, [https://ollama.com](https://ollama.com) 에서 직접 설치합니다.