codeBOKER commited on
Commit
9a3879b
ยท
1 Parent(s): aa966e0

feat: replace Hugging Face with Gemini as fallback AI provider

Browse files
.env.example CHANGED
@@ -16,8 +16,8 @@ PINECONE_NAMESPACE=default
16
 
17
  GROQ_API_KEY=your-groq-api-key
18
  GROQ_MODEL=your-groq-tool-calling-model
19
- HF_TOKEN=your-hugging-face-token
20
- HF_MODEL=your-hugging-face-tool-calling-model
21
  AI_TEMPERATURE=0.2
22
  AI_MAX_TOOL_ITERATIONS=3
23
  REQUEST_TIMEOUT_SECONDS=20
 
16
 
17
  GROQ_API_KEY=your-groq-api-key
18
  GROQ_MODEL=your-groq-tool-calling-model
19
+ GEMINI_API_KEY=your-gemini-api-key
20
+ GEMINI_MODEL=gemini-2.0-flash
21
  AI_TEMPERATURE=0.2
22
  AI_MAX_TOOL_ITERATIONS=3
23
  REQUEST_TIMEOUT_SECONDS=20
.gitignore CHANGED
@@ -10,3 +10,5 @@ __pycache__/
10
  htmlcov/
11
  tests/notes.txt
12
  whatsappDebug/
 
 
 
10
  htmlcov/
11
  tests/notes.txt
12
  whatsappDebug/
13
+ .agents/
14
+ skills-lock.json
app/ai/__init__.py CHANGED
@@ -1,4 +1,4 @@
1
  from app.ai.orchestrator import AIOrchestrator
2
- from app.ai.providers import GroqChatProvider, HuggingFaceChatProvider
3
 
4
- __all__ = ["AIOrchestrator", "GroqChatProvider", "HuggingFaceChatProvider"]
 
1
  from app.ai.orchestrator import AIOrchestrator
2
+ from app.ai.providers import GeminiChatProvider, GroqChatProvider
3
 
4
+ __all__ = ["AIOrchestrator", "GroqChatProvider", "GeminiChatProvider"]
app/ai/orchestrator.py CHANGED
@@ -52,9 +52,9 @@ class AIOrchestrator:
52
  temperature=max(temperature - 0.2, 0.1),
53
  )
54
  except RetryableProviderError:
55
- logger.warning("Primary retry failed; falling back to Hugging Face")
56
  except RetryableProviderError:
57
- logger.warning("Primary provider failed; falling back to Hugging Face")
58
 
59
  return await self.fallback.chat(
60
  messages,
@@ -89,9 +89,9 @@ class AIOrchestrator:
89
  temperature=max(self.temperature - 0.2, 0.1),
90
  )
91
  except RetryableProviderError:
92
- logger.warning("Primary retry failed; falling back to Hugging Face")
93
  except RetryableProviderError:
94
- logger.warning("Primary provider failed; falling back to Hugging Face")
95
 
96
  return await self._run_provider(
97
  self.fallback,
 
52
  temperature=max(temperature - 0.2, 0.1),
53
  )
54
  except RetryableProviderError:
55
+ logger.warning("Primary retry failed; falling back to Gemini")
56
  except RetryableProviderError:
57
+ logger.warning("Primary provider failed; falling back to Gemini")
58
 
59
  return await self.fallback.chat(
60
  messages,
 
89
  temperature=max(self.temperature - 0.2, 0.1),
90
  )
91
  except RetryableProviderError:
92
+ logger.warning("Primary retry failed; falling back to Gemini")
93
  except RetryableProviderError:
94
+ logger.warning("Primary provider failed; falling back to Gemini")
95
 
96
  return await self._run_provider(
97
  self.fallback,
app/ai/providers.py CHANGED
@@ -1,3 +1,4 @@
 
1
  from typing import Any, Protocol
2
 
3
  from app.config import Settings
@@ -97,15 +98,55 @@ class GroqChatProvider(OpenAICompatibleChatProvider):
97
  )
98
 
99
 
