File size: 11,489 Bytes
380b8f7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45d150f
380b8f7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""
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

# Importar módulo de memória (lazy load para não atrasar startup)
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

# ============================================================================
# Configuration
# ============================================================================

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 for the chatbot
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."""

# ============================================================================
# Phoneme to Viseme Mapping
# ============================================================================

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


# ============================================================================
# FastAPI App
# ============================================================================

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] = []  # Contexto recuperado


@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."""
    
    # Validar API key
    if not OPENROUTER_API_KEY:
        raise HTTPException(
            status_code=500, 
            detail="OPENROUTER_API_KEY não configurada. Configure nas secrets do Space."
        )
    
    # Validar mensagem
    if not request.message or not request.message.strip():
        raise HTTPException(status_code=400, detail="Mensagem vazia")
    
    # =========================================================================
    # Buscar contexto na memória vetorial
    # =========================================================================
    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:  # Só usar se relevância > 30%
                    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}")
    
    # =========================================================================
    # Build messages
    # =========================================================================
    messages = [{"role": "system", "content": SYSTEM_PROMPT}]
    
    # Adicionar contexto de memória se houver
    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}"
        })
    
    # Histórico recente
    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})
    
    # =========================================================================
    # Call OpenRouter
    # =========================================================================
    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")
            
            # Extrair texto da resposta
            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)}")
    
    # Limpar texto
    bot_text = bot_text.strip()
    if not bot_text:
        bot_text = "Hmm, não entendi. Pode reformular?"
    
    # =========================================================================
    # Salvar na memória
    # =========================================================================
    if memory:
        try:
            memory.add_memory(request.message, bot_text)
        except Exception as e:
            print(f"Erro ao salvar memória: {e}")
    
    # =========================================================================
    # Generate TTS
    # =========================================================================
    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}")
    
    # Generate visemes
    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)