Cachinde commited on
Commit
cfbe056
·
2 Parent(s): d057a3ce39c34b

merge: Integradas alteracoes remotas do HF (learning v1, warmup e docs)

Browse files
Files changed (13) hide show
  1. .dockerignore +6 -0
  2. .env.example +21 -0
  3. .gitattributes +35 -0
  4. .gitignore +13 -0
  5. Dockerfile +27 -0
  6. README.md +50 -1
  7. ai_engine.py +36 -9
  8. app.py +78 -53
  9. config.py +25 -0
  10. learning.py +182 -0
  11. local_llm.py +96 -48
  12. main.py +14 -3
  13. models.py +15 -1
.dockerignore ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ venv
2
+ __pycache__
3
+ .git
4
+ .env
5
+ *.pyc
6
+ .pytest_cache
.env.example ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Secrets do Space Plenitude
2
+ API_AUTH_TOKEN=
3
+ AI_PROVIDER=auto
4
+ AI_MODEL=
5
+
6
+ OPENROUTER_API_KEY=
7
+ GROQ_API_KEY=
8
+ DEEPSEEK_API_KEY=
9
+ MISTRAL_API_KEY=
10
+ GEMINI_API_KEY=
11
+
12
+ # Llama local (ZeroGPU) — opcional; sem isto usa só as APIs externas
13
+ # Modelo gated (ex. Meta Llama) exige licença aceite + HF_TOKEN com acesso
14
+ LOCAL_MODEL_ID=NousResearch/Meta-Llama-3-8B-Instruct
15
+ LORA_ADAPTER_PATH=
16
+ HF_TOKEN=
17
+
18
+ CATALOG_API_URL=
19
+ PRICES_API_URL=
20
+ HOURS_API_URL=
21
+ BUSINESS_API_TOKEN=
.gitattributes ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ *.7z filter=lfs diff=lfs merge=lfs -text
2
+ *.arrow filter=lfs diff=lfs merge=lfs -text
3
+ *.bin filter=lfs diff=lfs merge=lfs -text
4
+ *.bz2 filter=lfs diff=lfs merge=lfs -text
5
+ *.ckpt filter=lfs diff=lfs merge=lfs -text
6
+ *.ftz filter=lfs diff=lfs merge=lfs -text
7
+ *.gz filter=lfs diff=lfs merge=lfs -text
8
+ *.h5 filter=lfs diff=lfs merge=lfs -text
9
+ *.joblib filter=lfs diff=lfs merge=lfs -text
10
+ *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
+ *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
+ *.model filter=lfs diff=lfs merge=lfs -text
13
+ *.msgpack filter=lfs diff=lfs merge=lfs -text
14
+ *.npy filter=lfs diff=lfs merge=lfs -text
15
+ *.npz filter=lfs diff=lfs merge=lfs -text
16
+ *.onnx filter=lfs diff=lfs merge=lfs -text
17
+ *.ot filter=lfs diff=lfs merge=lfs -text
18
+ *.parquet filter=lfs diff=lfs merge=lfs -text
19
+ *.pb filter=lfs diff=lfs merge=lfs -text
20
+ *.pickle filter=lfs diff=lfs merge=lfs -text
21
+ *.pkl filter=lfs diff=lfs merge=lfs -text
22
+ *.pt filter=lfs diff=lfs merge=lfs -text
23
+ *.pth filter=lfs diff=lfs merge=lfs -text
24
+ *.rar filter=lfs diff=lfs merge=lfs -text
25
+ *.safetensors filter=lfs diff=lfs merge=lfs -text
26
+ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
+ *.tar.* filter=lfs diff=lfs merge=lfs -text
28
+ *.tar filter=lfs diff=lfs merge=lfs -text
29
+ *.tflite filter=lfs diff=lfs merge=lfs -text
30
+ *.tgz filter=lfs diff=lfs merge=lfs -text
31
+ *.wasm filter=lfs diff=lfs merge=lfs -text
32
+ *.xz filter=lfs diff=lfs merge=lfs -text
33
+ *.zip filter=lfs diff=lfs merge=lfs -text
34
+ *.zst filter=lfs diff=lfs merge=lfs -text
35
+ *tfevents* filter=lfs diff=lfs merge=lfs -text
.gitignore ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ venv/
2
+ __pycache__/
3
+ *.py[cod]
4
+ .env
5
+ .env.local
6
+ data/
7
+ conversas.jsonl
8
+ .pytest_cache/
9
+ .mypy_cache/
10
+ *.egg-info/
11
+ .DS_Store
12
+ .idea/
13
+ .vscode/
Dockerfile ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ RUN useradd -m -u 1000 user
4
+ WORKDIR /app
5
+
6
+ COPY --chown=user ./requirements.txt requirements.txt
7
+ RUN pip install --no-cache-dir --upgrade -r requirements.txt
8
+
9
+ COPY --chown=user . /app
10
+
11
+ # Pesos do Llama local: NÃO embutidos na imagem (8B em bf16 ≈ 16GB).
12
+ # São baixados no arranque para o cache do disco (ver warmup em app.py)
13
+ # e reutilizados nos arranques seguintes. Para pré-baixar na build
14
+ # (opt-in, só com disco e licença garantidos), descommentar:
15
+ # ARG LOCAL_MODEL_ID=NousResearch/Meta-Llama-3-8B-Instruct
16
+ # ARG HF_TOKEN=""
17
+ # RUN HF_TOKEN=$HF_TOKEN python -c "from transformers import AutoTokenizer, AutoModelForCausalLM; AutoTokenizer.from_pretrained('$LOCAL_MODEL_ID'); AutoModelForCausalLM.from_pretrained('$LOCAL_MODEL_ID', torch_dtype='auto')"
18
+ # Nota: repo gated (ex. Meta Llama) exige licença aceite + HF_TOKEN com acesso.
19
+
20
+ USER user
21
+ ENV HOME=/home/user \
22
+ PATH=/home/user/.local/bin:$PATH \
23
+ PORT=7860
24
+
25
+ EXPOSE 7860
26
+
27
+ CMD ["sh", "-c", "uvicorn main:app --host 0.0.0.0 --port ${PORT:-7860} --workers 1 --proxy-headers"]
README.md CHANGED
@@ -1,10 +1,59 @@
1
- ---
2
  title: Plenitude Negra Plena
