File size: 13,387 Bytes
c726703
5451335
7e493b0
c726703
 
 
7e493b0
5043fdc
13b8931
c726703
5451335
 
 
 
 
a66c44d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5451335
4f0f73a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
08b0f79
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4f0f73a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
08b0f79
 
4f0f73a
 
 
5451335
c726703
 
 
 
 
7e493b0
13b8931
c726703
7e493b0
 
 
51d338a
 
7e493b0
51d338a
 
7e493b0
 
51d338a
7e493b0
c726703
 
 
 
 
 
7e493b0
 
 
 
 
13b8931
 
 
 
5043fdc
 
 
 
 
 
 
51d338a
 
 
 
 
 
 
 
 
c726703
513e5c0
 
 
 
 
 
 
c726703
c7c02b0
da5ecc8
 
c7c02b0
 
 
 
 
 
 
 
7e493b0
c726703
c7c02b0
 
 
 
 
ce9dd03
c7c02b0
 
 
 
7e493b0
c7c02b0
 
b9d4a6a
 
 
 
 
 
 
 
c7c02b0
513e5c0
c7c02b0
 
 
 
7e493b0
 
c7c02b0
 
 
a66c44d
 
 
 
 
c7c02b0
 
 
 
9fdf3e0
 
c726703
c7c02b0
7e493b0
 
c726703
 
5451335
 
4f0f73a
 
5451335
c7c02b0
 
13b8931
7e493b0
 
 
 
 
 
 
 
c7c02b0
c726703
 
 
 
 
 
 
 
c7c02b0
c726703
b9d4a6a
 
 
 
 
 
 
 
c726703
 
 
 
 
 
 
 
 
 
 
 
c7c02b0
c726703
b9d4a6a
 
 
 
 
 
 
 
c726703
 
 
 
 
 
 
 
 
 
 
 
 
 
ce9dd03
 
 
 
 
c726703
 
 
 
 
 
 
 
 
 
 
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
import requests
import re
import uuid
from config import Config
from memory import MemoryManager
from catalog import CatalogService
from learning import LearningStore
from profiles import ProfileStore, detetar_emocao


_SAUDACAO_INICIAL = re.compile(
    r"^\s*(ol[aá]|oi|bom dia|boa tarde|boa noite)\b[^,;:.!?…\n]*[,;:.!?…]?\s*",
    re.IGNORECASE,
)

# Chaves nunca no log (o Space imprime tracebacks; URLs trazem ?key=...).
_PADROES_SEGREDO = [
    (re.compile(r"key=[^&\s'\"]+"), "key=***"),
    (re.compile(r"Bearer\s+\S+"), "Bearer ***"),
    (re.compile(r"\b(sk-[A-Za-z0-9\-_]{8,}|gsk_[A-Za-z0-9\-_]{8,})"), "***"),
]


def limpar_segredo(texto: str) -> str:
    """Redige segredos (chaves em URLs, Bearers) antes de ir para o log."""
    limpo = texto or ""
    try:
        for rx, rep in _PADROES_SEGREDO:
            limpo = rx.sub(rep, limpo)
    except Exception:
        return "***"
    return limpo


def tirar_saudacao(resposta: str, nome: str = "") -> str:
    """Corta saudação inicial repetida ('Olá Isaac! ...', 'Isaac! ...').

    O nome próprio só é cortado quando é o cliente conhecido.
    Nunca devolve vazio.
    """
    texto = _SAUDACAO_INICIAL.sub("", resposta or "", count=1).lstrip()
    nome_limpo = (nome or "").strip()
    if nome_limpo:
        variantes = {nome_limpo, nome_limpo.split()[0]}
        for variante in sorted(variantes, key=len, reverse=True):
            if len(variante) >= 3:
                texto = re.sub(
                    r"^\s*" + re.escape(variante) + r"\s*[!,]\s*",
                    "", texto, count=1, flags=re.IGNORECASE,
                ).lstrip()
    return texto if texto else (resposta or "").strip()