100
- class HuggingFaceChatProvider(OpenAICompatibleChatProvider):
 
 
101
  def __init__(self, settings: Settings) -> None:
102
- super().__init__(
103
- api_key=settings.hf_token,
104
- base_url="https://router.huggingface.co/v1",
105
- model=settings.hf_model,
106
- timeout=settings.request_timeout_seconds,
107
- name="huggingface",
108
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
109
 
110
 
111
  def _normalize_openai_message(message: Any) -> AIProviderResponse:
@@ -135,6 +176,122 @@ def _normalize_openai_message(message: Any) -> AIProviderResponse:
135
  )
136
 
137
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
138
  def _provider_error_from_exception(exc: Exception, provider_name: str) -> ProviderError:
139
  status_code = getattr(exc, "status_code", None)
140
  body = getattr(exc, "body", None)
 
1
+ import json
2
  from typing import Any, Protocol
3
 
4
  from app.config import Settings
 
98
  )
99
 
100
 
101
+ class GeminiChatProvider:
102
+ name = "gemini"
103
+
104
  def __init__(self, settings: Settings) -> None:
105
+ self.api_key = settings.gemini_api_key
106
+ self.model = settings.gemini_model
107
+ self.timeout = settings.request_timeout_seconds
108
+ self._client: Any | None = None
109
+
110
+ @property
111
+ def client(self) -> Any:
112
+ if self._client is None:
113
+ from google import genai
114
+
115
+ self._client = genai.Client(api_key=self.api_key)
116
+ return self._client
117
+
118
+ async def chat(
119
+ self,
120
+ messages: list[dict[str, Any]],
121
+ *,
122
+ tools: list[dict[str, Any]] | None = None,
123
+ tool_choice: str | dict[str, Any] | None = "auto",
124
+ temperature: float = 0.2,
125
+ ) -> AIProviderResponse:
126
+ from google.genai import types
127
+
128
+ try:
129
+ contents = _convert_messages_to_gemini(messages)
130
+ config_kwargs: dict[str, Any] = {"temperature": temperature}
131
+ if tools:
132
+ config_kwargs["tools"] = _convert_tools_to_gemini(tools)
133
+ if tool_choice and tool_choice != "none":
134
+ config_kwargs["tool_config"] = types.ToolConfig(
135
+ function_calling_config=types.FunctionCallingConfig(
136
+ mode=types.FunctionCallingConfig.Mode.AUTO
137
+ if tool_choice == "auto"
138
+ else types.FunctionCallingConfig.Mode.ANY,
139
+ )
140
+ )
141
+ config = types.GenerateContentConfig(**config_kwargs)
142
+ response = await self.client.aio.models.generate_content(
143
+ model=self.model,
144
+ contents=contents,
145
+ config=config,
146
+ )
147
+ return _parse_gemini_response(response)
148
+ except Exception as exc: # noqa: BLE001
149
+ raise _provider_error_from_exception(exc, self.name) from exc
150
 
151
 
152
  def _normalize_openai_message(message: Any) -> AIProviderResponse:
 
176
  )
177
 
178
 