3
  emoji: 🧁
4
  colorFrom: purple
5
  colorTo: pink
6
  sdk: gradio
7
  sdk_version: "5.6.0"
 
8
  app_file: app.py
9
  pinned: false
10
  ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
  title: Plenitude Negra Plena
3
  emoji: 🧁
4
  colorFrom: purple
5
  colorTo: pink
6
  sdk: gradio
7
  sdk_version: "5.6.0"
8
+ python_version: "3.12"
9
  app_file: app.py
10
  pinned: false
11
  ---
12
+
13
+ # Plenitude — cérebro da Negra Plena
14
+
15
+ API FastAPI da assistente **Plenitude**, atendimento da pastelaria **Negra Plena**.
16
+ Criada pela **Softedge**. O conector WhatsApp (Node, Railway) envia `POST /api/escutar` e recebe `{ "texto": "..." }`.
17
+
18
+ `app.py` serve a API (`main.py`) com uvicorn na porta 7860 e mostra uma interface Gradio de teste em `/`.
19
+
20
+ ## GPU ativada
21
+ A anotação @spaces.GPU está configurada para suportar inferência rápida.
22
+
23
+ ## Hierarquia de provedores (fallback)
24
+
25
+ 1. Llama local (ZeroGPU) → 2. OpenRouter → 3. Groq → 4. DeepSeek → 5. Mistral → 6. Gemini.
26
+ Cada nível que falha cai para o seguinte; `AI_PROVIDER` força um específico primeiro.
27
+ O Llama local (`local_llm.py`, `NousResearch/Meta-Llama-3-8B-Instruct` por defeito,
28
+ LoRA opcional via `LORA_ADAPTER_PATH`) é carregado uma vez e corre no `@spaces.GPU`;
29
+ se o download falhar (ex. repo gated sem licença + `HF_TOKEN`), cai para as APIs externas.
30
+ Vars: `LOCAL_MODEL_ID`, `LORA_ADAPTER_PATH`, `HF_TOKEN`.
31
+
32
+ ### Download dos pesos (onde e como confirmar)
33
+
34
+ - Os pesos **não** vão embutidos na imagem Docker (8B em bf16 ≈ 16GB).
35
+ - No arranque, `app.py` faz o pré-aquecimento: baixa uma vez para o cache
36
+ do disco (`~/.cache/huggingface`) e os arranques seguintes reutilizam.
37
+ - Confirme no log do container: `[llama] a carregar ...` → `[llama] modelo pronto.`
38
+ Se aparecer `[llama] indisponível (...)`, leia o motivo (ex. repo gated).
39
+ - Modelo gated (ex. Meta Llama): aceite a licença na página do modelo no Hub
40
+ e defina o Secret `HF_TOKEN` com uma conta que tenha acesso. Sem isto,
41
+ o Llama fica inativo e o bot responde via APIs externas (Groq etc.).
42
+ - Para pré-baixar na build Docker (opt-in), ver bloco comentado no `Dockerfile`.
43
+
44
+ ## Aprendizado (v1, log local JSONL)
45
+
46
+ - Cada resposta do `/escutar` é registada em `data/conversas.jsonl` e devolve um `mensagem_id`.
47
+ - Avalie com `POST /api/feedback` (`{"mensagem_id": "...", "nota": "boa"|"ma"}`);
48
+ casos `"ma"` nunca voltam como exemplo, `"boa"` tem prioridade.
49
+ - Perguntas parecidas com casos anteriores entram no prompt como exemplos (few-shot).
50
+ - O ficheiro contém números de clientes: nunca vai para git e perde-se em factory rebuild.
51
+ - Vars: `DATA_DIR` (`./data`), `LEARNING_MAX_LINHAS` (2000), `LEARNING_TOP_K` (2).
52
+
53
+ ## Endpoints
54
+
55
+ - `GET /` — interface Gradio de teste
56
+ - `GET /health` e `GET /api/health`
57
+ - `POST /api/escutar` ou `POST /escutar`
58
+ - `POST /api/feedback` ou `POST /feedback`
59
+ - `GET /docs` — OpenAPI
ai_engine.py CHANGED
@@ -1,20 +1,38 @@
1
  import requests
 
2
  from config import Config
3
  from memory import MemoryManager
4
  from catalog import CatalogService
 
5
 
6
  class AIEngine:
7
  def __init__(self):
8
  self.memory = MemoryManager(max_history=8)
9
  self.catalog = CatalogService()
 
10
 
11
  def generate_reply(self, numero_crm: str, texto_cliente: str, nome_cliente: str) -> str:
 
 
 
 
 
 
 
 
 
 
12
  historico = self.memory.get_context(numero_crm)
13
  dados_negocio = self.catalog.get_context()
14
  system = Config.SYSTEM_PROMPT
15
  if dados_negocio:
16
  system += f"\n\nDADOS ATUAIS DO NEGOCIO:\n{dados_negocio}"
