Skip to content

Commit ff22ca6

Browse files
mrveisstclaude
authored
fix(api/chat): wire chat endpoint through LLMService.chat() — closes silent canned-response regression (#7047) (#7101)
Two #3185 LLMInterface-retirement misses shipped silently in the same helper, causing chat() to silently return a canned "I'm currently unable to generate a response" string for every user request: Bug 1 — broken module path (api/chat.py:106): from llm_service import LLMService # module never existed Canonical path is services.llm_service. The import was inside ``get_llm_service()`` so it deferred to first call rather than boot — silent until a chat request arrives, then ModuleNotFoundError. Bug 2 — feature-degradation guard around a stale method (api/chat.py:543): if hasattr(llm_service, "generate_response"): return await llm_service.generate_response(...) else: return {"content": "I'm currently unable to generate a response..."} LLMService doesn't expose generate_response (that was an LLMInterface method). The hasattr guard always took the else branch, so every request received the canned fallback regardless of whether LLMService was healthy. Major user-facing functional regression. Fix --- 1. Update lazy import to ``services.llm_service.LLMService``. 2. Replace the hasattr/else dance with a real ``llm_service.chat(...)`` call. Map LLMService's contract: - messages ← llm_context (already OpenAI-format) - conversation_id ← session_id (per-conversation overrides) - request_id ← request_id (passes through **kwargs for tracing) Read response.error / response.content per LLMResponse's Pydantic model (verified shapes against services/llm_service.py:99-112). 3. User-facing fallback strings are unchanged on error — internal error reasons are logged via ``logger.warning`` but never leaked to the response. Verification ------------ Added ``api/chat_generate_ai_response_test.py`` — 5 tests pinning the contract: - happy path: response.content surfaces as result["content"] - chat() args: messages/conversation_id/request_id passed through - LLM returns error: fallback string, error reason NOT leaked to user - chat() raises: fallback string, exception detail NOT leaked - **regression pin** for the original bug: hasattr(llm_service, "generate_response") path can never silently swallow output again — test fails if a future change reintroduces it - **regression pin** for the import path: assert source contains ``from services.llm_service import LLMService`` $ python3 -m pytest autobot-backend/api/chat_generate_ai_response_test.py -xvs ============================== 5 passed in 2.80s =============================== Per the feedback memory I just saved (verify_return_shape_in_method_migration): - LLMResponse.content: str ✓ verified - LLMResponse.error: Optional[str] ✓ verified - chat() return type: LLMResponse (inspected via inspect.signature) Closes #7047 (items 1 + 2). The remaining sites flagged in the issue body (async_chat_workflow.py, modern_ai_integration.py, nl_database_service.py) are tracked for separate per-site verification — their contexts differ enough that batching them risks the same return- shape miss this PR caught with tests. Co-authored-by: t <t@t> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 9d2348c commit ff22ca6

2 files changed

Lines changed: 160 additions & 7 deletions

File tree

autobot-backend/api/chat.py

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ def get_memory_interface(request: Request) -> Optional[Any]:
103103

104104
def get_llm_service(request: Request) -> Any:
105105
"""Get LLM service from app state, with lazy initialization"""
106-
from llm_service import LLMService
106+
from services.llm_service import LLMService
107107

108108
from utils.lazy_singleton import lazy_init_singleton
109109

@@ -540,15 +540,21 @@ async def _generate_ai_response(llm_service, llm_context: List[Dict], session_id
540540
AI response dict with content and role
541541
"""
542542
try:
543-
if hasattr(llm_service, "generate_response"):
544-
return await llm_service.generate_response(
545-
messages=llm_context, session_id=session_id, request_id=request_id
546-
)
547-
else:
543+
# LLMService.chat() accepts OpenAI-format messages and uses
544+
# conversation_id for per-conversation provider/model overrides.
545+
# request_id flows through via **kwargs for tracing.
546+
response = await llm_service.chat(
547+
messages=llm_context,
548+
conversation_id=session_id,
549+
request_id=request_id,
550+
)
551+
if response.error:
552+
logger.warning("LLM returned error for request %s: %s", request_id, response.error)
548553
return {
549-
"content": "I'm currently unable to generate a response. Please try again.",
554+
"content": "I encountered an error processing your message. Please try again.",
550555
"role": "assistant",
551556
}
557+
return {"content": response.content, "role": "assistant"}
552558
except Exception as e:
553559
logger.error("LLM generation failed: %s", e)
554560
return {
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
# AutoBot - AI-Powered Automation Platform
2+
# Copyright (c) 2025 mrveiss
3+
# Author: mrveiss
4+
"""Contract tests for ``api.chat._generate_ai_response`` (#7047).
5+
6+
Two regressions shipped silently in the same helper after the #3185
7+
LLMInterface retirement:
8+
9+
1. ``api/chat.py:106`` imported from a non-existent ``llm_service`` module
10+
(canonical path is ``services.llm_service``); function-scoped, so it
11+
fired only on first call.
12+
13+
2. ``api/chat.py:543`` had ``hasattr(llm_service, "generate_response")``
14+
guarding a method that LLMService never exposed. The else-branch ran
15+
for every chat request — users got a canned "I'm currently unable
16+
to generate a response" string instead of the model's reply.
17+
18+
These tests pin the migrated shape so the same class of drift can't
19+
recur silently.
20+
"""
21+
22+
from __future__ import annotations
23+
24+
from typing import Any
25+
from unittest.mock import AsyncMock
26+
27+
import pytest
28+
29+
30+
@pytest.fixture
31+
def make_llm_response():
32+
"""Return a factory that builds a stub ``LLMResponse``-shaped object."""
33+
34+
class _StubResponse:
35+
def __init__(self, *, content: str = "", error: str | None = None):
36+
self.content = content
37+
self.error = error
38+
39+
return _StubResponse
40+
41+
42+
@pytest.mark.asyncio
43+
async def test_generate_ai_response_returns_model_content_on_success(make_llm_response: Any) -> None:
44+
"""Happy path: LLMResponse.content surfaces in the result dict."""
45+
from api.chat import _generate_ai_response
46+
47+
llm_service = AsyncMock()
48+
llm_service.chat = AsyncMock(return_value=make_llm_response(content="Hello, world!", error=None))
49+
50+
result = await _generate_ai_response(
51+
llm_service=llm_service,
52+
llm_context=[{"role": "user", "content": "hi"}],
53+
session_id="s-123",
54+
request_id="r-abc",
55+
)
56+
57+
assert result == {"content": "Hello, world!", "role": "assistant"}
58+
# Pin the call shape — confirms migrated args reach LLMService.chat correctly.
59+
llm_service.chat.assert_awaited_once_with(
60+
messages=[{"role": "user", "content": "hi"}],
61+
conversation_id="s-123",
62+
request_id="r-abc",
63+
)
64+
65+
66+
@pytest.mark.asyncio
67+
async def test_generate_ai_response_falls_back_when_llm_returns_error(make_llm_response: Any) -> None:
68+
"""LLMResponse.error truthy → user-friendly fallback message."""
69+
from api.chat import _generate_ai_response
70+
71+
llm_service = AsyncMock()
72+
llm_service.chat = AsyncMock(return_value=make_llm_response(content="", error="rate limit exceeded"))
73+
74+
result = await _generate_ai_response(
75+
llm_service=llm_service,
76+
llm_context=[{"role": "user", "content": "hi"}],
77+
session_id="s-1",
78+
request_id="r-1",
79+
)
80+
81+
assert result["role"] == "assistant"
82+
# Must NOT leak the underlying error message to the user — fallback string only.
83+
assert "I encountered an error" in result["content"]
84+
assert "rate limit" not in result["content"]
85+
86+
87+
@pytest.mark.asyncio
88+
async def test_generate_ai_response_falls_back_when_chat_raises(make_llm_response: Any) -> None:
89+
"""Network/runtime exception in chat() → user-friendly fallback."""
90+
from api.chat import _generate_ai_response
91+
92+
llm_service = AsyncMock()
93+
llm_service.chat = AsyncMock(side_effect=RuntimeError("boom"))
94+
95+
result = await _generate_ai_response(
96+
llm_service=llm_service,
97+
llm_context=[{"role": "user", "content": "hi"}],
98+
session_id="s-1",
99+
request_id="r-1",
100+
)
101+
102+
assert result["role"] == "assistant"
103+
assert "I encountered an error" in result["content"]
104+
# Underlying exception detail must not surface in user-facing string.
105+
assert "boom" not in result["content"]
106+
107+
108+
@pytest.mark.asyncio
109+
async def test_generate_ai_response_does_not_call_legacy_generate_response(make_llm_response: Any) -> None:
110+
"""Regression pin: post-#3185 the helper must call .chat(), never
111+
.generate_response() (which was the deleted LLMInterface method).
112+
Catches the original #7047 silent-fallback bug if reintroduced.
113+
"""
114+
from api.chat import _generate_ai_response
115+
116+
llm_service = AsyncMock()
117+
llm_service.chat = AsyncMock(return_value=make_llm_response(content="ok", error=None))
118+
# If a future caller hits this attribute, the test fails — locks the migration.
119+
llm_service.generate_response = AsyncMock(side_effect=AssertionError("legacy method"))
120+
121+
result = await _generate_ai_response(
122+
llm_service=llm_service,
123+
llm_context=[{"role": "user", "content": "hi"}],
124+
session_id="s-1",
125+
request_id="r-1",
126+
)
127+
128+
assert result["content"] == "ok"
129+
llm_service.generate_response.assert_not_awaited()
130+
llm_service.chat.assert_awaited_once()
131+
132+
133+
def test_get_llm_service_imports_canonical_module_path() -> None:
134+
"""Regression pin: the lazy import inside ``get_llm_service`` must
135+
resolve to ``services.llm_service.LLMService`` (the canonical post-#3185
136+
location), not a non-existent top-level ``llm_service`` module.
137+
"""
138+
import inspect
139+
140+
from api import chat
141+
142+
src = inspect.getsource(chat.get_llm_service)
143+
assert "from services.llm_service import LLMService" in src, (
144+
"get_llm_service must import from services.llm_service "
145+
"(canonical post-#3185 path); the older 'from llm_service import' "
146+
"raises ModuleNotFoundError at first call."
147+
)

0 commit comments

Comments
 (0)