-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathserver.py
More file actions
465 lines (383 loc) · 17.4 KB
/
server.py
File metadata and controls
465 lines (383 loc) · 17.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
import sys
import os
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
import json
import uuid
import tempfile
import speech_recognition as sr
from pydub import AudioSegment
project_dir = os.path.dirname(os.path.abspath(__file__))
ffmpeg_path = os.path.join(project_dir, "ffmpeg.exe")
ffprobe_path = os.path.join(project_dir, "ffprobe.exe")
if os.path.exists(ffmpeg_path):
AudioSegment.converter = ffmpeg_path
print(f"[SETUP] FFmpeg configurado localmente: {ffmpeg_path}")
if os.path.exists(ffprobe_path):
AudioSegment.ffprobe = ffprobe_path
print(f"[SETUP] FFprobe configurado localmente: {ffprobe_path}")
# -------------------------------------------
import asyncio
import requests
from urllib.parse import quote_plus
from flask import Flask, request, jsonify
from flask_cors import CORS
from google.genai import types
from google.adk.runners import Runner
from google.adk.sessions import DatabaseSessionService, InMemorySessionService
from nutrimind_agent.root_agent import root_agent
from nutrimind_agent.specialists import image_analysis_agent
app = Flask(__name__)
CORS(app)
from dotenv import load_dotenv
load_dotenv()
DB_USER = os.getenv("DB_USER")
DB_PASS = quote_plus(os.getenv("DB_PASSWORD", ""))
DB_HOST = os.getenv("DB_HOST", "localhost")
DB_PORT = os.getenv("DB_PORT", "5432")
DB_NAME = os.getenv("DB_NAME", "nutrimind_sessions")
BACKEND_API_URL = os.getenv("BACKEND_API_URL", "http://localhost:8081")
db_url = f"postgresql+psycopg2://{DB_USER}:{DB_PASS}@{DB_HOST}:{DB_PORT}/{DB_NAME}"
# conecta no postgresql para armazenar sessões
session_service = DatabaseSessionService(db_url=db_url)
# runner é o motor do agente - o componente central que "liga os fios" e faz tudo funcionar
runner = Runner(
# inicializa dando as três "peças" principais:
agent=root_agent,
session_service=session_service,
app_name="nutrimind_app"
)
APP_NAME = "nutrimind_app"
# configuração do agente de análise de imagem
image_session_service = InMemorySessionService()
IMAGE_APP_NAME = "nutrimind_image_analysis"
image_runner = Runner(
agent=image_analysis_agent,
session_service=image_session_service,
app_name=IMAGE_APP_NAME
)
def fetch_session_messages_from_backend(session_id: str, limit: int = 20, authorization: str | None = None):
"""
busca as últimas mensagens da sessão no backend Java.
"""
try:
url = f"{BACKEND_API_URL}/api/chat/sessions/{session_id}/messages?size={limit}&page=0"
headers = {"Accept": "application/json"}
if authorization:
headers["Authorization"] = authorization
resp = requests.get(url, headers=headers, timeout=6)
if resp.status_code == 200:
return resp.json()
if resp.status_code == 404:
print("[INFO] Nenhum histórico encontrado para esta sessão (primeiro acesso).")
return []
else:
print(f"[WARN] Erro ao buscar mensagens do backend: {resp.status_code} - {resp.text}")
return []
except requests.RequestException as e:
print(f"[ERROR] Exceção ao buscar mensagens do backend: {e}")
return []
def transcribe_audio(audio_file):
"""
Transcreve o arquivo de áudio recebido para texto usando SpeechRecognition e Google Web Speech API.
Converte WebM/Ogg para WAV antes de processar usando pydub.
"""
recognizer = sr.Recognizer()
text = ""
temp_webm_path = None
temp_wav_path = None
try:
with tempfile.NamedTemporaryFile(delete=False, suffix=".webm") as temp_audio:
audio_file.save(temp_audio.name)
temp_webm_path = temp_audio.name
temp_wav_path = temp_webm_path.replace(".webm", ".wav")
print(f"[INFO] Convertendo áudio de {temp_webm_path} para WAV...")
audio = AudioSegment.from_file(temp_webm_path)
audio = audio.set_channels(1).set_frame_rate(16000)
audio.export(temp_wav_path, format="wav")
with sr.AudioFile(temp_wav_path) as source:
print("[INFO] Lendo arquivo WAV para reconhecimento...")
audio_data = recognizer.record(source)
print("[INFO] Enviando áudio para reconhecimento (Google Web Speech)...")
text = recognizer.recognize_google(audio_data, language="pt-BR")
except sr.UnknownValueError:
print("[WARN] Áudio não compreendido pela API de reconhecimento.")
text = "(Áudio ininteligível)"
except sr.RequestError as e:
print(f"[ERROR] Erro na API de reconhecimento de fala: {e}")
text = "(Erro no serviço de transcrição)"
except Exception as e:
print(f"[ERROR] Erro genérico na transcrição/conversão: {e}")
if "FileNotFound" in str(e) or "WinError 2" in str(e):
text = "(Erro: FFmpeg não encontrado. Verifique se ffmpeg.exe está na pasta do projeto)"
else:
text = f"(Erro ao processar áudio: {str(e)})"
finally:
if temp_webm_path and os.path.exists(temp_webm_path):
try: os.remove(temp_webm_path)
except: pass
if temp_wav_path and os.path.exists(temp_wav_path):
try: os.remove(temp_wav_path)
except: pass
return text
# --- Rota da API ---
@app.route('/api/chat', methods=['POST'])
def chat_handler():
"""
o frontend chama essa rota sempre que o usuário envia uma mensagem
"""
user_message = ""
session_id = ""
user_id = ""
authorization = ""
user_name = ""
image_file = None
if request.is_json:
# Caso 1: Requisição apenas JSON (texto puro)
data = request.get_json()
user_message = data.get('userMessage')
session_id = data.get('sessionId')
user_id = data.get('userId')
authorization = data.get('authorization')
user_name = data.get('userName')
else:
# Caso 2: Multipart/form-data (pode ter imagem ou áudio)
user_message = request.form.get('userMessage')
session_id = request.form.get('sessionId')
user_id = request.form.get('userId')
authorization = request.headers.get('Authorization') or request.form.get('authorization')
user_name = request.form.get('userName')
# Pega a imagem se existir
if 'image' in request.files:
image_file = request.files['image']
# Pega o áudio se existir
if 'audio' in request.files:
print("[INFO] Arquivo de áudio detectado. Iniciando transcrição...")
audio_file = request.files['audio']
transcribed_text = transcribe_audio(audio_file)
print(f"[INFO] Áudio transcrito: '{transcribed_text}'")
if not user_message or user_message.strip() == "":
user_message = transcribed_text
else:
user_message = f"{user_message} (Transcrição do áudio: {transcribed_text})"
# Validação básica
if not session_id or not user_id:
return jsonify({'error': 'sessionId e userId são obrigatórios.'}), 400
try:
response_text = asyncio.run(
handle_conversation_turn(user_message, user_id, session_id, authorization, user_name, image_file=image_file)
)
return jsonify({'reply': response_text}) # envia resposta final do agente para o frontend json
except Exception as e:
import traceback
print(f"Ocorreu um erro crítico durante a execução do agente: {e}")
traceback.print_exc()
return jsonify({'error': str(e)}), 500
async def handle_conversation_turn(user_message: str, user_id: str, session_id: str, authorization: str, user_name: str, image_file=None) -> str | None:
# busca ou cria a sessão (o histórico do chat)
session = await session_service.get_session(
app_name=APP_NAME, user_id=user_id, session_id=session_id
)
if session is None:
session = await session_service.create_session(
app_name=APP_NAME,
user_id=user_id,
session_id=session_id,
state={"authorization": authorization, "user_id": user_id}
)
# busca histórico da sessão
HISTORY_LIMIT = 6
raw_backend_response = fetch_session_messages_from_backend(session_id=session_id, limit=HISTORY_LIMIT, authorization=authorization)
# se a resposta for um dict e contiver a lista de mensagens
if isinstance(raw_backend_response, dict) and "content" in raw_backend_response:
backend_messages = raw_backend_response["content"]
else:
backend_messages = raw_backend_response # se já for uma lista
# formata para o prompt
def format_message_for_prompt(msg):
# senderType pode ser "USER" ou "ASSISTANT"
sender = str(msg.get('senderType', 'USER')).upper()
label = "Usuário" if "USER" in sender else "Assistente"
content = msg.get('content', '')
return f"{label}: {content}"
# formata todo o histórico e texto
conversation_history = "\n".join(format_message_for_prompt(m) for m in backend_messages[-HISTORY_LIMIT:]) if backend_messages else "(sem histórico)"
# montar prompt para o agente
prompt_for_agent = f"""
- User ID: {user_id}
- User Name: {user_name}
- Session ID: {session_id}
- Authorization: {authorization}
Contexto (histórico das últimas {HISTORY_LIMIT} mensagens):
{conversation_history}
Nova mensagem do usuário:
"{user_message}"
"""
# monta as PARTES da mensagem (Texto + Imagem Opcional)
message_parts = []
# Adiciona a imagem primeiro (se houver)
if image_file:
image_bytes = image_file.read()
mime_type = image_file.mimetype
# cria o blob para o ADK
image_part = types.Part(
inline_data=types.Blob(mime_type=mime_type, data=image_bytes)
)
message_parts.append(image_part)
print(f"[INFO] Imagem anexada à mensagem. Tipo: {mime_type}")
# adiciona o texto
message_parts.append(types.Part(text=prompt_for_agent))
# esse texto é passado ao root_agent como tipo role=user
content = types.Content(role='user', parts=message_parts)
final_response_text = "Desculpe, não consegui gerar uma resposta no momento."
# envia a nova mensagem do usuário para o runner
async for event in runner.run_async(
user_id=user_id,
session_id=session_id,
new_message=content
):
try:
print(f"Evento recebido: Autor='{event.author}', Conteúdo='{getattr(event.content, 'parts', None)}'")
except Exception:
print("Evento recebido (impossível printar conteúdo)")
if event.content and event.content.parts:
if hasattr(event.content.parts[0], "text"):
final_response_text = event.content.parts[0].text
return final_response_text
async def process_image_analysis(prompt_parts, auth_token, user_id):
"""Processa análise de imagem usando o runner do ADK com sessão descartável"""
final_response_text = "Desculpe, não consegui analisar a imagem."
# id único para isolamento total
temp_session_id = "temp_session_" + str(uuid.uuid4())
target_user_id = user_id if user_id else "temp_user_" + str(uuid.uuid4())
try:
# cria sessão
await image_session_service.create_session(
app_name=IMAGE_APP_NAME,
user_id=target_user_id,
session_id=temp_session_id,
state={
"authorization": auth_token,
"user_id": target_user_id
}
)
except Exception as e:
print(f"[ERROR] Erro ao criar sessão: {e}")
return final_response_text
content = types.Content(role='user', parts=prompt_parts)
try:
async for event in image_runner.run_async(
user_id=target_user_id,
session_id=temp_session_id,
new_message=content
):
if event.content and event.content.parts:
if hasattr(event.content.parts[0], "text"):
final_response_text = event.content.parts[0].text
except Exception as e:
error_msg = str(e)
print(f"[ERROR] Erro no runner de imagem: {error_msg}")
final_response_text = json.dumps({
"description": "Erro técnico na análise.",
"analysis": "Tive um problema ao processar sua imagem. Tente novamente.",
"totalCalories": 0,
"foods": []
})
finally:
try:
# limpeza da sessão na memória do InMemorySessionService
# duas abordagens para garantir remoção
if hasattr(image_session_service, 'delete_session'):
try:
await image_session_service.delete_session(session_id=temp_session_id)
except TypeError:
# Fallback se a assinatura for diferente
await image_session_service.delete_session(temp_session_id)
print(f"[INFO] Sessão {temp_session_id} removida via delete_session.")
# segunda abordagem: remoção direta do dicionário interno
elif hasattr(image_session_service, '_sessions'):
sessions_dict = image_session_service._sessions
deleted = False
# tenta remover pela chave composta (app_name, session_id)
key_compound = (IMAGE_APP_NAME, temp_session_id)
if key_compound in sessions_dict:
del sessions_dict[key_compound]
deleted = True
# tenta remover pela session_id simples
elif temp_session_id in sessions_dict:
del sessions_dict[temp_session_id]
deleted = True
# se não achou direto, varre o dicionário (fallback seguro)
if not deleted:
keys_to_remove = [k for k in sessions_dict.keys() if temp_session_id in str(k)]
for k in keys_to_remove:
del sessions_dict[k]
deleted = True
if deleted:
print(f"[INFO] Memória limpa: Sessão {temp_session_id} removida manualmente.")
else:
print(f"[WARN] Sessão {temp_session_id} não encontrada para limpeza.")
except Exception as e:
print(f"[WARN] Não foi possível limpar a sessão da memória: {e}")
return final_response_text
@app.route('/api/analyze-image', methods=['POST'])
def analyze_image_handler():
"""
O backend chama esta rota para analisar uma imagem.
Recebe multipart/form-data com 'image', 'prompt' e 'image_url'.
"""
print("[INFO] Rota /api/analyze-image recebida.")
try:
if 'image' not in request.files:
return jsonify({'error': "Campo 'image' é obrigatório."}), 400
image_file = request.files['image']
prompt_text = request.form.get('prompt', "")
auth_token = request.headers.get('Authorization') or request.form.get('authorization')
user_id = request.form.get('userId')
if not auth_token:
print("[WARN] Token não fornecido para análise de imagem. Perfil não será carregado.")
# não paramos o processo, mas a ferramenta de perfil vai falhar
if not prompt_text or not prompt_text.strip():
print("[INFO] Prompt vazio recebido. Aplicando instrução padrão.")
prompt_text = "Por favor, analise esta imagem, identifique os alimentos presentes e calcule as calorias totais."
image_url = request.form.get('image_url', "caminho/desconhecido")
print(f"[INFO] Processando imagem: {image_url}")
print(f"[INFO] Prompt do usuário: '{prompt_text}'")
image_bytes = image_file.read()
mime_type = image_file.mimetype
if not mime_type.startswith("image/"):
return jsonify({'error': "Arquivo inválido. Envie uma imagem."}), 400
image_artifact = types.Part(
inline_data=types.Blob(mime_type=mime_type, data=image_bytes)
)
prompt_parts = [
image_artifact,
types.Part(text=f"Mensagem do usuário sobre a imagem: {prompt_text}")
]
response_text = asyncio.run(process_image_analysis(prompt_parts, auth_token, user_id))
raw_text = response_text
if "```json" in raw_text:
import re
match = re.search(r'```json\s*({.*?})\s*```', raw_text, re.DOTALL)
if match:
raw_text = match.group(1)
elif "```" in raw_text:
raw_text = raw_text.replace("```", "")
try:
analysis_data = json.loads(raw_text)
except json.JSONDecodeError:
print(f"[WARN] Resposta não é JSON válido, usando como texto: {raw_text}")
analysis_data = {
"description": raw_text,
"analysis": raw_text,
"totalCalories": 0,
"foods": []
}
print(f"[INFO] Análise concluída: {analysis_data}")
return jsonify(analysis_data)
except Exception as e:
print(f"[ERROR] Erro crítico: {e}")
import traceback
traceback.print_exc()
return jsonify({'error': str(e)}), 500
if __name__ == '__main__':
app.run(host='0.0.0.0', port=5000, debug=True)