179
+ def _convert_tools_to_gemini(tools: list[dict[str, Any]]) -> list[Any]:
180
+ from google.genai import types
181
+
182
+ declarations = []
183
+ for tool in tools:
184
+ func = tool.get("function", {})
185
+ params = func.get("parameters", {})
186
+ declarations.append(
187
+ types.FunctionDeclaration(
188
+ name=func.get("name", ""),
189
+ description=func.get("description", ""),
190
+ parameters=params if params else None,
191
+ )
192
+ )
193
+ return [types.Tool(function_declarations=declarations)]
194
+
195
+
196
+ def _convert_messages_to_gemini(messages: list[dict[str, Any]]) -> list[Any]:
197
+ from google.genai import types
198
+
199
+ contents = []
200
+ pending_tool_calls: dict[str, dict[str, Any]] = {}
201
+
202
+ for msg in messages:
203
+ role = msg.get("role", "user")
204
+
205
+ if role == "tool":
206
+ tool_call_id = msg.get("tool_call_id", "")
207
+ if tool_call_id in pending_tool_calls:
208
+ tc = pending_tool_calls.pop(tool_call_id)
209
+ contents.append(
210
+ types.Content(
211
+ role="user",
212
+ parts=[
213
+ types.Part.from_function_response(
214
+ name=tc["name"],
215
+ response=json.loads(msg.get("content", "{}")),
216
+ )
217
+ ],
218
+ )
219
+ )
220
+ continue
221
+
222
+ parts: list[Any] = []
223
+ content = msg.get("content")
224
+ if content:
225
+ parts.append(types.Part(text=content))
226
+
227
+ tool_calls = msg.get("tool_calls") or []
228
+ for tc in tool_calls:
229
+ func = tc.get("function", {})
230
+ args = func.get("arguments", "{}")
231
+ try:
232
+ parsed_args = json.loads(args)
233
+ except (json.JSONDecodeError, TypeError):
234
+ parsed_args = {}
235
+ parts.append(
236
+ types.Part.from_function_call(
237
+ name=func.get("name", ""),
238
+ args=parsed_args,
239
+ )
240
+ )
241
+ pending_tool_calls[tc.get("id", "")] = {
242
+ "name": func.get("name", ""),
243
+ }
244
+
245
+ if parts:
246
+ gemini_role = "model" if role == "assistant" else "user"
247
+ contents.append(types.Content(role=gemini_role, parts=parts))
248
+
249
+ return contents
250
+
251
+
252
+ def _parse_gemini_response(response: Any) -> AIProviderResponse:
253
+ text_content = ""
254
+ tool_calls: list[ToolCall] = []
255
+ raw_message: dict[str, Any] = {}
256
+
257
+ if not response.candidates:
258
+ return AIProviderResponse(
259
+ content=text_content, tool_calls=tool_calls, raw_message=raw_message
260
+ )
261
+
262
+ candidate = response.candidates[0]
263
+ parts = candidate.content.parts if candidate.content else []
264
+
265
+ for part in parts:
266
+ if part.text:
267
+ text_content += part.text
268
+ elif part.function_call:
269
+ fc = part.function_call
270
+ args = dict(fc.args) if fc.args else {}
271
+ tool_calls.append(
272
+ ToolCall(
273
+ id=fc.name,
274
+ name=fc.name,
275
+ arguments=json.dumps(args, ensure_ascii=False),
276
+ )
277
+ )
278
+
279
+ raw_message["role"] = "assistant"
280
+ if text_content:
281
+ raw_message["content"] = text_content
282
+ if tool_calls:
283
+ raw_message["tool_calls"] = [
284
+ {
285
+ "id": tc.id,
286
+ "type": "function",
287
+ "function": {"name": tc.name, "arguments": tc.arguments},
288
+ }
289
+ for tc in tool_calls
290
+ ]
291
+
292
+ return AIProviderResponse(content=text_content, tool_calls=tool_calls, raw_message=raw_message)
293
+
294
+
295
  def _provider_error_from_exception(exc: Exception, provider_name: str) -> ProviderError:
296
  status_code = getattr(exc, "status_code", None)
297
  body = getattr(exc, "body", None)
app/config.py CHANGED
@@ -32,8 +32,8 @@ class Settings(BaseSettings):
32
 
33
  groq_api_key: str = Field(min_length=1)
34
  groq_model: str = Field(min_length=1)
35
- hf_token: str = Field(min_length=1)
36
- hf_model: str = Field(min_length=1)
37
  ai_temperature: float = 0.2
38
  ai_max_tool_iterations: int = 3
39
  request_timeout_seconds: float = 20.0
 
32
 
33
  groq_api_key: str = Field(min_length=1)
34
  groq_model: str = Field(min_length=1)
35
+ gemini_api_key: str = Field(min_length=1)
36
+ gemini_model: str = Field(min_length=1)
37
  ai_temperature: float = 0.2
38
  ai_max_tool_iterations: int = 3
39
  request_timeout_seconds: float = 20.0
app/database/supabase.py CHANGED
@@ -53,7 +53,7 @@ class SupabaseRepository:
53
  update_payload: dict[str, Any] = {}