# Marca e assistente: qualquer par parecido vira a forma correcta,
# menos a forma correcta (guardas). Cobre variantes ainda não vistas.
_MARCA = re.compile(r"\bNeg[a-z]{1,6}\s+[Pp][a-z]{2,6}\b", re.IGNORECASE)
_BOTNOME = re.compile(r"\bPlen[a-z]{4,7}\b", re.IGNORECASE)


def _fix_marca(m):
    a, b = m.group(0).split()
    if a.lower() == "negra" and b.lower() == "plena":
        return m.group(0)
    return "Negra Plena"


def _fix_botnome(m):
    w = m.group(0)
    if w.lower() == "plenitude":
        return w
    return "Plenitude"


# Outras corrupções observadas no Llama-8B -> forma correcta.
# Lista fechada (só o que já apareceu nos logs); ordem importa.
_CORRECOES = [
    (r"\bservicios\b", "serviços"),
    (r"\bpasteleira\b|\bpasteleria\b", "pastelaria"),
    (r"\bmás\b", "mais"),
    (r"\batenci[oó]n\b", "atenção"),
    (r"\bsimplicit[eé]\b", "simplicidade"),
    (r"\bangolanenses\b", "angolanos"),
    (r"\bangolanense\b", "angolana"),
    (r"\bEnroladinh[oó]s\b", "enroladinhos"),
    (r"\bchourisos\b", "chouriços"),
    (r"\bchouriso\b", "chouriço"),
    (r"\bBolhas\s+de\s+Berl(?:im|in)\b", "Bolas de Berlim"),
    (r"\bRiss[oó]i\b", "rissóis"),
    (r"\bsaudos\b", "salgados"),
    (r"\bBerlin\b", "Berlim"),
]
_CORRECOES_RX = [(re.compile(pat, re.IGNORECASE), rep) for pat, rep in _CORRECOES]


def sanitizar(resposta: str) -> str:
    """Corrige corrupções conhecidas do 8B (nomes, idioma, produtos)."""
    texto = resposta or ""
    texto = _MARCA.sub(_fix_marca, texto)
    texto = _BOTNOME.sub(_fix_botnome, texto)
    for rx, rep in _CORRECOES_RX:
        texto = rx.sub(rep, texto)
    return texto.strip() or (resposta or "").strip()