17
 
 
 
 
 
 
18
  user_text = texto_cliente.strip() or "(o cliente enviou uma mensagem sem texto)"
19
  mensagens = [
20
  {"role": "system", "content": system},
@@ -39,6 +57,7 @@ class AIEngine:
39
 
40
  resposta = None
41
  erro_final = None
 
42
 
43
  for provider in ordem_provedores:
44
  try:
@@ -49,36 +68,44 @@ class AIEngine:
49
  if provider == "mistral" and not Config.MISTRAL_API_KEY: continue
50
  if provider == "gemini" and not Config.GEMINI_API_KEY: continue
51
 
52
- print(f"🔄 A tentar gerar resposta usando: {provider}...")
53
 
54
  if provider == "local_llama":
55
- from local_llm import LocalLlamaEngine
56
- engine_local = LocalLlamaEngine.get_instance()
57
- resposta = engine_local.generate(mensagens)
58
  elif provider == "gemini":
59
  resposta = self._call_gemini(system, mensagens[1]["content"])
60
  else:
61
  resposta = self._call_openai_compatible(provider, mensagens)
62
 
63
  if resposta:
64
- print(f"✅ Sucesso com {provider}!")
 
65
  break
66
 
67
  except Exception as exc:
68
- print(f"❌ Falha critica no provedor {provider}: {exc}")
69
  erro_final = exc
70
  continue # Se falhou, a hierarquia ignora o erro e avanca para o proximo!
71
 
72
  if not resposta:
73
- print(f"❌ TODOS os provedores da hierarquia falharam. Erro fatal: {erro_final}")
74
  return (
75
  "Peco desculpa, estou com uma instabilidade tecnica no sistema. "
76
- "Pode repetir a sua mensagem daqui a instantes?"
 
77
  )
78
 
79
  self.memory.add_message(numero_crm, "user", user_text)
80
  self.memory.add_message(numero_crm, "model", resposta)
81
- return resposta
 
 
 
 
 
 
 
82
 
83
  def _call_openai_compatible(self, provider: str, messages: list) -> str:
84
  url, headers = self._provider_endpoint(provider)
 
1
  import requests
2
+ import uuid
3
  from config import Config
4
  from memory import MemoryManager
5
  from catalog import CatalogService
6
+ from learning import LearningStore
7
 
8
  class AIEngine:
9
  def __init__(self):
10
  self.memory = MemoryManager(max_history=8)
11
  self.catalog = CatalogService()
12
+ self.learning = LearningStore()
13
 
14
  def generate_reply(self, numero_crm: str, texto_cliente: str, nome_cliente: str) -> str:
15
+ resposta, _ = self._responder(numero_crm, texto_cliente, nome_cliente, registar=False)
16
+ return resposta
17
+
18
+ def responder(self, numero_crm: str, texto_cliente: str, nome_cliente: str) -> tuple:
19
+ """Igual a generate_reply mas regista a troca no aprendizado e devolve o id."""
20
+ return self._responder(numero_crm, texto_cliente, nome_cliente, registar=True)
21
+
22
+ def _responder(self, numero_crm: str, texto_cliente: str, nome_cliente: str,
23
+ registar: bool) -> tuple:
24
+ mensagem_id = uuid.uuid4().hex[:12]
25
  historico = self.memory.get_context(numero_crm)
26
  dados_negocio = self.catalog.get_context()
27
  system = Config.SYSTEM_PROMPT
28
  if dados_negocio:
29
  system += f"\n\nDADOS ATUAIS DO NEGOCIO:\n{dados_negocio}"
30
 
31
+ user_text = texto_cliente.strip() or "(o cliente enviou uma mensagem sem texto)"
32
+ exemplos = self.learning.format_examples(user_text)
33
+ if exemplos:
34
+ system += f"\n\n{exemplos}"
35
+
36
  user_text = texto_cliente.strip() or "(o cliente enviou uma mensagem sem texto)"
37
  mensagens = [
38
  {"role": "system", "content": system},
 
57
 
58
  resposta = None
59
  erro_final = None
60
+ provedor_ok = ""
61
 
62
  for provider in ordem_provedores:
63
  try:
 
68
  if provider == "mistral" and not Config.MISTRAL_API_KEY: continue
69
  if provider == "gemini" and not Config.GEMINI_API_KEY: continue
70
 
71
+ print(f"[hierarchy] A tentar gerar resposta usando: {provider}...", flush=True)
72
 
73
  if provider == "local_llama":
74
+ from local_llm import generate_local
75
+ resposta = generate_local(mensagens)
 
76
  elif provider == "gemini":
77
  resposta = self._call_gemini(system, mensagens[1]["content"])
78
  else:
79
  resposta = self._call_openai_compatible(provider, mensagens)
80
 
81
  if resposta:
82
+ print(f"[hierarchy] Sucesso com {provider}!", flush=True)
83
+ provedor_ok = provider
84
  break
85
 
86
  except Exception as exc:
87
+ print(f"[hierarchy] Falha no provedor {provider}: {exc}", flush=True)
88
  erro_final = exc
89
  continue # Se falhou, a hierarquia ignora o erro e avanca para o proximo!
90
 
91
  if not resposta:
92
+ print(f"[hierarchy] TODOS os provedores falharam. Erro final: {erro_final}", flush=True)
93
  return (
94
  "Peco desculpa, estou com uma instabilidade tecnica no sistema. "
95
+ "Pode repetir a sua mensagem daqui a instantes?",
96
+ mensagem_id,
97
  )
98
 
99
  self.memory.add_message(numero_crm, "user", user_text)
100
  self.memory.add_message(numero_crm, "model", resposta)
101
+ if registar:
102
+ modelo = Config.LOCAL_MODEL_ID if provedor_ok == "local_llama" else Config.get_model(provedor_ok)
103
+ self.learning.log_exchange(
104
+ numero_crm, user_text, resposta,
105
+ provedor=provedor_ok, modelo=modelo,
106
+ mensagem_id=mensagem_id,
107
+ )
108
+ return resposta, mensagem_id
109
 
110
  def _call_openai_compatible(self, provider: str, messages: list) -> str:
111
  url, headers = self._provider_endpoint(provider)
app.py CHANGED
@@ -1,56 +1,81 @@
1
- import gradio as gr
2
- import spaces
3
- from fastapi import FastAPI, Depends, Header, HTTPException
4
- from fastapi.middleware.cors import CORSMiddleware
5
- from config import Config
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
  from ai_engine import AIEngine
7
- from models import EscutarPayload, EscutarResponse
8
-
9
- engine = AIEngine()
10
-
11
- # ---------- FastAPI (WhatsApp Bot envia pedidos aqui) ----------
12
- api = FastAPI(title="Plenitude API")
13
- api.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
14
-
15
- def require_token(authorization: str = Header(default="")):
16
- if not Config.API_AUTH_TOKEN:
17
- return
18
- if authorization != f"Bearer {Config.API_AUTH_TOKEN}":
19
- raise HTTPException(status_code=401, detail="Token invalido")
20
-
21
- @api.get("/health")
22
- @api.get("/api/health")
23
- def health():
24
- return {"status": "ok"}
25
-
26
- @api.post("/escutar", response_model=EscutarResponse)
27
- @api.post("/api/escutar", response_model=EscutarResponse)
28
- def escutar(payload: EscutarPayload, _=Depends(require_token)):
29
- cliente = payload.numero_crm or payload.numero or "unknown"
30
- texto = payload.texto.strip()
31
- if not texto and not payload.audio_base64:
32
- return EscutarResponse(texto="Nao consegui ler a sua mensagem. Pode escrever de novo?")
33
- if not texto:
34
- texto = "(audio recebido)"
35
- resposta = engine.generate_reply(cliente, texto, payload.nome or "Cliente")
36
- return EscutarResponse(texto=resposta)
37
-
38
- # ---------- Gradio (Interface visual + ZeroGPU) ----------
39
- @spaces.GPU
40
- def plenitude_chat(message, history):
41
- if not message or not message.strip():
42
- return "Por favor, escreva a sua mensagem."
43
- return engine.generate_reply("web_user", message, "Visitante")
44
-
45
- _demo = gr.ChatInterface(
46
- fn=plenitude_chat,
47
- title="Plenitude - Negra Plena",
48
- description="Assistente virtual da pastelaria Negra Plena. Desenvolvida pela Softedge.",
49
- )
50
-
51
- # Monta o Gradio por cima do FastAPI (ambos na mesma porta 7860)
52
- app = gr.mount_gradio_app(api, _demo, path="/")
 
 
 
 
 
 
 
 
 
 
53
 
54
  if __name__ == "__main__":
55
- import uvicorn
56
- uvicorn.run(app, host="0.0.0.0", port=7860)
 
1
+ import os
2
+
3
+ import gradio as gr
4
+ import uvicorn
5
+
6
+ try:
7
+ import spaces
8
+
9
+ print(f"[boot] spaces real: {getattr(spaces, '__file__', '?')}", flush=True)
10
+ except ImportError: # fora do ZeroGPU (dev local)
11
+ print("[boot] AVISO: pacote 'spaces' ausente, fallback local (ZeroGPU desligado)", flush=True)
12
+
13
+ class _SpacesFallback:
14
+ @staticmethod
15
+ def GPU(task=None, **kwargs):
16
+ if task is None:
17
+ return lambda fn: fn
18
+ return task
19
+
20
+ spaces = _SpacesFallback()
21
+
22
  from ai_engine import AIEngine
23
+ from config import Config
24
+ from main import app as api_app
25
+
26
+ PORT = int(os.getenv("PORT") or os.getenv("GRADIO_SERVER_PORT") or 7860)
27
+
28
+ _engine = AIEngine()
29
+
30
+
31
+ def _demo_reply(pergunta: str) -> str:
32
+ pergunta = (pergunta or "").strip()
33
+ if not pergunta:
34
+ return "Escreve uma pergunta, por exemplo: quanto custa a pizza?"
35
+ return _engine.generate_reply("demo", pergunta, "Visitante")
36
+
37
+
38
+ @spaces.GPU(duration=120)
39
+ def _zerogpu_status() -> str:
40
+ return "ZeroGPU activo neste Space."
41
+
42
+
43
+ api_app.router.routes = [r for r in api_app.router.routes if getattr(r, "path", None) != "/"]
44
+
45
+ with gr.Blocks(title=f"{Config.ASSISTANT_NAME} - {Config.COMPANY_NAME}") as demo:
46
+ gr.Markdown(
47
+ f"# {Config.ASSISTANT_NAME} - cérebro da {Config.COMPANY_NAME}\n"
48
+ "API de atendimento usada pelo bot WhatsApp. \n"
49
+ "`POST /api/escutar` · `GET /health` · `GET /docs`"
50
+ )
51
+ entrada = gr.Textbox(label="Pergunta de teste", placeholder="quanto custa a pizza?")
52
+ btn = gr.Button("Enviar", variant="primary")
53
+ saida = gr.Markdown()
54
+ btn.click(_demo_reply, inputs=entrada, outputs=saida)
55
+ entrada.submit(_demo_reply, inputs=entrada, outputs=saida)
56
+ gr.Button("Estado ZeroGPU").click(_zerogpu_status, inputs=None, outputs=saida)
57
+
58
+ # SSR desligado: o proxy Node do Gradio fica com a 7860 e o uvicorn precisa dela.
59
+ app = gr.mount_gradio_app(api_app, demo, path="/", ssr_mode=False)
60
+
61
+ # ZeroGPU: o demo TEM de ser lançado para o scan de startup detectar o @spaces.GPU.
62
+ # Padrão comprovado (Gradio + ZeroGPU + Uvicorn): launch interno não-bloqueante
63
+ # numa porta livre e fecha; o tráfego real é servido pelo uvicorn na $PORT.
64
+ try:
65
+ demo.launch(prevent_thread_lock=True, server_name="127.0.0.1", server_port=8000)
66
+ demo.close()
67
+ print("[boot] demo lançado e fechado para o scan ZeroGPU", flush=True)
68
+ except Exception as exc:
69
+ print(f"[boot] lançamento interno do demo falhou: {exc!r}", flush=True)
70
+
71
+ # Pré-aquecimento do Llama local: baixa os pesos UMA vez (depois usa o cache
72
+ # do disco) para a primeira resposta não pagar o download. Falha silenciosa
73
+ # com fallback para as APIs externas — nunca impede o arranque.
74
+ try:
75
+ from local_llm import _ensure_loaded as _warm_llama
76
+ _warm_llama()
77
+ except Exception as exc:
78
+ print(f"[boot] warmup Llama ignorado: {exc!r}", flush=True)
79
 
80
  if __name__ == "__main__":
81
+ uvicorn.run(app, host="0.0.0.0", port=PORT, log_level="info")
 
config.py CHANGED
@@ -31,6 +31,31 @@ class Config:
31
  COMPANY_NAME = os.getenv("COMPANY_NAME", "Negra Plena")
32
  ASSISTANT_NAME = os.getenv("ASSISTANT_NAME", "Plenitude")
33
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
  SYSTEM_PROMPT = (
35
  "Voce e a Plenitude, assistente virtual oficial de atendimento da Negra Plena.\n\n"
36
  "Identidade\n"
 
31
  COMPANY_NAME = os.getenv("COMPANY_NAME", "Negra Plena")
32
  ASSISTANT_NAME = os.getenv("ASSISTANT_NAME", "Plenitude")
33
 
34
+ # Preços oficiais — exigido por catalog.py (não remover).
35
+ OFFICIAL_PRICES = """
36
+ PREÇOS OFICIAIS (prioridade máxima — usa estes valores; não inventes outros):
37
+
38
+ Pizzas
39
+ - Pequena: 5.000 Kz
40
+ - Média: 7.000 Kz
41
+ - Grande: 9.500 Kz
42
+
43
+ Bolo no pote (artigo especial da marca): 1.500 Kz
44
+
45
+ Doces e salgados — vendidos por DÚZIA
46
+ - Dúzia de mini pizzas: 9.000 Kz
47
+ - Dúzia de rissóis: 9.000 Kz
48
+ - Dúzia de enroladinhos de pão de chouriço: 10.000 Kz
49
+ - Dúzia de mini pudim: 12.000 Kz
50
+ - Dúzia de bolas de Berlim: 12.000 Kz
51
+
52
+ Notas de preço
53
+ - Doces incluem: bola de Berlim, bolinho, enroladinho de coco e restantes doces da casa.
54
+ - Salgados incluem: chamuça, pastel de massa tenra, pão com chouriço, enroladinho de chouriço e restantes salgados da casa.
55
+ - Mousse, pudim e similares também são por dúzia (quando aplicável).
56
+ - Se o cliente pedir um produto sem preço listado, diz que a equipa confirma o valor.
57
+ """.strip()
58
+
59
  SYSTEM_PROMPT = (
60
  "Voce e a Plenitude, assistente virtual oficial de atendimento da Negra Plena.\n\n"
61
  "Identidade\n"
learning.py ADDED
@@ -0,0 +1,182 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Aprendizado v1 — log local JSONL + recuperação de casos parecidos (few-shot).
2
+
3
+ Sem dependências novas, só stdlib. CPU-friendly, sem GPU.
4
+
5
+ - Cada troca bot<->cliente é anexada a `conversas.jsonl` (ver `DATA_DIR`).
6
+ - `POST /feedback` classifica uma resposta como "boa"/"ma".
7
+ - Respostas "ma" nunca voltam como exemplo; "boa" tem prioridade.
8
+ - Exemplos parecidos entram no prompt como few-shot (factos e tom).
9
+ - O ficheiro nunca deve ir para git (contém números de clientes): ver `.gitignore`.
10
+ """
11
+
12
+ import json
13
+ import os
14
+ import re
15
+ import threading
16
+ import unicodedata
17
+ import uuid
18
+ from datetime import datetime, timezone
19
+
20
+ _STOPWORDS = {
21
+ "a", "o", "e", "de", "do", "da", "dos", "das", "em", "um", "uma",
22
+ "para", "com", "que", "quanto", "quantos", "qual", "quais", "como",
23
+ "quando", "onde", "os", "as", "uns", "umas", "no", "na", "nos",
24
+ "nas", "ao", "aos", "por", "mais", "muito", "isto", "isso", "este",
25
+ "esta", "esse", "essa", "meu", "minha", "seu", "sua", "voce", "você",
26
+ "tem", "têm", "e", "se", "qto", "qt", "q", "la", "lá", "ai", "aí",
27
+ "entao", "então", "pois", "mas", "ou", "tambem", "também",
28
+ }
29
+
30
+
31
+ def _tokens(texto: str) -> set:
32
+ sem_acento = unicodedata.normalize("NFKD", texto or "").encode("ascii", "ignore").decode()
33
+ palavras = re.findall(r"[a-z0-9]+", sem_acento.lower())
34
+ return {p for p in palavras if len(p) > 2 and p not in _STOPWORDS}
35
+
36
+
37
+ class LearningStore:
38
+ """Guarda trocas em JSONL e recupera casos parecidos."""
39
+
40
+ def __init__(
41
+ self,
42
+ data_dir: str | None = None,
43
+ arquivo: str = "conversas.jsonl",
44
+ max_linhas: int = 2000,
45
+ top_k: int = 2,
46
+ min_score: float = 0.15,
47
+ ):
48
+ self.data_dir = data_dir or os.getenv("DATA_DIR", "./data")
49
+ self.arquivo = arquivo
50
+ self.max_linhas = int(os.getenv("LEARNING_MAX_LINHAS", str(max_linhas)))
51
+ self.top_k = int(os.getenv("LEARNING_TOP_K", str(top_k)))
52
+ self.min_score = float(os.getenv("LEARNING_MIN_SCORE", str(min_score)))
53
+ self._lock = threading.Lock()
54
+ try:
55
+ os.makedirs(self.data_dir, exist_ok=True)
56
+ except Exception as exc:
57
+ print(f"[learning] não consegui criar {self.data_dir}: {exc}")
58
+
59
+ def _caminho(self) -> str:
60
+ return os.path.join(self.data_dir, self.arquivo)
61
+
62
+ def _ler_tudo(self) -> list:
63
+ registos = []
64
+ try:
65
+ with open(self._caminho(), "r", encoding="utf-8") as fh:
66
+ for linha in fh:
67
+ linha = linha.strip()
68
+ if not linha:
69
+ continue
70
+ try:
71
+ registos.append(json.loads(linha))
72
+ except json.JSONDecodeError:
73
+ continue
74
+ except FileNotFoundError:
75
+ pass
76
+ except Exception as exc:
77
+ print(f"[learning] erro a ler: {exc}")
78
+ return registos
79
+
80
+ def log_exchange(self, cliente: str, pergunta: str, resposta: str,
81
+ provedor: str = "", modelo: str = "",
82
+ mensagem_id: str | None = None) -> str:
83
+ mensagem_id = mensagem_id or uuid.uuid4().hex[:12]
84
+ registo = {
85
+ "id": mensagem_id,
86
+ "ts": datetime.now(timezone.utc).isoformat(),
87
+ "cliente": cliente or "unknown",
88
+ "pergunta": (pergunta or "")[:1000],
89
+ "resposta": (resposta or "")[:1000],
90
+ "provedor": provedor,
91
+ "modelo": modelo,
92
+ "avaliacao": None,
93
+ }
94
+ try:
95
+ with self._lock:
96
+ with open(self._caminho(), "a", encoding="utf-8") as fh:
97
+ fh.write(json.dumps(registo, ensure_ascii=False) + "\n")
98
+ self._podar()
99
+ except Exception as exc:
100
+ print(f"[learning] erro a registar: {exc}")
101
+ return mensagem_id
102
+
103
+ def _podar(self) -> None:
104
+ try:
105
+ with open(self._caminho(), "r", encoding="utf-8") as fh:
106
+ linhas = fh.readlines()
107
+ if len(linhas) > self.max_linhas + 500:
108
+ with open(self._caminho(), "w", encoding="utf-8") as fh:
109
+ fh.writelines(linhas[-self.max_linhas:])
110
+ except FileNotFoundError:
111
+ pass
112
+ except Exception as exc:
113
+ print(f"[learning] erro a podar: {exc}")
114
+
115
+ def rate(self, mensagem_id: str, nota: str, observacao: str = "") -> bool:
116
+ if nota not in ("boa", "ma"):
117
+ return False
118
+ try:
119
+ with self._lock:
120
+ registos = self._ler_tudo()
121
+ achou = False
122
+ for reg in registos:
123
+ if reg.get("id") == mensagem_id:
124
+ reg["avaliacao"] = nota
125
+ if observacao:
126
+ reg["observacao"] = observacao[:500]
127
+ achou = True
128
+ break
129
+ if not achou:
130
+ return False
131
+ with open(self._caminho(), "w", encoding="utf-8") as fh:
132
+ for reg in registos:
133
+ fh.write(json.dumps(reg, ensure_ascii=False) + "\n")
134
+ return True
135
+ except Exception as exc:
136
+ print(f"[learning] erro a avaliar: {exc}")
137
+ return False
138
+
139
+ def find_similar(self, pergunta: str, k: int | None = None) -> list:
140
+ base = _tokens(pergunta)
141
+ if not base:
142
+ return []
143
+ candidatos = []
144
+ try:
145
+ for reg in self._ler_tudo():
146
+ if reg.get("avaliacao") == "ma":
147
+ continue
148
+ pontos = _tokens(reg.get("pergunta", ""))
149
+ if not pontos:
150
+ continue
151
+ uniao = base | pontos
152
+ score = len(base & pontos) / len(uniao) if uniao else 0.0
153
+ if reg.get("avaliacao") == "boa":
154
+ score = min(1.0, score + 0.25)
155
+ if score >= self.min_score:
156
+ candidatos.append((score, reg))
157
+ except Exception as exc:
158
+ print(f"[learning] erro a procurar: {exc}")
159
+ return []
160
+ candidatos.sort(key=lambda item: item[0], reverse=True)
161
+ return [reg for _, reg in candidatos[: (k or self.top_k)]]
162
+
163
+ def format_examples(self, pergunta: str) -> str:
164
+ exemplos = self.find_similar(pergunta)
165
+ if not exemplos:
166
+ return ""
167
+ blocos = ["EXEMPLOS DE ATENDIMENTOS ANTERIORES (segue o mesmo padrão de factos e tom):"]
168
+ total = len(blocos[0])
169
+ for i, reg in enumerate(exemplos, 1):
170
+ perg = (reg.get("pergunta", "") or "")[:200]
171
+ resp = (reg.get("resposta", "") or "")[:300]
172
+ bloco = f"{i}) Cliente: {perg}\n Plenitude: {resp}"
173
+ if total + len(bloco) > 900:
174
+ break
175
+ blocos.append(bloco)
176
+ total += len(bloco)
177
+ if len(blocos) < 2:
178
+ return ""
179
+ return "\n".join(blocos)
180
+
181
+ def count(self) -> int:
182
+ return len(self._ler_tudo())
local_llm.py CHANGED
@@ -1,54 +1,102 @@
1
- import os
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
  import torch
3
- import spaces
4
  from transformers import AutoModelForCausalLM, AutoTokenizer
5
- from peft import PeftModel
 
 
 
 
 
6
  from config import Config
7
 
8
- class LocalLlamaEngine:
9
- _instance = None
10
-
11
- def __init__(self):
12
- self.model_id = Config.LOCAL_MODEL_ID
13
- self.lora_path = Config.LORA_ADAPTER_PATH
14
-
15
- print(f"🔄 A iniciar Llama local ({self.model_id}) na RAM (ZeroGPU movera para GPU na inferencia)...")
16
- self.tokenizer = AutoTokenizer.from_pretrained(self.model_id)
17
-
18
- self.model = AutoModelForCausalLM.from_pretrained(
19
- self.model_id,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
  torch_dtype=torch.bfloat16,
21
- low_cpu_mem_usage=True
22
  )
23
-
24
- if self.lora_path and os.path.exists(self.lora_path):
25
- print(f"🧠 A aplicar pesos de aprendizagem LoRA de: {self.lora_path}...")
26
- self.model = PeftModel.from_pretrained(self.model, self.lora_path)
27
-
28
- print("✅ Llama local preparado e pronto a usar!")
29
-
30
- @classmethod
31
- def get_instance(cls):
32
- if cls._instance is None:
33
- cls._instance = cls()
34
- return cls._instance
35
-
36
- @spaces.GPU
37
- def generate(self, messages: list) -> str:
38
- # Movemos o modelo para a GPU no exacto momento do atendimento
39
- self.model.to("cuda")
40
-
41
- prompt = self.tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
42
- inputs = self.tokenizer(prompt, return_tensors="pt").to("cuda")
43
-
44
- outputs = self.model.generate(
45
- **inputs,
46
- max_new_tokens=450,
47
- temperature=0.4,
48
- top_p=0.9,
49
- do_sample=True,
50
- pad_token_id=self.tokenizer.eos_token_id
51
- )
52
-
53
- response = self.tokenizer.decode(outputs[0][inputs["input_ids"].shape[-1]:], skip_special_tokens=True)
54
- return response.strip()
 
 
 
 
 
 
 
 
 
 
 
1
+ """Llama local no ZeroGPU — inferência com pesos abertos + LoRA opcional.
2
+
3
+ Regras ZeroGPU respeitadas:
4
+ - `import spaces` ANTES de qualquer import que toque CUDA (regra nº1).
5
+ - Modelo carregado UMA vez no processo principal com `.to("cuda")`
6
+ (o hijack do spaces trata disto); NUNCA dentro do `@spaces.GPU`.
7
+ - `@spaces.GPU` só em função de módulo (o `self` com o modelo não
8
+ atravessa o fork; ver "Process isolation and pickle").
9
+ - Falha de download (ex. repo gated sem licença) NÃO deita o Space
10
+ abaixo: regista o motivo uma vez e a hierarquia cai para as APIs externas.
11
+ """
12
+
13
+ import spaces # tem de ser o primeiro import com CUDA por perto
14
+
15
+ import os
16
+ import threading
17
+
18
  import torch
 
19
  from transformers import AutoModelForCausalLM, AutoTokenizer
20
+
21
+ try:
22
+ from peft import PeftModel
23
+ except ImportError:
24
+ PeftModel = None
25
+
26
  from config import Config
27
 
28
+ _MODEL = None
29
+ _TOKENIZER = None
30
+ _LOAD_FAILED = ""
31
+ _LOAD_LOCK = threading.Lock()
32
+
33
+
34
+ def _ensure_loaded() -> bool:
35
+ global _MODEL, _TOKENIZER, _LOAD_FAILED
36
+ if _MODEL is not None:
37
+ return True
38
+ if _LOAD_FAILED:
39
+ return False
40
+ with _LOAD_LOCK:
41
+ if _MODEL is not None:
42
+ return True
43
+ if _LOAD_FAILED:
44
+ return False
45
+ return _load_once()
46
+
47
+
48
+ def _load_once() -> bool:
49
+ global _MODEL, _TOKENIZER, _LOAD_FAILED
50
+ model_id = Config.LOCAL_MODEL_ID
51
+ try:
52
+ print(f"[llama] a carregar {model_id} ...", flush=True)
53
+ tok = AutoTokenizer.from_pretrained(model_id)
54
+ if tok.pad_token is None:
55
+ tok.pad_token = tok.eos_token
56
+ mdl = AutoModelForCausalLM.from_pretrained(
57
+ model_id,
58
  torch_dtype=torch.bfloat16,
59
+ low_cpu_mem_usage=True,
60
  )
61
+ # Uma vez no processo principal; o hijack do spaces gere o resto.
62
+ mdl.to("cuda")
63
+ lora = Config.LORA_ADAPTER_PATH
64
+ if lora:
65
+ if not os.path.exists(lora):
66
+ print(f"[llama] LORA_ADAPTER_PATH inexistente ({lora}); a ignorar.", flush=True)
67
+ elif PeftModel is None:
68
+ raise RuntimeError("peft não instalado; não consigo aplicar o adaptador LoRA")
69
+ else:
70
+ print(f"[llama] a aplicar LoRA de: {lora} ...", flush=True)
71
+ mdl = PeftModel.from_pretrained(mdl, lora)
72
+ mdl.eval()
73
+ _MODEL, _TOKENIZER = mdl, tok
74
+ print("[llama] modelo pronto.", flush=True)
75
+ return True
76
+ except Exception as exc:
77
+ _LOAD_FAILED = f"{type(exc).__name__}: {exc}"
78
+ print(f"[llama] indisponível ({_LOAD_FAILED}); fallback para APIs externas.", flush=True)
79
+ return False
80
+
81
+
82
+ @spaces.GPU(duration=180)
83
+ def generate_local(messages: list) -> str:
84
+ """Gera resposta com o Llama local. Falha rápido se o modelo não carregou."""
85
+ if not _ensure_loaded():
86
+ raise RuntimeError(f"Llama local indisponível: {_LOAD_FAILED}")
87
+ prompt = _TOKENIZER.apply_chat_template(
88
+ messages, tokenize=False, add_generation_prompt=True
89
+ )
90
+ inputs = _TOKENIZER(prompt, return_tensors="pt").to("cuda")
91
+ gen = _MODEL.generate(
92
+ **inputs,
93
+ max_new_tokens=450,
94
+ temperature=0.4,
95
+ top_p=0.9,
96
+ do_sample=True,
97
+ pad_token_id=_TOKENIZER.eos_token_id,
98
+ )
99
+ texto = _TOKENIZER.decode(
100
+ gen[0][inputs["input_ids"].shape[-1]:], skip_special_tokens=True
101
+ )
102
+ return texto.strip()
main.py CHANGED
@@ -5,7 +5,7 @@ import spaces
5
 
6
  from ai_engine import AIEngine
7
  from config import Config
8
- from models import EscutarPayload, EscutarResponse
9
 
10
  app = FastAPI(
11
  title="Plenitude — Cerebro Negra Plena",
@@ -68,5 +68,16 @@ def escutar(payload: EscutarPayload, _: None = Depends(require_token)):
68
  if not texto and payload.audio_base64:
69
  texto = "(o cliente enviou um audio; transcricao na GPU ainda em preparacao)"
70
 
71
- resposta = engine.generate_reply(cliente, texto, payload.nome or "Cliente")
72
- return EscutarResponse(texto=resposta)
 
 
 
 
 
 
 
 
 
 
 
 
5
 
6
  from ai_engine import AIEngine
7
  from config import Config
8
+ from models import EscutarPayload, EscutarResponse, FeedbackPayload, FeedbackResponse
9
 
10
  app = FastAPI(
11
  title="Plenitude — Cerebro Negra Plena",
 
68
  if not texto and payload.audio_base64:
69
  texto = "(o cliente enviou um audio; transcricao na GPU ainda em preparacao)"
70
 
71
+ resposta, mensagem_id = engine.responder(cliente, texto, payload.nome or "Cliente")
72
+ return EscutarResponse(texto=resposta, mensagem_id=mensagem_id)
73
+
74
+
75
+ @app.post("/feedback", response_model=FeedbackResponse)
76
+ @app.post("/api/feedback", response_model=FeedbackResponse)
77
+ def feedback(payload: FeedbackPayload, _: None = Depends(require_token)):
78
+ ok = engine.learning.rate(
79
+ payload.mensagem_id, payload.nota, payload.comentario or ""
80
+ )
81
+ if ok:
82
+ return FeedbackResponse(ok=True, detalhe="Avaliação registada. Obrigado!")
83
+ return FeedbackResponse(ok=False, detalhe="mensagem_id não encontrado.")
models.py CHANGED
@@ -1,4 +1,4 @@
1
- from typing import Optional
2
 
3
  from pydantic import BaseModel, Field
4
 
@@ -16,3 +16,17 @@ class EscutarPayload(BaseModel):
16
 
17
  class EscutarResponse(BaseModel):
18
  texto: str = Field(..., description="Resposta a enviar no WhatsApp")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Literal, Optional
2
 
3
  from pydantic import BaseModel, Field
4
 
 
16
 
17
  class EscutarResponse(BaseModel):
18
  texto: str = Field(..., description="Resposta a enviar no WhatsApp")
19
+ mensagem_id: Optional[str] = Field(
20
+ default=None, description="Id para avaliar depois em POST /feedback"
21
+ )
22
+
23
+
24
+ class FeedbackPayload(BaseModel):
25
+ mensagem_id: str = Field(..., description="Id devolvido em EscutarResponse")
26
+ nota: Literal["boa", "ma"] = Field(..., description="'boa' ou 'ma'")
27
+ comentario: Optional[str] = Field(default=None, description="Nota opcional da equipa")
28
+
29
+
30
+ class FeedbackResponse(BaseModel):
31
+ ok: bool = Field(..., description="True se a avaliação foi registada")
32
+ detalhe: str = Field(..., description="Explicação curta")