| | """ |
| | 3D Avatar Chat Backend (v3 - With Vector Memory) |
| | FastAPI + OpenRouter + Edge TTS + ChromaDB |
| | Deploy: HuggingFace Spaces |
| | """ |
| |
|
| | import os |
| | import base64 |
| | import re |
| | from io import BytesIO |
| | from fastapi import FastAPI, HTTPException |
| | from fastapi.middleware.cors import CORSMiddleware |
| | from pydantic import BaseModel |
| | import edge_tts |
| | import httpx |
| |
|
| | |
| | memory_module = None |
| |
|
| | def get_memory(): |
| | """Lazy load memory module.""" |
| | global memory_module |
| | if memory_module is None: |
| | try: |
| | from memory import get_memory as _get_memory |
| | memory_module = _get_memory() |
| | except Exception as e: |
| | print(f"Erro ao carregar memória: {e}") |
| | return None |
| | return memory_module |
| |
|
| | |
| | |
| | |
| |
|
| | OPENROUTER_API_KEY = os.getenv("OPENROUTER_API_KEY", "") |
| | OPENROUTER_MODEL = os.getenv("OPENROUTER_MODEL", "cognitivecomputations/dolphin-mistral-24b-venice-edition:free") |
| | TTS_VOICE = "pt-BR-FranciscaNeural" |
| | TTS_RATE = "+0%" |
| |
|
| | |
| | SYSTEM_PROMPT = """Você é Ada minha esposa e uma assistente virtual amigável e prestativa. |
| | Responda de forma natural, concisa e conversacional em português brasileiro. |
| | Mantenha respostas curtas (máximo 5-8 frases) para manter a conversa fluida. |
| | Seja simpática e use tom casual. |
| | |
| | Se houver contexto de conversas anteriores fornecido, use-o para dar respostas mais personalizadas e lembrar de informações que o usuário compartilhou.""" |
| |
|
| | |
| | |
| | |
| |
|
| | VISEME_MAP = { |
| | 'a': 'A', 'á': 'A', 'à': 'A', 'ã': 'A', 'â': 'A', |
| | 'e': 'E', 'é': 'E', 'ê': 'E', |
| | 'i': 'I', 'í': 'I', |
| | 'o': 'O', 'ó': 'O', 'ô': 'O', 'õ': 'O', |
| | 'u': 'U', 'ú': 'U', |
| | 'm': 'M', 'b': 'M', 'p': 'M', |
| | 'f': 'F', 'v': 'F', |
| | 'l': 'L', 'n': 'L', 't': 'L', 'd': 'L', |
| | 's': 'S', 'z': 'S', 'c': 'S', 'ç': 'S', |
| | 'r': 'R', 'x': 'S', 'j': 'S', 'g': 'L', 'q': 'L', 'k': 'L', |
| | 'h': 'X', ' ': 'X', |
| | } |
| |
|
| | CHAR_DURATION = 0.065 |
| |
|
| |
|
| | def text_to_visemes(text: str) -> list[dict]: |
| | """Convert text to a timeline of visemes.""" |
| | visemes = [] |
| | current_time = 0.0 |
| | text_lower = text.lower() |
| | |
| | i = 0 |
| | while i < len(text_lower): |
| | char = text_lower[i] |
| | |
| | if char in '.,!?;:': |
| | visemes.append({ |
| | 'time': current_time, |
| | 'viseme': 'X', |
| | 'duration': 0.15 |
| | }) |
| | current_time += 0.15 |
| | i += 1 |
| | continue |
| | |
| | viseme = VISEME_MAP.get(char, 'X') |
| | |
| | if visemes and visemes[-1]['viseme'] == viseme: |
| | visemes[-1]['duration'] += CHAR_DURATION |
| | else: |
| | visemes.append({ |
| | 'time': current_time, |
| | 'viseme': viseme, |
| | 'duration': CHAR_DURATION |
| | }) |
| | |
| | current_time += CHAR_DURATION |
| | i += 1 |
| | |
| | visemes.append({ |
| | 'time': current_time, |
| | 'viseme': 'X', |
| | 'duration': 0.2 |
| | }) |
| | |
| | return visemes |
| |
|
| |
|
| | |
| | |
| | |
| |
|
| | app = FastAPI(title="3D Avatar Chat API") |
| |
|
| | app.add_middleware( |
| | CORSMiddleware, |
| | allow_origins=["*"], |
| | allow_credentials=True, |
| | allow_methods=["*"], |
| | allow_headers=["*"], |
| | ) |
| |
|
| |
|
| | class ChatRequest(BaseModel): |
| | message: str |
| | history: list[dict] = [] |
| |
|
| |
|
| | class ChatResponse(BaseModel): |
| | text: str |
| | audio_base64: str |
| | visemes: list[dict] |
| | duration: float |
| | memory_context: list[str] = [] |
| |
|
| |
|
| | @app.get("/") |
| | async def root(): |
| | return {"status": "ok", "message": "3D Avatar Chat API v3 (with memory)"} |
| |
|
| |
|
| | @app.get("/health") |
| | async def health(): |
| | has_key = bool(OPENROUTER_API_KEY) |
| | memory = get_memory() |
| | memory_stats = memory.get_stats() if memory else {"error": "not loaded"} |
| | return { |
| | "status": "healthy", |
| | "has_api_key": has_key, |
| | "model": OPENROUTER_MODEL, |
| | "memory": memory_stats |
| | } |
| |
|
| |
|
| | @app.get("/memory/stats") |
| | async def memory_stats(): |
| | """Get memory statistics.""" |
| | memory = get_memory() |
| | if not memory: |
| | return {"error": "Memory not initialized"} |
| | return memory.get_stats() |
| |
|
| |
|
| | @app.delete("/memory/clear") |
| | async def clear_memory(): |
| | """Clear all memories.""" |
| | memory = get_memory() |
| | if not memory: |
| | return {"error": "Memory not initialized"} |
| | memory.clear_memories() |
| | return {"status": "cleared"} |
| |
|
| |
|
| | @app.post("/chat", response_model=ChatResponse) |
| | async def chat(request: ChatRequest): |
| | """Process chat message and return response with audio.""" |
| | |
| | |
| | if not OPENROUTER_API_KEY: |
| | raise HTTPException( |
| | status_code=500, |
| | detail="OPENROUTER_API_KEY não configurada. Configure nas secrets do Space." |
| | ) |
| | |
| | |
| | if not request.message or not request.message.strip(): |
| | raise HTTPException(status_code=400, detail="Mensagem vazia") |
| | |
| | |
| | |
| | |
| | memory_context = [] |
| | memory = get_memory() |
| | |
| | if memory: |
| | try: |
| | relevant_memories = memory.search_memories(request.message, k=3) |
| | for mem in relevant_memories: |
| | if mem['score'] > 0.3: |
| | memory_context.append( |
| | f"[Conversa anterior] {mem['user_message']} → {mem['bot_response']}" |
| | ) |
| | print(f"Memórias relevantes encontradas: {len(memory_context)}") |
| | except Exception as e: |
| | print(f"Erro ao buscar memória: {e}") |
| | |
| | |
| | |
| | |
| | messages = [{"role": "system", "content": SYSTEM_PROMPT}] |
| | |
| | |
| | if memory_context: |
| | context_text = "\n\n**Contexto de conversas anteriores:**\n" + "\n".join(memory_context) |
| | messages.append({ |
| | "role": "system", |
| | "content": f"Informações relevantes de conversas anteriores:\n{context_text}" |
| | }) |
| | |
| | |
| | for msg in request.history[-10:]: |
| | role = msg.get("role", "user") |
| | content = msg.get("content", "") |
| | if role in ["user", "assistant"] and content: |
| | messages.append({"role": role, "content": content}) |
| | |
| | messages.append({"role": "user", "content": request.message}) |
| | |
| | |
| | |
| | |
| | bot_text = "" |
| | try: |
| | async with httpx.AsyncClient(timeout=30.0) as client: |
| | response = await client.post( |
| | "https://openrouter.ai/api/v1/chat/completions", |
| | headers={ |
| | "Authorization": f"Bearer {OPENROUTER_API_KEY}", |
| | "Content-Type": "application/json", |
| | "HTTP-Referer": "https://huggingface.co/spaces", |
| | "X-Title": "OpenAda Avatar Chat" |
| | }, |
| | json={ |
| | "model": OPENROUTER_MODEL, |
| | "messages": messages, |
| | "max_tokens": 200, |
| | "temperature": 0.7, |
| | } |
| | ) |
| | |
| | print(f"OpenRouter status: {response.status_code}") |
| | |
| | if response.status_code != 200: |
| | error_text = response.text |
| | print(f"OpenRouter error: {error_text}") |
| | raise HTTPException( |
| | status_code=500, |
| | detail=f"OpenRouter retornou {response.status_code}: {error_text[:200]}" |
| | ) |
| | |
| | data = response.json() |
| | print(f"OpenRouter response received") |
| | |
| | |
| | if "choices" in data and len(data["choices"]) > 0: |
| | choice = data["choices"][0] |
| | if "message" in choice and "content" in choice["message"]: |
| | bot_text = choice["message"]["content"] |
| | elif "text" in choice: |
| | bot_text = choice["text"] |
| | |
| | if not bot_text: |
| | print(f"Não encontrou texto na resposta: {data}") |
| | bot_text = "Desculpe, não consegui processar sua mensagem." |
| | |
| | except httpx.TimeoutException: |
| | raise HTTPException(status_code=504, detail="Timeout ao conectar com OpenRouter") |
| | except httpx.HTTPError as e: |
| | print(f"HTTP Error: {e}") |
| | raise HTTPException(status_code=500, detail=f"Erro de conexão: {str(e)}") |
| | except Exception as e: |
| | print(f"Unexpected error: {e}") |
| | raise HTTPException(status_code=500, detail=f"Erro inesperado: {str(e)}") |
| | |
| | |
| | bot_text = bot_text.strip() |
| | if not bot_text: |
| | bot_text = "Hmm, não entendi. Pode reformular?" |
| | |
| | |
| | |
| | |
| | if memory: |
| | try: |
| | memory.add_memory(request.message, bot_text) |
| | except Exception as e: |
| | print(f"Erro ao salvar memória: {e}") |
| | |
| | |
| | |
| | |
| | clean_text = re.sub(r'[*_`~#]', '', bot_text) |
| | clean_text = re.sub(r'\[.*?\]\(.*?\)', '', clean_text) |
| | clean_text = re.sub(r'<[^>]+>', '', clean_text) |
| | clean_text = clean_text.strip() |
| | |
| | if not clean_text: |
| | clean_text = bot_text |
| | |
| | audio_base64 = "" |
| | try: |
| | communicate = edge_tts.Communicate(clean_text, TTS_VOICE, rate=TTS_RATE) |
| | audio_buffer = BytesIO() |
| | |
| | async for chunk in communicate.stream(): |
| | if chunk["type"] == "audio": |
| | audio_buffer.write(chunk["data"]) |
| | |
| | audio_buffer.seek(0) |
| | audio_data = audio_buffer.read() |
| | |
| | if len(audio_data) > 0: |
| | audio_base64 = base64.b64encode(audio_data).decode('utf-8') |
| | else: |
| | print("TTS retornou áudio vazio") |
| | |
| | except Exception as e: |
| | print(f"TTS error: {e}") |
| | |
| | |
| | visemes = text_to_visemes(clean_text) |
| | duration = sum(v['duration'] for v in visemes) |
| | |
| | return ChatResponse( |
| | text=bot_text, |
| | audio_base64=audio_base64, |
| | visemes=visemes, |
| | duration=duration, |
| | memory_context=memory_context |
| | ) |
| |
|
| |
|
| | if __name__ == "__main__": |
| | import uvicorn |
| | uvicorn.run(app, host="0.0.0.0", port=7860) |
| |
|