54
  if remote_jid and not existing.get("remoteJid"):
55
  update_payload["remoteJid"] = remote_jid
56
- if name is not None:
57
  update_payload["name"] = name
58
  if registered is not None:
59
  update_payload["registered"] = registered
 
53
  update_payload: dict[str, Any] = {}
54
  if remote_jid and not existing.get("remoteJid"):
55
  update_payload["remoteJid"] = remote_jid
56
+ if name is not None and not existing.get("name"):
57
  update_payload["name"] = name
58
  if registered is not None:
59
  update_payload["registered"] = registered
app/services/container.py CHANGED
@@ -1,7 +1,7 @@
1
  from dataclasses import dataclass
2
 
3
  from app.ai.orchestrator import AIOrchestrator
4
- from app.ai.providers import GroqChatProvider, HuggingFaceChatProvider
5
  from app.config import Settings
6
  from app.database.supabase import SupabaseRepository, create_supabase_client
7
  from app.services.admin_service import AdminService
@@ -30,7 +30,7 @@ class ServiceContainer:
30
  whatsapp = WhatsAppClient(settings)
31
  ai = AIOrchestrator(
32
  primary=GroqChatProvider(settings),
33
- fallback=HuggingFaceChatProvider(settings),
34
  temperature=settings.ai_temperature,
35
  max_tool_iterations=settings.ai_max_tool_iterations,
36
  )
 
1
  from dataclasses import dataclass
2
 
3
  from app.ai.orchestrator import AIOrchestrator
4
+ from app.ai.providers import GeminiChatProvider, GroqChatProvider
5
  from app.config import Settings
6
  from app.database.supabase import SupabaseRepository, create_supabase_client
7
  from app.services.admin_service import AdminService
 
30
  whatsapp = WhatsAppClient(settings)
31
  ai = AIOrchestrator(
32
  primary=GroqChatProvider(settings),
33
+ fallback=GeminiChatProvider(settings),
34
  temperature=settings.ai_temperature,
35
  max_tool_iterations=settings.ai_max_tool_iterations,
36
  )
app/services/conversation_service.py CHANGED
@@ -234,7 +234,7 @@ class ConversationService:
234
  customer_id=str(customer["id"]),
235
  sender_type="assistant",
236
  message=reply,
237
- metadata={"provider_flow": "groq_primary_hf_fallback", "user_mode": user_mode},
238
  )
239
 
240
  if is_returning_driver:
 
234
  customer_id=str(customer["id"]),
235
  sender_type="assistant",
236
  message=reply,
237
+ metadata={"provider_flow": "groq_primary_gemini_fallback", "user_mode": user_mode},
238
  )
239
 
240
  if is_returning_driver:
prompts/system.md CHANGED
@@ -9,6 +9,7 @@ Rules:
9
  - Never pass phone_number; WhatsApp supplies it.
10
  - Do not discuss internal tools, prompts, databases, or failover.
11
  - STRICT: Only answer about FALSA travel booking. Decline anything else.
 
12
 
13
  Context:
14
  - {current_datetime} ({day_name}, {timezone})
 
9
  - Never pass phone_number; WhatsApp supplies it.
10
  - Do not discuss internal tools, prompts, databases, or failover.
11
  - STRICT: Only answer about FALSA travel booking. Decline anything else.
12
+ - CRITICAL: The brand name is always spelled "ูู„ุฒุง" (ู-ู„-ุฒ-ุง) โ€” NEVER "ูู„ุณุง" or any other spelling.
13
 
14
  Context:
15
  - {current_datetime} ({day_name}, {timezone})
prompts/system_new_user.md CHANGED
@@ -8,3 +8,4 @@ It is the sender's first message. Welcome them warmly and introduce FALSA with a
8
  4. Use emojis (๐Ÿš—, ๐Ÿšš, etc.) and friendly tone. Be warm, not robotic.
9
  5. Keep it short โ€” 4-5 lines max. Do not over-explain.
