merge: Integradas alteracoes remotas do HF (learning v1, warmup e docs)
Browse files- .dockerignore +6 -0
- .env.example +21 -0
- .gitattributes +35 -0
- .gitignore +13 -0
- Dockerfile +27 -0
- README.md +50 -1
- ai_engine.py +36 -9
- app.py +78 -53
- config.py +25 -0
- learning.py +182 -0
- local_llm.py +96 -48
- main.py +14 -3
- 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"
|
| 53 |
|
| 54 |
if provider == "local_llama":
|
| 55 |
-
from local_llm import
|
| 56 |
-
|
| 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"
|
|
|
|
| 65 |
break
|
| 66 |
|
| 67 |
except Exception as exc:
|
| 68 |
-
print(f"
|
| 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"
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
from ai_engine import AIEngine
|
| 7 |
-
from
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
def
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
@
|
| 23 |
-
def
|
| 24 |
-
return
|
| 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 |
if __name__ == "__main__":
|
| 55 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
import torch
|
| 3 |
-
import spaces
|
| 4 |
from transformers import AutoModelForCausalLM, AutoTokenizer
|
| 5 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
from config import Config
|
| 7 |
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
torch_dtype=torch.bfloat16,
|
| 21 |
-
low_cpu_mem_usage=True
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 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")
|