class AIEngine:
    def __init__(self):
        self.memory = MemoryManager(max_history=8)
        self.catalog = CatalogService()
        self.learning = LearningStore()
        self.profiles = ProfileStore()
    def generate_reply(self, numero_crm: str, texto_cliente: str, nome_cliente: str) -> str:
        resposta, _ = self._responder(numero_crm, texto_cliente, nome_cliente, registar=False)
        return resposta

    def responder(self, numero_crm: str, texto_cliente: str, nome_cliente: str,
                  fora_expediente: bool = False) -> tuple:
        """Igual a generate_reply mas regista a troca no aprendizado e devolve o id."""
        return self._responder(numero_crm, texto_cliente, nome_cliente,
                               registar=True, fora_expediente=fora_expediente)

    def _responder(self, numero_crm: str, texto_cliente: str, nome_cliente: str,
                   registar: bool, fora_expediente: bool = False) -> tuple:
        mensagem_id = uuid.uuid4().hex[:12]
        historico = self.memory.get_context(numero_crm)
        dados_negocio = self.catalog.get_context()
        system = Config.SYSTEM_PROMPT
        if dados_negocio:
            system += f"\n\nDADOS ATUAIS DO NEGOCIO:\n{dados_negocio}"

        user_text = texto_cliente.strip() or "(o cliente enviou uma mensagem sem texto)"
        exemplos = self.learning.format_examples(user_text)
        if exemplos:
            system += f"\n\n{exemplos}"

        perfil_txt = self.profiles.linha_contexto(numero_crm, nome_cliente)
        if perfil_txt:
            system += f"\n\n{perfil_txt}"

        emocao_agora = detetar_emocao(user_text)
        if emocao_agora != "neutro":
            system += (
                f"\n\nEMOÇÃO AGORA: o cliente parece {emocao_agora} nesta mensagem. "
                f"Age segundo o bloco EMOÇÃO do prompt."
            )

        if fora_expediente:
            system += (
                "\n\nHORARIO: Agora está FORA do horário comercial "
                "(humanos: dias úteis 9h–18h Luanda). Atende na mesma com clareza; "
                "se pedirem atendimento humano, diz que a equipa responde a partir "
                "das 9h e que deixas o pedido registado. Nunca prometas contacto "
                "humano imediato fora de horas."
            )

        user_text = texto_cliente.strip() or "(o cliente enviou uma mensagem sem texto)"
        atual = f"O cliente {nome_cliente} ({numero_crm}) diz: {user_text}"
        # Turnos reais (melhor coerência que histórico colado num turno só).
        mensagens = [{"role": "system", "content": system}]
        mensagens += self.memory.get_turns(numero_crm)
        mensagens.append({"role": "user", "content": atual})
        # Versão corrida só para o caminho Gemini (1 turno de utilizador).
        texto_corrido = f"{historico}\n\n{atual}" if historico else atual

        # Nivel de Hierarquia (Fallback)
        # 1. OpenRouter -> 2. Groq -> 3. Cerebras -> 4. DeepSeek -> 5. Mistral -> 6. Gemini -> 7. Llama Local (ZeroGPU)
        ordem_provedores = ["openrouter", "groq", "cerebras", "deepseek", "mistral", "gemini", "local_llama"]
        
        forced_provider = Config.AI_PROVIDER
        if forced_provider and forced_provider != "auto":
            # Se forcares um especifico, tenta-o primeiro, depois os outros
            ordem_provedores = [forced_provider] + [p for p in ordem_provedores if p != forced_provider]

        resposta = None
        erro_final = None
        provedor_ok = ""

        for provider in ordem_provedores:
            try:
                # Saltar provedores se a respectiva chave de seguranca nao existir
                if provider == "openrouter" and not Config.OPENROUTER_API_KEY: continue
                if provider == "groq" and not Config.GROQ_API_KEY: continue
                if provider == "cerebras" and not Config.CEREBRAS_API_KEY: continue
                if provider == "deepseek" and not Config.DEEPSEEK_API_KEY: continue
                if provider == "mistral" and not Config.MISTRAL_API_KEY: continue
                if provider == "gemini" and not Config.GEMINI_API_KEY: continue
                
                print(f"[hierarchy] A tentar gerar resposta usando: {provider}...", flush=True)
                
                if provider == "local_llama":
                    from local_llm import em_pausa, generate_local, registar_quota
                    if em_pausa():
                        raise RuntimeError("local em pausa por quota (reset pendente)")
                    try:
                        resposta = generate_local(mensagens)
                    except Exception as exc_local:
                        registar_quota(str(exc_local))
                        raise
                elif provider == "gemini":
                    resposta = self._call_gemini(system, texto_corrido)
                else:
                    resposta = self._call_openai_compatible(provider, mensagens)
                
                if resposta:
                    print(f"[hierarchy] Sucesso com {provider}!", flush=True)
                    provedor_ok = provider
                    break

            except Exception as exc:
                detalhe = limpar_segredo(str(exc))
                print(f"[hierarchy] Falha no provedor {provider}: {detalhe}", flush=True)
                if any(s in str(exc) for s in ("401", "403", "Unauthorized", "API key")):
                    print(f"[hierarchy] DICA: {provider} rejeitou a chave — "
                          f"confira o Secret correspondente no Space.", flush=True)
                erro_final = exc
                continue # Se falhou, a hierarquia ignora o erro e avanca para o proximo!

        if not resposta:
            print(f"[hierarchy] TODOS os provedores falharam. "
                  f"Erro final: {limpar_segredo(str(erro_final))}", flush=True)
            return (
                "Peco desculpa, estou com uma instabilidade tecnica no sistema. "
                "Pode repetir a sua mensagem daqui a instantes?",
                mensagem_id,
            )

        era_conhecido = int(self.profiles.obter(numero_crm).get("n_trocas", 0) or 0) > 0
        if era_conhecido:
            resposta = tirar_saudacao(resposta, nome_cliente)
        resposta = sanitizar(resposta)

        self.memory.add_message(numero_crm, "user", user_text)
        self.memory.add_message(numero_crm, "model", resposta)
        self.profiles.registar(numero_crm, nome_cliente, user_text)
        if registar:
            modelo = Config.LOCAL_MODEL_ID if provedor_ok == "local_llama" else Config.get_model(provedor_ok)
            self.learning.log_exchange(
                numero_crm, user_text, resposta,
                provedor=provedor_ok, modelo=modelo,
                mensagem_id=mensagem_id,
            )
        return resposta, mensagem_id

    def _call_openai_compatible(self, provider: str, messages: list) -> str:
        url, headers = self._provider_endpoint(provider)
        payload = {
            "model": Config.get_model(provider),
            "messages": messages,
            "temperature": 0.4,
            "max_tokens": 450,
        }
        response = requests.post(url, headers=headers, json=payload, timeout=25)
        response.raise_for_status()
        try:
            texto = response.json()["choices"][0]["message"].get("content") or ""
        except (KeyError, IndexError, TypeError, AttributeError):
            texto = ""
        texto = texto.strip()
        if not texto:
            raise ValueError(f"resposta vazia de {provider}")
        return texto

    def _call_gemini(self, system: str, user_content: str) -> str:
        model = Config.get_model("gemini")
        url = (
            f"https://generativelanguage.googleapis.com/v1beta/models/"
            f"{model}:generateContent?key={Config.GEMINI_API_KEY}"
        )
        payload = {
            "systemInstruction": {"parts": [{"text": system}]},
            "contents": [{"role": "user", "parts": [{"text": user_content}]}],
            "generationConfig": {"temperature": 0.4, "maxOutputTokens": 450},
        }
        response = requests.post(url, json=payload, timeout=25)
        response.raise_for_status()
        try:
            texto = response.json()["candidates"][0]["content"]["parts"][0]["text"] or ""
        except (KeyError, IndexError, TypeError, AttributeError):
            texto = ""
        texto = texto.strip()
        if not texto:
            raise ValueError("resposta vazia de gemini")
        return texto

    def _provider_endpoint(self, provider: str):
        if provider == "openrouter":
            return "https://openrouter.ai/api/v1/chat/completions", {
                "Authorization": f"Bearer {Config.OPENROUTER_API_KEY}",
                "Content-Type": "application/json",
                "HTTP-Referer": "https://negraplena.ao",
                "X-Title": "Negra Plena Plenitude",
            }
        if provider == "groq":
            return "https://api.groq.com/openai/v1/chat/completions", {
                "Authorization": f"Bearer {Config.GROQ_API_KEY}",
                "Content-Type": "application/json",
            }
        if provider == "cerebras":
            return "https://api.cerebras.ai/v1/chat/completions", {
                "Authorization": f"Bearer {Config.CEREBRAS_API_KEY}",
                "Content-Type": "application/json",
            }
        if provider == "deepseek":
            return "https://api.deepseek.com/chat/completions", {
                "Authorization": f"Bearer {Config.DEEPSEEK_API_KEY}",
                "Content-Type": "application/json",
            }
        if provider == "mistral":
            return "https://api.mistral.ai/v1/chat/completions", {
                "Authorization": f"Bearer {Config.MISTRAL_API_KEY}",
                "Content-Type": "application/json",
            }
        raise ValueError(f"Provedor nao suportado: {provider}")