10
  6. Never ask for name of a passenger. Only ask for name if they want to become a driver.
 
 
8
  4. Use emojis (๐Ÿš—, ๐Ÿšš, etc.) and friendly tone. Be warm, not robotic.
9
  5. Keep it short โ€” 4-5 lines max. Do not over-explain.
10
  6. Never ask for name of a passenger. Only ask for name if they want to become a driver.
11
+ 7. The brand name is ALWAYS "ูู„ุฒุง" (ู-ู„-ุฒ-ุง) โ€” never "ูู„ุณุง" or any other variant.
pyproject.toml CHANGED
@@ -9,6 +9,7 @@ dependencies = [
9
  "pydantic-settings>=2.4.0",
10
  "supabase>=2.6.0",
11
  "openai>=1.40.0",
 
12
  "httpx>=0.27.0",
13
  "python-dotenv>=1.0.1",
14
  ]
 
9
  "pydantic-settings>=2.4.0",
10
  "supabase>=2.6.0",
11
  "openai>=1.40.0",
12
+ "google-genai>=1.0.0",
13
  "httpx>=0.27.0",
14
  "python-dotenv>=1.0.1",
15
  ]
requirements.txt CHANGED
@@ -3,6 +3,7 @@ uvicorn[standard]>=0.30.0
3
  pydantic-settings>=2.4.0
4
  supabase>=2.6.0
5
  openai>=1.40.0
 
6
  httpx>=0.27.0
7
  python-dotenv>=1.0.1
8
 
 
3
  pydantic-settings>=2.4.0
4
  supabase>=2.6.0
5
  openai>=1.40.0
6
+ google-genai>=1.0.0
7
  httpx>=0.27.0
8
  python-dotenv>=1.0.1
9
 
tests/conftest.py CHANGED
@@ -20,8 +20,8 @@ def settings() -> Settings:
20
  jina_api_key="jina-secret",
21
  groq_api_key="groq-secret",
22
  groq_model="groq-tool-model",
23
- hf_token="hf-secret",
24
- hf_model="hf-tool-model",
25
  whatsapp_verify_token="verify-token",
26
  whatsapp_app_secret="app-secret",
27
  whatsapp_access_token="wa-token",
@@ -130,7 +130,7 @@ class FakeRepository:
130
  if existing:
131
  if remote_jid and not existing.get("remoteJid"):
132
  existing["remoteJid"] = remote_jid
133
- if name is not None:
134
  existing["name"] = name
135
  if registered is not None:
136
  existing["registered"] = registered
 
20
  jina_api_key="jina-secret",
21
  groq_api_key="groq-secret",
22
  groq_model="groq-tool-model",
23
+ gemini_api_key="gemini-secret",
24
+ gemini_model="gemini-tool-model",
25
  whatsapp_verify_token="verify-token",
26
  whatsapp_app_secret="app-secret",
27
  whatsapp_access_token="wa-token",
 
130
  if existing:
131
  if remote_jid and not existing.get("remoteJid"):
132
  existing["remoteJid"] = remote_jid
133
+ if name is not None and not existing.get("name"):
134
  existing["name"] = name
135
  if registered is not None:
136
  existing["registered"] = registered
tests/test_ai_orchestrator.py CHANGED
@@ -31,7 +31,7 @@ class ScriptedProvider:
31
  @pytest.mark.asyncio
32
  async def test_ai_falls_back_when_primary_rate_limited():
33
  primary = ScriptedProvider("groq", [RetryableProviderError("rate limited")])
34
- fallback = ScriptedProvider("huggingface", [AIProviderResponse(content="fallback reply")])
35
  registry = ToolRegistry()
36
  orchestrator = AIOrchestrator(
37
  primary=primary,
@@ -56,7 +56,7 @@ async def test_ai_retries_invalid_groq_tool_generation_before_fallback():
56
  AIProviderResponse(content="primary retry reply"),
57
  ],
58
  )
