Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 38 additions & 11 deletions VoiceCommand/agent/llm_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 사용
Expand Down Expand Up @@ -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,
Expand All @@ -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:
Expand Down Expand Up @@ -918,15 +939,16 @@ 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},
{"role": "user", "content": user_message},
],
temperature=0,
max_tokens=700,
extra_body=self._reasoning_extra_body(provider),
)
choices = self._response_field(response, "choices", []) or []
if not choices:
Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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",
Expand All @@ -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:
Expand Down Expand Up @@ -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 ""
Expand Down
10 changes: 10 additions & 0 deletions VoiceCommand/commands/ai_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
3 changes: 3 additions & 0 deletions VoiceCommand/i18n/locales/en/LC_MESSAGES/ari.po
Original file line number Diff line number Diff line change
Expand Up @@ -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}"

Expand Down
3 changes: 3 additions & 0 deletions VoiceCommand/i18n/locales/ja/LC_MESSAGES/ari.po
Original file line number Diff line number Diff line change
Expand Up @@ -3404,6 +3404,9 @@ msgstr "マイクが見つからないため、音声認識を利用できませ
msgid "아직 이전 요청을 처리하고 있어요."
msgstr "前のリクエストを処理中です。"

msgid "(걱정) 응답이 비어 있었어요. 다시 말씀해 주시거나 다른 모델로 바꿔 보세요."
msgstr "(心配) 応答が空でした。もう一度話すか、別のモデルに切り替えてみてください。"

msgid "Version: {version} · Commit: {commit}"
msgstr "バージョン {version} · コミット {commit}"

Expand Down
3 changes: 3 additions & 0 deletions VoiceCommand/i18n/locales/ko/LC_MESSAGES/ari.po
Original file line number Diff line number Diff line change
Expand Up @@ -3404,6 +3404,9 @@ msgstr "마이크를 찾을 수 없어 음성 인식을 사용할 수 없습니
msgid "아직 이전 요청을 처리하고 있어요."
msgstr "아직 이전 요청을 처리하고 있어요."

msgid "(걱정) 응답이 비어 있었어요. 다시 말씀해 주시거나 다른 모델로 바꿔 보세요."
msgstr "(걱정) 응답이 비어 있었어요. 다시 말씀해 주시거나 다른 모델로 바꿔 보세요."

msgid "Version: {version} · Commit: {commit}"
msgstr "버전 {version} · 커밋 {commit}"

Expand Down
49 changes: 49 additions & 0 deletions VoiceCommand/tests/test_ai_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
66 changes: 66 additions & 0 deletions VoiceCommand/tests/test_request_failures.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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초 제한)
Expand Down
1 change: 1 addition & 0 deletions docs/USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) 에서 직접 설치합니다.
Expand Down
Loading