Ana2012 commited on
Commit
614aa6b
·
1 Parent(s): aa09607

Deploy backend FastAPI para HF Spaces

Browse files
.dockerignore ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ fly.toml
2
+ .git/
3
+ __pycache__/
4
+ .envrc
5
+ .venv/
.gitattributes CHANGED
@@ -33,3 +33,4 @@ saved_model/**/* 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
 
 
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
36
+ *.csv filter=lfs diff=lfs merge=lfs -text
ChatAmoOfertas/.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
ChatAmoOfertas/README.md ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: ChatAmoOfertas
3
+ emoji: 🏃
4
+ colorFrom: green
5
+ colorTo: gray
6
+ sdk: docker
7
+ pinned: false
8
+ ---
9
+
10
+ Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
Dockerfile ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ FROM python:3.11-slim
3
+
4
+ RUN useradd -m -u 1000 user
5
+
6
+ WORKDIR /app
7
+
8
+ ENV PYTHONUNBUFFERED=1 \
9
+ PORT=7860 \
10
+ HF_HOME=/home/user/.cache/huggingface \
11
+ HF_HUB_CACHE=/home/user/.cache/huggingface/hub \
12
+ TRANSFORMERS_CACHE=/home/user/.cache/huggingface/transformers
13
+
14
+ COPY requirements.txt requirements.txt
15
+ RUN pip install --no-cache-dir --upgrade pip \
16
+ && pip install --no-cache-dir --extra-index-url https://download.pytorch.org/whl/cpu -r requirements.txt
17
+
18
+ COPY . /app
19
+ RUN mkdir -p /home/user/.cache/huggingface/hub /home/user/.cache/huggingface/transformers \
20
+ && chown -R user:user /app /home/user/.cache
21
+
22
+ USER user
23
+
24
+ EXPOSE 7860
25
+
26
+ CMD ["sh", "-c", "python -m uvicorn app.main:app --host 0.0.0.0 --port ${PORT:-7860}"]
app/__init__.py ADDED
File without changes
app/agent.py ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from .search import SearchEngine
2
+
3
+
4
+ class ShoppingAgent:
5
+ def __init__(self):
6
+ self.search_engine = SearchEngine()
7
+ self.search_engine.load()
8
+
9
+ def runtime_info(self):
10
+ return self.search_engine.runtime_info()
11
+
12
+ def montar_resposta(self, query, resultados):
13
+ if not resultados:
14
+ return f'Não encontrei produtos relevantes para "{query}".'
15
+
16
+ nomes = [item["product_name"] for item in resultados[:3]]
17
+
18
+ if len(nomes) == 1:
19
+ return f'Encontrei um produto relevante para "{query}": {nomes[0]}.'
20
+
21
+ if len(nomes) == 2:
22
+ return f'Encontrei produtos relevantes para "{query}", com destaque para {nomes[0]} e {nomes[1]}.'
23
+
24
+ return (
25
+ f'Encontrei produtos relevantes para "{query}", com destaque para '
26
+ f'{nomes[0]}, {nomes[1]} e {nomes[2]}.'
27
+ )
28
+
29
+ def verificar_resposta(self, resposta, resultados):
30
+ if not resultados:
31
+ return resposta
32
+
33
+ nomes_resultados = [item["product_name"] for item in resultados]
34
+ resposta_limpa = resposta.lower()
35
+
36
+ mencoes_validas = any(nome.lower() in resposta_limpa for nome in nomes_resultados)
37
+
38
+ if mencoes_validas:
39
+ return resposta
40
+
41
+ top1 = resultados[0]["product_name"]
42
+ return f"{resposta} O item mais relevante encontrado foi {top1}."
43
+
44
+ def responder(self, query, top_k=5):
45
+ busca = self.search_engine.buscar(query, top_k=top_k)
46
+ resultados = busca["resultados"]
47
+
48
+ resposta_inicial = self.montar_resposta(query, resultados)
49
+ resposta_final = self.verificar_resposta(resposta_inicial, resultados)
50
+
51
+ return {
52
+ "query": query,
53
+ "categoria_inferida": busca["categoria_inferida"],
54
+ "answer": resposta_final,
55
+ "products": resultados,
56
+ }
app/feedback.py ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import csv
2
+ import os
3
+ from datetime import datetime
4
+
5
+ from .memory import salvar_memoria_negativa
6
+
7
+
8
+ BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
9
+ # Usa volume persistente do Fly.io montado em /data
10
+ # Garante que feedback não seja perdido após deploy/restart
11
+ LOGS_DIR = os.getenv("LOGS_DIR", "/data/logs")
12
+ FEEDBACK_FILE = os.path.join(LOGS_DIR, "feedback.csv")
13
+
14
+
15
+ def garantir_pasta_logs():
16
+ os.makedirs(LOGS_DIR, exist_ok=True)
17
+
18
+
19
+ def inicializar_arquivo_feedback():
20
+ garantir_pasta_logs()
21
+
22
+ if not os.path.exists(FEEDBACK_FILE):
23
+ with open(FEEDBACK_FILE, mode="w", newline="", encoding="utf-8") as f:
24
+ writer = csv.writer(f)
25
+ writer.writerow([
26
+ "timestamp",
27
+ "query",
28
+ "product_id",
29
+ "product_name",
30
+ "rating",
31
+ "is_helpful"
32
+ ])
33
+
34
+
35
+ def salvar_feedback(query, product_id, product_name, rating=None, is_helpful=None):
36
+ inicializar_arquivo_feedback()
37
+
38
+ with open(FEEDBACK_FILE, mode="a", newline="", encoding="utf-8") as f:
39
+ writer = csv.writer(f)
40
+ writer.writerow([
41
+ datetime.now().isoformat(),
42
+ query,
43
+ product_id,
44
+ product_name,
45
+ rating if rating is not None else "",
46
+ is_helpful if is_helpful is not None else ""
47
+ ])
48
+
49
+ # Regra simples para criar memória negativa
50
+ if rating is not None and rating <= 2:
51
+ salvar_memoria_negativa(
52
+ query=query,
53
+ product_id=product_id,
54
+ product_name=product_name,
55
+ rating=rating,
56
+ motivo="rating_baixo"
57
+ )
58
+
59
+ if is_helpful is False:
60
+ salvar_memoria_negativa(
61
+ query=query,
62
+ product_id=product_id,
63
+ product_name=product_name,
64
+ rating=rating if rating is not None else "",
65
+ motivo="nao_foi_util"
66
+ )
67
+
68
+ return {
69
+ "status": "ok",
70
+ "message": "Feedback salvo com sucesso."
71
+ }
72
+
73
+
74
+ def caminho_feedback():
75
+ return FEEDBACK_FILE
app/logger.py ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import csv
2
+ import os
3
+ from datetime import datetime
4
+
5
+
6
+ BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
7
+ LOGS_DIR = os.path.join(BASE_DIR, "logs")
8
+ SEARCH_LOG_FILE = os.path.join(LOGS_DIR, "search_logs.csv")
9
+
10
+
11
+ def garantir_pasta_logs():
12
+ os.makedirs(LOGS_DIR, exist_ok=True)
13
+
14
+
15
+ def inicializar_arquivo_logs():
16
+ garantir_pasta_logs()
17
+
18
+ if not os.path.exists(SEARCH_LOG_FILE):
19
+ with open(SEARCH_LOG_FILE, "w", newline="", encoding="utf-8") as f:
20
+ writer = csv.writer(f)
21
+ writer.writerow([
22
+ "timestamp",
23
+ "query",
24
+ "categoria_inferida",
25
+ "answer",
26
+ "top1_id",
27
+ "top1_name",
28
+ "top2_id",
29
+ "top2_name",
30
+ "top3_id",
31
+ "top3_name"
32
+ ])
33
+
34
+
35
+ def salvar_log_busca(resultado):
36
+ inicializar_arquivo_logs()
37
+
38
+ produtos = resultado.get("products", [])
39
+
40
+ def get_prod(i, campo):
41
+ if i < len(produtos):
42
+ return produtos[i].get(campo, "")
43
+ return ""
44
+
45
+ with open(SEARCH_LOG_FILE, "a", newline="", encoding="utf-8") as f:
46
+ writer = csv.writer(f)
47
+ writer.writerow([
48
+ datetime.now().isoformat(),
49
+ resultado.get("query", ""),
50
+ resultado.get("categoria_inferida", ""),
51
+ resultado.get("answer", ""),
52
+ get_prod(0, "product_id"),
53
+ get_prod(0, "product_name"),
54
+ get_prod(1, "product_id"),
55
+ get_prod(1, "product_name"),
56
+ get_prod(2, "product_id"),
57
+ get_prod(2, "product_name"),
58
+ ])
59
+
60
+ return {"status": "ok"}
app/main.py ADDED
@@ -0,0 +1,186 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import threading
3
+ from pathlib import Path
4
+
5
+ from fastapi import FastAPI, Response
6
+ from fastapi.middleware.cors import CORSMiddleware
7
+ from fastapi.responses import FileResponse, RedirectResponse
8
+ from pydantic import BaseModel
9
+ from typing import Optional
10
+
11
+ from .agent import ShoppingAgent
12
+ from .feedback import caminho_feedback, salvar_feedback
13
+ from .logger import salvar_log_busca
14
+ from .memory import caminho_memoria_negativa
15
+
16
+ EMBEDDING_PROVIDER = os.getenv("EMBEDDING_PROVIDER", "transformers").strip().lower()
17
+ HF_MODEL_REPO = os.getenv("HF_MODEL_REPO", "Ana2012/bertimbau-buscador").strip()
18
+
19
+
20
+
21
+ def _env_flag(name, default="true"):
22
+ return os.getenv(name, default).strip().lower() in {"1", "true", "yes", "on"}
23
+
24
+
25
+ PRELOAD_AGENT = _env_flag("PRELOAD_AGENT", "true")
26
+ LOGS_DIR = os.getenv("LOGS_DIR", "/data/logs")
27
+ DATA_DIR = "/data"
28
+
29
+ app = FastAPI(title="TCC2 Agent API")
30
+
31
+ app.add_middleware(
32
+ CORSMiddleware,
33
+ # Libera temporariamente a comunicacao entre frontend na Cloudflare e backend no Fly.io.
34
+ allow_origins=["*"],
35
+ allow_credentials=False,
36
+ allow_methods=["*"],
37
+ allow_headers=["*"],
38
+ )
39
+
40
+ agent = None
41
+ agent_lock = threading.Lock()
42
+
43
+
44
+ def get_agent():
45
+ global agent
46
+ if agent is None:
47
+ with agent_lock:
48
+ if agent is None:
49
+ agent = ShoppingAgent()
50
+ return agent
51
+
52
+
53
+ @app.on_event("startup")
54
+ def preload_agent():
55
+ if PRELOAD_AGENT:
56
+ get_agent()
57
+
58
+
59
+ class ChatRequest(BaseModel):
60
+ query: Optional[str] = None
61
+ message: Optional[str] = None
62
+ top_k: int = 5
63
+
64
+
65
+ class FeedbackRequest(BaseModel):
66
+ query: str
67
+ product_id: str
68
+ product_name: str
69
+ rating: Optional[int] = None
70
+ is_helpful: Optional[bool] = None
71
+
72
+
73
+ @app.get("/health")
74
+ def health():
75
+ runtime = get_agent().runtime_info() if agent is not None else None
76
+ return {
77
+ "status": "ok",
78
+ "agent_ready": agent is not None,
79
+ "embedding_provider": EMBEDDING_PROVIDER,
80
+ "model_repo": HF_MODEL_REPO,
81
+ "preload_agent": PRELOAD_AGENT,
82
+ "runtime": runtime,
83
+ }
84
+
85
+
86
+ @app.get("/", include_in_schema=False)
87
+ def root():
88
+ return RedirectResponse(url="/docs")
89
+
90
+
91
+ @app.get("/favicon.ico", include_in_schema=False)
92
+ def favicon():
93
+ return Response(status_code=204)
94
+
95
+
96
+ @app.get("/debug/files")
97
+ def debug_files():
98
+ data_path = Path(DATA_DIR)
99
+ logs_path = Path(LOGS_DIR)
100
+ feedback_path = Path(caminho_feedback())
101
+ memory_path = Path(caminho_memoria_negativa())
102
+
103
+ return {
104
+ "data_exists": data_path.exists(),
105
+ "logs_exists": logs_path.exists(),
106
+ "feedback_exists": feedback_path.exists(),
107
+ "negative_memory_exists": memory_path.exists(),
108
+ "data_files": sorted(p.name for p in data_path.iterdir()) if data_path.exists() else [],
109
+ "logs_files": sorted(p.name for p in logs_path.iterdir()) if logs_path.exists() else [],
110
+ "feedback_file": str(feedback_path),
111
+ "negative_memory_file": str(memory_path),
112
+ }
113
+
114
+
115
+ @app.get("/debug/feedback")
116
+ def debug_feedback():
117
+ feedback_path = Path(caminho_feedback())
118
+ if not feedback_path.exists():
119
+ return {"error": "arquivo nao existe"}
120
+
121
+ return {"conteudo": feedback_path.read_text(encoding="utf-8")}
122
+
123
+
124
+ @app.get("/download/feedback")
125
+ def download_feedback():
126
+ feedback_path = caminho_feedback()
127
+ if not os.path.exists(feedback_path):
128
+ return {"error": "arquivo nao existe"}
129
+
130
+ return FileResponse(feedback_path, filename="feedback.csv")
131
+
132
+
133
+ @app.get("/debug/memory")
134
+ def debug_memory():
135
+ memory_path = Path(caminho_memoria_negativa())
136
+ if not memory_path.exists():
137
+ return {"status": "missing", "file": str(memory_path)}
138
+
139
+ return {
140
+ "status": "ok",
141
+ "file": str(memory_path),
142
+ "content": memory_path.read_text(encoding="utf-8"),
143
+ }
144
+
145
+
146
+ @app.post("/chat")
147
+ def chat(request: ChatRequest):
148
+ texto = request.query or request.message
149
+
150
+ if not texto:
151
+ return {"error": "query ou message deve ser informado"}
152
+
153
+ resultado = get_agent().responder(texto, top_k=request.top_k)
154
+ salvar_log_busca(resultado)
155
+ return resultado
156
+
157
+
158
+ @app.post("/feedback")
159
+ def feedback(request: FeedbackRequest):
160
+ feedback_file = caminho_feedback()
161
+ print(
162
+ "Salvando feedback:",
163
+ {
164
+ "query": request.query,
165
+ "product_id": request.product_id,
166
+ "feedback_file": feedback_file,
167
+ "logs_dir_exists": os.path.exists(LOGS_DIR),
168
+ },
169
+ )
170
+
171
+ try:
172
+ return salvar_feedback(
173
+ query=request.query,
174
+ product_id=request.product_id,
175
+ product_name=request.product_name,
176
+ rating=request.rating,
177
+ is_helpful=request.is_helpful
178
+ )
179
+ except Exception as exc:
180
+ return {
181
+ "status": "error",
182
+ "message": "Erro ao salvar feedback.",
183
+ "detail": str(exc),
184
+ "feedback_file": feedback_file,
185
+ "logs_dir_exists": os.path.exists(LOGS_DIR),
186
+ }
app/memory.py ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import csv
2
+ import os
3
+ from datetime import datetime
4
+
5
+
6
+ BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
7
+ # Usa volume persistente do Fly.io montado em /data
8
+ # Garante que feedback não seja perdido após deploy/restart
9
+ LOGS_DIR = os.getenv("LOGS_DIR", "/data/logs")
10
+ NEGATIVE_MEMORY_FILE = os.path.join(LOGS_DIR, "negative_memory.csv")
11
+
12
+
13
+ def garantir_pasta_logs():
14
+ os.makedirs(LOGS_DIR, exist_ok=True)
15
+
16
+
17
+ def inicializar_memoria_negativa():
18
+ garantir_pasta_logs()
19
+
20
+ if not os.path.exists(NEGATIVE_MEMORY_FILE):
21
+ with open(NEGATIVE_MEMORY_FILE, "w", newline="", encoding="utf-8") as f:
22
+ writer = csv.writer(f)
23
+ writer.writerow([
24
+ "timestamp",
25
+ "query",
26
+ "product_id",
27
+ "product_name",
28
+ "rating",
29
+ "motivo"
30
+ ])
31
+
32
+
33
+ def salvar_memoria_negativa(query, product_id, product_name, rating, motivo="feedback_negativo"):
34
+ inicializar_memoria_negativa()
35
+
36
+ with open(NEGATIVE_MEMORY_FILE, "a", newline="", encoding="utf-8") as f:
37
+ writer = csv.writer(f)
38
+ writer.writerow([
39
+ datetime.now().isoformat(),
40
+ query,
41
+ product_id,
42
+ product_name,
43
+ rating,
44
+ motivo
45
+ ])
46
+
47
+ return {"status": "ok"}
48
+
49
+
50
+ def caminho_memoria_negativa():
51
+ return NEGATIVE_MEMORY_FILE
app/search.py ADDED
@@ -0,0 +1,259 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+
3
+ import numpy as np
4
+ import pandas as pd
5
+ import torch
6
+ from sentence_transformers import SentenceTransformer
7
+ from sklearn.metrics.pairwise import cosine_similarity
8
+
9
+ from .utils import (
10
+ bonus_lexical,
11
+ inferir_categoria_consulta,
12
+ limpar_texto,
13
+ mapear_categoria,
14
+ )
15
+
16
+
17
+ BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
18
+ DATA_DIR = os.path.join(BASE_DIR, "data")
19
+ LOGS_DIR = os.path.join(BASE_DIR, "logs")
20
+
21
+ PATH_PRODUCTS = os.path.join(DATA_DIR, "produtos_finetunado.csv")
22
+ PATH_EMBEDDINGS = os.path.join(DATA_DIR, "embeddings_produtos_finetunado.npy")
23
+ PATH_NEGATIVE_MEMORY = os.path.join(LOGS_DIR, "negative_memory.csv")
24
+
25
+ MODEL_NAME = os.getenv("HF_MODEL_REPO", "Ana2012/bertimbau-buscador").strip()
26
+ HF_API_TOKEN = os.getenv("HF_API_TOKEN", "").strip()
27
+
28
+
29
+ class SearchEngine:
30
+ def __init__(self):
31
+ self.device = "cuda" if torch.cuda.is_available() else "cpu"
32
+ self.model = None
33
+ self.df_produtos = None
34
+ self.emb_produtos = None
35
+ self.df_negative_memory = pd.DataFrame()
36
+ self.negative_memory_mtime = None
37
+
38
+ def load(self):
39
+ self._load_products()
40
+ self._load_model()
41
+ self._load_embeddings()
42
+ self._refresh_negative_memory(force=True)
43
+
44
+ def _load_products(self):
45
+ df = pd.read_csv(PATH_PRODUCTS)
46
+ df.columns = df.columns.str.strip().str.lower()
47
+
48
+ df["product_name"] = df["product_name"].fillna("").astype(str)
49
+ df["description"] = df["description"].fillna("").astype(str)
50
+ df["categoria_principal"] = df["categoria_principal"].fillna("").astype(str)
51
+ df["category_names_text"] = df["category_names_text"].fillna("").astype(str)
52
+ df["region"] = df["region"].fillna("").astype(str)
53
+ df["neighborhood"] = df["neighborhood"].fillna("").astype(str)
54
+
55
+ df["product_name_limpo"] = df["product_name"].apply(limpar_texto)
56
+ df["description_limpa"] = df["description"].apply(limpar_texto)
57
+ df["categoria_principal_limpa"] = df["categoria_principal"].apply(limpar_texto)
58
+ df["category_names_text_limpo"] = df["category_names_text"].apply(limpar_texto)
59
+ df["region_limpa"] = df["region"].apply(limpar_texto)
60
+ df["neighborhood_limpo"] = df["neighborhood"].apply(limpar_texto)
61
+
62
+ df["texto_busca_reforcado"] = (
63
+ "produto " + df["product_name_limpo"] + " "
64
+ + "categoria " + df["categoria_principal_limpa"] + " "
65
+ + "categorias " + df["category_names_text_limpo"] + " "
66
+ + "bairro " + df["neighborhood_limpo"] + " "
67
+ + "regiao " + df["region_limpa"] + " "
68
+ + "descricao " + df["description_limpa"]
69
+ ).str.strip()
70
+
71
+ df["categoria_grupo"] = df["categoria_principal"].apply(mapear_categoria)
72
+
73
+ self.df_produtos = df
74
+
75
+ def _load_model(self):
76
+ kwargs = {"device": self.device}
77
+ if HF_API_TOKEN:
78
+ kwargs["token"] = HF_API_TOKEN
79
+
80
+ # Usa o mesmo pipeline validado localmente com SentenceTransformer.
81
+ self.model = SentenceTransformer(MODEL_NAME, **kwargs)
82
+
83
+ def _load_embeddings(self):
84
+ self.emb_produtos = np.load(PATH_EMBEDDINGS)
85
+
86
+ # Se estes embeddings .npy foram gerados com outro pipeline
87
+ # (por exemplo, AutoModel + mean pooling manual), os scores podem ficar inconsistentes.
88
+ # Nesse caso, regenere os embeddings dos produtos com o mesmo SentenceTransformer.
89
+ if self.emb_produtos.ndim != 2:
90
+ raise RuntimeError("O arquivo de embeddings precisa conter uma matriz 2D.")
91
+
92
+ def runtime_info(self):
93
+ return {
94
+ "model_repo": MODEL_NAME,
95
+ "device": self.device,
96
+ "products_loaded": 0 if self.df_produtos is None else int(len(self.df_produtos)),
97
+ "embeddings_loaded": 0 if self.emb_produtos is None else int(len(self.emb_produtos)),
98
+ "embedding_dim": 0 if self.emb_produtos is None else int(self.emb_produtos.shape[1]),
99
+ }
100
+
101
+ def _refresh_negative_memory(self, force=False):
102
+ if not os.path.exists(PATH_NEGATIVE_MEMORY):
103
+ self.df_negative_memory = pd.DataFrame()
104
+ self.negative_memory_mtime = None
105
+ return
106
+
107
+ current_mtime = os.path.getmtime(PATH_NEGATIVE_MEMORY)
108
+ if not force and self.negative_memory_mtime == current_mtime:
109
+ return
110
+
111
+ df = pd.read_csv(PATH_NEGATIVE_MEMORY)
112
+ df.columns = df.columns.str.strip().str.lower()
113
+
114
+ for col in ["query", "product_id", "product_name", "motivo", "rating"]:
115
+ if col not in df.columns:
116
+ df[col] = ""
117
+
118
+ df["query"] = df["query"].fillna("").astype(str)
119
+ df["query_limpa"] = df["query"].apply(limpar_texto)
120
+ df["product_id"] = df["product_id"].fillna("").astype(str)
121
+ df["product_name"] = df["product_name"].fillna("").astype(str)
122
+ df["motivo"] = df["motivo"].fillna("").astype(str)
123
+ df["rating_num"] = pd.to_numeric(df["rating"], errors="coerce")
124
+
125
+ self.df_negative_memory = df
126
+ self.negative_memory_mtime = current_mtime
127
+
128
+ def _similaridade_consulta(self, query_atual, query_memoria):
129
+ if not query_atual or not query_memoria:
130
+ return 0.0
131
+
132
+ if query_atual == query_memoria:
133
+ return 1.0
134
+
135
+ termos_atuais = set(query_atual.split())
136
+ termos_memoria = set(query_memoria.split())
137
+
138
+ if not termos_atuais or not termos_memoria:
139
+ return 0.0
140
+
141
+ intersecao = len(termos_atuais & termos_memoria)
142
+ if intersecao == 0:
143
+ return 0.0
144
+
145
+ return intersecao / max(len(termos_atuais), len(termos_memoria))
146
+
147
+ def _calcular_penalidade_feedback(self, query_text, df_filtrado):
148
+ self._refresh_negative_memory()
149
+
150
+ if self.df_negative_memory.empty:
151
+ return np.zeros(len(df_filtrado))
152
+
153
+ query_limpa = limpar_texto(query_text)
154
+ memorias = self.df_negative_memory[
155
+ self.df_negative_memory["product_id"].isin(df_filtrado["product_id"].astype(str))
156
+ ]
157
+
158
+ if memorias.empty:
159
+ return np.zeros(len(df_filtrado))
160
+
161
+ penalidades = {}
162
+
163
+ for _, memoria in memorias.iterrows():
164
+ similaridade = self._similaridade_consulta(query_limpa, memoria["query_limpa"])
165
+ if similaridade <= 0:
166
+ continue
167
+
168
+ penalidade = 0.08 + (0.12 * similaridade)
169
+
170
+ if memoria["motivo"] == "nao_foi_util":
171
+ penalidade += 0.04
172
+
173
+ if pd.notna(memoria["rating_num"]) and memoria["rating_num"] <= 2:
174
+ penalidade += 0.04
175
+
176
+ product_id = memoria["product_id"]
177
+ penalidades[product_id] = min(penalidades.get(product_id, 0.0) + penalidade, 0.45)
178
+
179
+ return df_filtrado["product_id"].astype(str).map(lambda x: penalidades.get(x, 0.0)).values
180
+
181
+ def gerar_embedding_unico(self, texto):
182
+ embedding = self.model.encode(
183
+ texto,
184
+ convert_to_numpy=True,
185
+ normalize_embeddings=False,
186
+ show_progress_bar=False,
187
+ )
188
+ return np.asarray(embedding, dtype=np.float32)
189
+
190
+ def buscar(self, query_text, top_k=5):
191
+ query_limpa = limpar_texto(query_text)
192
+ categoria = inferir_categoria_consulta(query_limpa)
193
+
194
+ if categoria is not None:
195
+ mask = self.df_produtos["categoria_grupo"] == categoria
196
+ df_filtrado = self.df_produtos[mask].copy()
197
+ idx_filtrado = df_filtrado.index.tolist()
198
+ else:
199
+ df_filtrado = self.df_produtos.copy()
200
+ idx_filtrado = df_filtrado.index.tolist()
201
+
202
+ if len(df_filtrado) == 0:
203
+ df_filtrado = self.df_produtos.copy()
204
+ idx_filtrado = df_filtrado.index.tolist()
205
+
206
+ emb_query = self.gerar_embedding_unico(query_text).reshape(1, -1)
207
+ emb_base = self.emb_produtos[idx_filtrado]
208
+
209
+ if emb_base.shape[1] != emb_query.shape[1]:
210
+ raise RuntimeError(
211
+ "Dimensao incompatível entre os embeddings salvos e o embedding da consulta. "
212
+ "Regenere o arquivo .npy com o mesmo modelo SentenceTransformer."
213
+ )
214
+
215
+ sims = cosine_similarity(emb_query, emb_base)[0]
216
+
217
+ bonus = df_filtrado.apply(
218
+ lambda row: bonus_lexical(
219
+ query_text,
220
+ row["product_name"],
221
+ row["categoria_principal"],
222
+ row["neighborhood"],
223
+ row["region"],
224
+ row["description"],
225
+ row["texto_busca_reforcado"],
226
+ ),
227
+ axis=1,
228
+ ).values
229
+
230
+ penalidade_feedback = self._calcular_penalidade_feedback(query_text, df_filtrado)
231
+ score_final = sims + bonus - penalidade_feedback
232
+
233
+ top_idx_local = np.argsort(score_final)[::-1][:top_k]
234
+
235
+ resultados = []
236
+ for rank, idx_local in enumerate(top_idx_local, start=1):
237
+ idx_global = idx_filtrado[idx_local]
238
+ prod = self.df_produtos.iloc[idx_global]
239
+
240
+ resultados.append({
241
+ "rank": rank,
242
+ "establishment_id": str(prod["establishment_id"]),
243
+ "product_id": str(prod["product_id"]),
244
+ "product_name": prod["product_name"],
245
+ "categoria_principal": prod["categoria_principal"],
246
+ "categoria_grupo": prod["categoria_grupo"],
247
+ "region": prod["region"],
248
+ "neighborhood": prod["neighborhood"],
249
+ "score_semantico": float(sims[idx_local]),
250
+ "bonus_lexical": float(bonus[idx_local]),
251
+ "penalidade_feedback": float(penalidade_feedback[idx_local]),
252
+ "score_final": float(score_final[idx_local]),
253
+ })
254
+
255
+ return {
256
+ "query": query_text,
257
+ "categoria_inferida": categoria,
258
+ "resultados": resultados,
259
+ }
app/test_agent.py ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from .agent import ShoppingAgent
2
+
3
+ agent = ShoppingAgent()
4
+
5
+ resultado = agent.responder("coca cola 2l")
6
+
7
+ print("Consulta:", resultado["query"])
8
+ print("Categoria inferida:", resultado["categoria_inferida"])
9
+ print("Resposta do agente:", resultado["answer"])
10
+ print("\nProdutos encontrados:")
11
+
12
+ for item in resultado["products"]:
13
+ print(
14
+ item["rank"],
15
+ item["product_name"],
16
+ item["categoria_principal"],
17
+ item["score_final"]
18
+ )
app/test_search.py ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from .search import SearchEngine
2
+
3
+ engine = SearchEngine()
4
+ engine.load()
5
+
6
+ resultado = engine.buscar("coca cola 2l", top_k=5)
7
+
8
+ print("Consulta:", resultado["query"])
9
+ print("Categoria inferida:", resultado["categoria_inferida"])
10
+
11
+ for item in resultado["resultados"]:
12
+ print(
13
+ item["rank"],
14
+ item["product_name"],
15
+ item["categoria_principal"],
16
+ item["score_final"]
17
+ )
app/utils.py ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import re
2
+ import unicodedata
3
+ import pandas as pd
4
+
5
+
6
+ def limpar_texto(texto):
7
+ if pd.isna(texto):
8
+ return ""
9
+
10
+ texto = str(texto).lower().strip()
11
+ texto = unicodedata.normalize("NFKD", texto)
12
+ texto = "".join(c for c in texto if not unicodedata.combining(c))
13
+
14
+ texto = re.sub(r"[\n\r\t]", " ", texto)
15
+ texto = re.sub(r"[^a-z0-9\s]", " ", texto)
16
+ texto = re.sub(r"\s+", " ", texto).strip()
17
+
18
+ return texto
19
+
20
+
21
+ def mapear_categoria(cat):
22
+ cat = limpar_texto(cat)
23
+
24
+ if "acai" in cat:
25
+ return "acai"
26
+ if "pastel" in cat or "pastel de pizza" in cat:
27
+ return "pastel"
28
+ if "pizza" in cat:
29
+ return "pizza"
30
+ if "hamburg" in cat or "burger" in cat:
31
+ return "hamburguer"
32
+ if "sushi" in cat or "japones" in cat or "oriental" in cat:
33
+ return "japones"
34
+ if "suco" in cat:
35
+ return "suco"
36
+ if "bebida" in cat or "refrigerante" in cat or "refri" in cat:
37
+ return "bebida"
38
+
39
+ return cat
40
+
41
+
42
+ def inferir_categoria_consulta(query):
43
+ q = limpar_texto(query)
44
+
45
+ if "acai" in q:
46
+ return "acai"
47
+ if "pastel" in q or "pastel de pizza" in q:
48
+ return "pastel"
49
+ if "pizza" in q:
50
+ return "pizza"
51
+ if "hamburguer" in q or "burger" in q or "x bacon" in q:
52
+ return "hamburguer"
53
+ if "sushi" in q or "temaki" in q:
54
+ return "japones"
55
+ if "suco" in q:
56
+ return "suco"
57
+ if "coca" in q or "refrigerante" in q or "refri" in q:
58
+ return "bebida"
59
+
60
+ return None
61
+
62
+
63
+ def bonus_lexical(query, *texts):
64
+ q = limpar_texto(query)
65
+ referencias = [limpar_texto(texto) for texto in texts if texto]
66
+
67
+ bonus = 0.0
68
+
69
+ for termo in q.split():
70
+ if any(termo in referencia for referencia in referencias):
71
+ bonus += 0.03
72
+
73
+ return bonus
data/embeddings_produtos_bertimbau_reforcado.npy ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:fd9acd51ae3ccf45d25108f07c4aa51c662ed9c77f38a728c0853199152687ed
3
+ size 158850176
data/embeddings_produtos_finetunado.npy ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:96ed5c483e191b957161d75c711b7d268b84a5434aa92d41a1f910e975136a2c
3
+ size 158850176
data/products_tratado_textobusca.csv ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:4fd942f41249a721b2342e5a72b8ab0c3a2799ba8e0fe4b78732068c0f7b10ed
3
+ size 31993441
data/produtos_finetunado.csv ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:dade0a9d6c3ecf4c98b49bac5e03f46ddea2da8cbf059bd1d43952162d6e63ba
3
+ size 31961695
fly.toml ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # fly.toml app configuration file generated for backend-damp-fog-5601 on 2026-03-26T21:56:01-03:00
2
+ #
3
+ # See https://fly.io/docs/reference/configuration/ for information about how to use this file.
4
+ #
5
+
6
+ app = 'backend-damp-fog-5601'
7
+ primary_region = 'gru'
8
+
9
+ [build]
10
+ dockerfile = 'Dockerfile'
11
+
12
+ [env]
13
+ PORT = '7860'
14
+ PRELOAD_AGENT = 'true'
15
+ EMBEDDING_PROVIDER = 'transformers'
16
+ HF_MODEL_REPO = 'Ana2012/bertimbau-buscador'
17
+ HF_HOME = '/home/user/.cache/huggingface'
18
+ HF_HUB_CACHE = '/home/user/.cache/huggingface/hub'
19
+ TRANSFORMERS_CACHE = '/home/user/.cache/huggingface/transformers'
20
+
21
+ [processes]
22
+ app = "sh -c 'python -m uvicorn app.main:app --host 0.0.0.0 --port ${PORT:-7860}'"
23
+
24
+ [http_service]
25
+ internal_port = 7860
26
+ force_https = true
27
+ auto_stop_machines = 'stop'
28
+ auto_start_machines = true
29
+ min_machines_running = 1
30
+ processes = ['app']
31
+
32
+ [[mounts]]
33
+ source = "data"
34
+ destination = "/data"
35
+
36
+ [[vm]]
37
+ memory = '2gb'
38
+ cpus = 1
39
+ memory_mb = 2048
requirements.txt ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ fastapi
2
+ uvicorn[standard]
3
+ python-dotenv
4
+ pandas
5
+ numpy
6
+ torch
7
+ transformers
8
+ sentence-transformers
9
+ huggingface-hub
10
+ safetensors
11
+ scikit-learn