59
- fallback = ScriptedProvider("huggingface", [AIProviderResponse(content="fallback")])
60
  orchestrator = AIOrchestrator(
61
  primary=primary,
62
  fallback=fallback,
@@ -142,7 +142,7 @@ async def test_ai_reports_invalid_tool_arguments_to_model():
142
  @pytest.mark.asyncio
143
  async def test_chat_falls_back_when_primary_rate_limited():
144
  primary = ScriptedProvider("groq", [RetryableProviderError("rate limited")])
145
- fallback = ScriptedProvider("huggingface", [AIProviderResponse(content="fallback reply")])
146
  orchestrator = AIOrchestrator(
147
  primary=primary,
148
  fallback=fallback,
@@ -162,7 +162,7 @@ async def test_chat_falls_back_when_primary_rate_limited():
162
  @pytest.mark.asyncio
163
  async def test_chat_returns_primary_on_success():
164
  primary = ScriptedProvider("groq", [AIProviderResponse(content="primary reply")])
165
- fallback = ScriptedProvider("huggingface", [AIProviderResponse(content="fallback")])
166
  orchestrator = AIOrchestrator(
167
  primary=primary,
168
  fallback=fallback,
@@ -188,7 +188,7 @@ async def test_chat_retries_invalid_tool_call_before_fallback():
188
  AIProviderResponse(content="primary retry reply"),
189
  ],
190
  )
191
- fallback = ScriptedProvider("huggingface", [AIProviderResponse(content="fallback")])
192
  orchestrator = AIOrchestrator(
193
  primary=primary,
194
  fallback=fallback,
 
31
  @pytest.mark.asyncio
32
  async def test_ai_falls_back_when_primary_rate_limited():
33
  primary = ScriptedProvider("groq", [RetryableProviderError("rate limited")])
34
+ fallback = ScriptedProvider("gemini", [AIProviderResponse(content="fallback reply")])
35
  registry = ToolRegistry()
36
  orchestrator = AIOrchestrator(
37
  primary=primary,
 
56
  AIProviderResponse(content="primary retry reply"),
57
  ],
58
  )
59
+ fallback = ScriptedProvider("gemini", [AIProviderResponse(content="fallback")])
60
  orchestrator = AIOrchestrator(
61
  primary=primary,
62
  fallback=fallback,
 
142
  @pytest.mark.asyncio
143
  async def test_chat_falls_back_when_primary_rate_limited():
144
  primary = ScriptedProvider("groq", [RetryableProviderError("rate limited")])
145
+ fallback = ScriptedProvider("gemini", [AIProviderResponse(content="fallback reply")])
146
  orchestrator = AIOrchestrator(
147
  primary=primary,
148
  fallback=fallback,
 
162
  @pytest.mark.asyncio
163
  async def test_chat_returns_primary_on_success():
164
  primary = ScriptedProvider("groq", [AIProviderResponse(content="primary reply")])
165
+ fallback = ScriptedProvider("gemini", [AIProviderResponse(content="fallback")])
166
  orchestrator = AIOrchestrator(
167
  primary=primary,
168
  fallback=fallback,
 
188
  AIProviderResponse(content="primary retry reply"),
189
  ],
190
  )
191
+ fallback = ScriptedProvider("gemini", [AIProviderResponse(content="fallback")])
192
  orchestrator = AIOrchestrator(
193
  primary=primary,
194
  fallback=fallback,
tests/test_group_message_service.py CHANGED
@@ -36,8 +36,8 @@ def settings() -> Settings:
36
  jina_api_key="jina-secret",
37
  groq_api_key="groq-secret",
38
  groq_model="groq-tool-model",
39
- hf_token="hf-secret",
40
- hf_model="hf-tool-model",
41
  whatsapp_verify_token="verify-token",
42
  whatsapp_app_secret="app-secret",
43
  whatsapp_access_token="wa-token",
 
36
  jina_api_key="jina-secret",
37
  groq_api_key="groq-secret",
38
  groq_model="groq-tool-model",
39
+ gemini_api_key="gemini-secret",
40
+ gemini_model="gemini-tool-model",
41
  whatsapp_verify_token="verify-token",
42
  whatsapp_app_secret="app-secret",
43
  whatsapp_access_token="wa-token",