Ana2012 commited on
Commit
33203e9
·
verified ·
1 Parent(s): 09fd6c0

Upload 11 files

Browse files
Files changed (10) hide show
  1. __init__.py +0 -0
  2. agent.py +56 -0
  3. feedback.py +168 -0
  4. logger.py +60 -0
  5. main.py +238 -0
  6. memory.py +51 -0
  7. search.py +259 -0
  8. test_agent.py +18 -0
  9. test_search.py +17 -0
  10. utils.py +73 -0
__init__.py ADDED
File without changes
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
+ }
feedback.py ADDED
@@ -0,0 +1,168 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import csv
2
+ import os
3
+ from datetime import datetime, timezone
4
+
5
+ from .google_oauth import GoogleAuthRequiredError, append_feedback_to_sheet, load_credentials
6
+ from .memory import salvar_memoria_negativa
7
+
8
+
9
+ BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
10
+ LOGS_DIR = os.getenv("LOGS_DIR", "/data/logs")
11
+ FEEDBACK_FILE = os.path.join(LOGS_DIR, "feedback.csv")
12
+ FEEDBACK_HEADERS = [
13
+ "timestamp",
14
+ "query",
15
+ "product_id",
16
+ "product_name",
17
+ "rating",
18
+ "is_helpful",
19
+ "note",
20
+ "categoria_inferida",
21
+ "feedback",
22
+ "motivo",
23
+ "score_final",
24
+ "score_semantico",
25
+ "bonus_lexical",
26
+ "penalidade_feedback",
27
+ "user_message",
28
+ ]
29
+
30
+
31
+ def garantir_pasta_logs():
32
+ os.makedirs(LOGS_DIR, exist_ok=True)
33
+
34
+
35
+ def inicializar_arquivo_feedback():
36
+ garantir_pasta_logs()
37
+
38
+ if not os.path.exists(FEEDBACK_FILE):
39
+ with open(FEEDBACK_FILE, mode="w", newline="", encoding="utf-8") as f:
40
+ writer = csv.writer(f)
41
+ writer.writerow(FEEDBACK_HEADERS)
42
+
43
+
44
+ def google_sheets_habilitado():
45
+ return load_credentials() is not None and bool(os.getenv("GOOGLE_SPREADSHEET_ID", "").strip())
46
+
47
+
48
+ def _append_feedback_csv(row):
49
+ inicializar_arquivo_feedback()
50
+
51
+ with open(FEEDBACK_FILE, mode="a", newline="", encoding="utf-8") as f:
52
+ writer = csv.writer(f)
53
+ writer.writerow([row.get(header, "") for header in FEEDBACK_HEADERS])
54
+
55
+
56
+ def _build_feedback_payload(
57
+ query,
58
+ product_id,
59
+ product_name,
60
+ rating=None,
61
+ is_helpful=None,
62
+ note=None,
63
+ categoria_inferida=None,
64
+ feedback=None,
65
+ motivo=None,
66
+ score_final=None,
67
+ score_semantico=None,
68
+ bonus_lexical=None,
69
+ penalidade_feedback=None,
70
+ user_message=None,
71
+ ):
72
+ return {
73
+ "timestamp": datetime.now(timezone.utc).isoformat(),
74
+ "query": query,
75
+ "product_id": product_id,
76
+ "product_name": product_name,
77
+ "rating": rating if rating is not None else "",
78
+ "is_helpful": is_helpful if is_helpful is not None else "",
79
+ "note": note or "",
80
+ "categoria_inferida": categoria_inferida or "",
81
+ "feedback": feedback or "",
82
+ "motivo": motivo or "",
83
+ "score_final": score_final if score_final is not None else "",
84
+ "score_semantico": score_semantico if score_semantico is not None else "",
85
+ "bonus_lexical": bonus_lexical if bonus_lexical is not None else "",
86
+ "penalidade_feedback": penalidade_feedback if penalidade_feedback is not None else "",
87
+ "user_message": user_message or "",
88
+ }
89
+
90
+
91
+ def salvar_feedback(
92
+ query,
93
+ product_id,
94
+ product_name,
95
+ rating=None,
96
+ is_helpful=None,
97
+ note=None,
98
+ categoria_inferida=None,
99
+ feedback=None,
100
+ motivo=None,
101
+ score_final=None,
102
+ score_semantico=None,
103
+ bonus_lexical=None,
104
+ penalidade_feedback=None,
105
+ user_message=None,
106
+ ):
107
+ row = _build_feedback_payload(
108
+ query=query,
109
+ product_id=product_id,
110
+ product_name=product_name,
111
+ rating=rating,
112
+ is_helpful=is_helpful,
113
+ note=note,
114
+ categoria_inferida=categoria_inferida,
115
+ feedback=feedback,
116
+ motivo=motivo,
117
+ score_final=score_final,
118
+ score_semantico=score_semantico,
119
+ bonus_lexical=bonus_lexical,
120
+ penalidade_feedback=penalidade_feedback,
121
+ user_message=user_message,
122
+ )
123
+
124
+ _append_feedback_csv(row)
125
+ saved_google_sheets = False
126
+ warning = None
127
+
128
+ try:
129
+ append_feedback_to_sheet(row)
130
+ saved_google_sheets = True
131
+ except GoogleAuthRequiredError:
132
+ warning = "Feedback salvo localmente, mas nao enviado ao Google Sheets. Acesse /auth/google para autorizar."
133
+ except Exception as exc:
134
+ warning = (
135
+ "Feedback salvo localmente, mas nao enviado ao Google Sheets. "
136
+ f"Erro de sincronizacao: {exc}"
137
+ )
138
+
139
+ if rating is not None and rating <= 2:
140
+ salvar_memoria_negativa(
141
+ query=query,
142
+ product_id=product_id,
143
+ product_name=product_name,
144
+ rating=rating,
145
+ motivo="rating_baixo",
146
+ )
147
+
148
+ if is_helpful is False:
149
+ salvar_memoria_negativa(
150
+ query=query,
151
+ product_id=product_id,
152
+ product_name=product_name,
153
+ rating=rating if rating is not None else "",
154
+ motivo="nao_foi_util",
155
+ )
156
+
157
+ response = {
158
+ "ok": True,
159
+ "saved_local": True,
160
+ "saved_google_sheets": saved_google_sheets,
161
+ }
162
+ if warning:
163
+ response["warning"] = warning
164
+ return response
165
+
166
+
167
+ def caminho_feedback():
168
+ return FEEDBACK_FILE
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"}
main.py ADDED
@@ -0,0 +1,238 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import threading
3
+ from pathlib import Path
4
+ from typing import Optional
5
+
6
+ from fastapi import FastAPI, Query, Response
7
+ from fastapi.middleware.cors import CORSMiddleware
8
+ from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
9
+ from pydantic import BaseModel
10
+
11
+ from .agent import ShoppingAgent
12
+ from .feedback import caminho_feedback, google_sheets_habilitado, salvar_feedback
13
+ from .google_oauth import build_flow, get_authorization_url, load_credentials, save_credentials
14
+ from .logger import salvar_log_busca
15
+ from .memory import caminho_memoria_negativa
16
+
17
+ EMBEDDING_PROVIDER = os.getenv("EMBEDDING_PROVIDER", "transformers").strip().lower()
18
+ HF_MODEL_REPO = os.getenv("HF_MODEL_REPO", "Ana2012/bertimbau-buscador").strip()
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
+ allow_origins=["*"],
34
+ allow_credentials=False,
35
+ allow_methods=["*"],
36
+ allow_headers=["*"],
37
+ )
38
+
39
+ agent = None
40
+ agent_lock = threading.Lock()
41
+
42
+
43
+ def get_agent():
44
+ global agent
45
+ if agent is None:
46
+ with agent_lock:
47
+ if agent is None:
48
+ agent = ShoppingAgent()
49
+ return agent
50
+
51
+
52
+ @app.on_event("startup")
53
+ def preload_agent():
54
+ if PRELOAD_AGENT:
55
+ get_agent()
56
+
57
+
58
+ class ChatRequest(BaseModel):
59
+ query: Optional[str] = None
60
+ message: Optional[str] = None
61
+ top_k: int = 5
62
+
63
+
64
+ class FeedbackRequest(BaseModel):
65
+ query: str
66
+ product_id: str
67
+ product_name: str
68
+ rating: Optional[int] = None
69
+ is_helpful: Optional[bool] = None
70
+ note: Optional[str] = None
71
+ categoria_inferida: Optional[str] = None
72
+ feedback: Optional[str] = None
73
+ motivo: Optional[str] = None
74
+ score_final: Optional[float] = None
75
+ score_semantico: Optional[float] = None
76
+ bonus_lexical: Optional[float] = None
77
+ penalidade_feedback: Optional[float] = None
78
+ user_message: Optional[str] = None
79
+
80
+
81
+ @app.get("/health")
82
+ def health():
83
+ runtime = get_agent().runtime_info() if agent is not None else None
84
+ return {
85
+ "status": "ok",
86
+ "agent_ready": agent is not None,
87
+ "embedding_provider": EMBEDDING_PROVIDER,
88
+ "model_repo": HF_MODEL_REPO,
89
+ "preload_agent": PRELOAD_AGENT,
90
+ "runtime": runtime,
91
+ "feedback_storage": "google_sheets" if google_sheets_habilitado() else "csv",
92
+ }
93
+
94
+
95
+ @app.get("/", include_in_schema=False)
96
+ def root():
97
+ return RedirectResponse(url="/docs")
98
+
99
+
100
+ @app.get("/favicon.ico", include_in_schema=False)
101
+ def favicon():
102
+ return Response(status_code=204)
103
+
104
+
105
+ @app.get("/auth/google")
106
+ def auth_google():
107
+ authorization_url, _state = get_authorization_url()
108
+ return RedirectResponse(url=authorization_url)
109
+
110
+
111
+ @app.get("/oauth2callback")
112
+ def oauth2callback(code: str = Query(...)):
113
+ flow = build_flow()
114
+ flow.fetch_token(code=code)
115
+ save_credentials(flow.credentials)
116
+ return HTMLResponse(
117
+ "<h3>Autorizacao concluida. O backend ja pode salvar feedbacks no Google Sheets.</h3>"
118
+ )
119
+
120
+
121
+ @app.get("/auth/status")
122
+ def auth_status():
123
+ credentials = load_credentials()
124
+ connected = credentials is not None and credentials.valid
125
+ return {
126
+ "google_sheets_connected": connected,
127
+ "message": (
128
+ "Google Sheets autorizado e pronto para uso."
129
+ if connected
130
+ else "Google Sheets ainda nao autorizado. Acesse /auth/google para conectar."
131
+ ),
132
+ }
133
+
134
+
135
+ @app.get("/debug/files")
136
+ def debug_files():
137
+ data_path = Path(DATA_DIR)
138
+ logs_path = Path(LOGS_DIR)
139
+ feedback_path = Path(caminho_feedback())
140
+ memory_path = Path(caminho_memoria_negativa())
141
+
142
+ return {
143
+ "data_exists": data_path.exists(),
144
+ "logs_exists": logs_path.exists(),
145
+ "feedback_exists": feedback_path.exists(),
146
+ "negative_memory_exists": memory_path.exists(),
147
+ "data_files": sorted(p.name for p in data_path.iterdir()) if data_path.exists() else [],
148
+ "logs_files": sorted(p.name for p in logs_path.iterdir()) if logs_path.exists() else [],
149
+ "feedback_file": str(feedback_path),
150
+ "negative_memory_file": str(memory_path),
151
+ "feedback_storage": "google_sheets" if google_sheets_habilitado() else "csv",
152
+ }
153
+
154
+
155
+ @app.get("/debug/feedback")
156
+ def debug_feedback():
157
+ feedback_path = Path(caminho_feedback())
158
+ if not feedback_path.exists():
159
+ return {"error": "arquivo nao existe"}
160
+
161
+ return {"conteudo": feedback_path.read_text(encoding="utf-8")}
162
+
163
+
164
+ @app.get("/download/feedback")
165
+ def download_feedback():
166
+ feedback_path = caminho_feedback()
167
+ if not os.path.exists(feedback_path):
168
+ return {"error": "arquivo nao existe"}
169
+
170
+ return FileResponse(feedback_path, filename="feedback.csv")
171
+
172
+
173
+ @app.get("/debug/memory")
174
+ def debug_memory():
175
+ memory_path = Path(caminho_memoria_negativa())
176
+ if not memory_path.exists():
177
+ return {"status": "missing", "file": str(memory_path)}
178
+
179
+ return {
180
+ "status": "ok",
181
+ "file": str(memory_path),
182
+ "content": memory_path.read_text(encoding="utf-8"),
183
+ }
184
+
185
+
186
+ @app.post("/chat")
187
+ def chat(request: ChatRequest):
188
+ texto = request.query or request.message
189
+
190
+ if not texto:
191
+ return {"error": "query ou message deve ser informado"}
192
+
193
+ resultado = get_agent().responder(texto, top_k=request.top_k)
194
+ salvar_log_busca(resultado)
195
+ return resultado
196
+
197
+
198
+ @app.post("/feedback")
199
+ def feedback(request: FeedbackRequest):
200
+ feedback_file = caminho_feedback()
201
+ print(
202
+ "Salvando feedback:",
203
+ {
204
+ "query": request.query,
205
+ "product_id": request.product_id,
206
+ "feedback_file": feedback_file,
207
+ "logs_dir_exists": os.path.exists(LOGS_DIR),
208
+ "google_sheets_enabled": google_sheets_habilitado(),
209
+ },
210
+ )
211
+
212
+ try:
213
+ return salvar_feedback(
214
+ query=request.query,
215
+ product_id=request.product_id,
216
+ product_name=request.product_name,
217
+ rating=request.rating,
218
+ is_helpful=request.is_helpful,
219
+ note=request.note,
220
+ categoria_inferida=request.categoria_inferida,
221
+ feedback=request.feedback,
222
+ motivo=request.motivo,
223
+ score_final=request.score_final,
224
+ score_semantico=request.score_semantico,
225
+ bonus_lexical=request.bonus_lexical,
226
+ penalidade_feedback=request.penalidade_feedback,
227
+ user_message=request.user_message,
228
+ )
229
+ except Exception as exc:
230
+ return {
231
+ "ok": False,
232
+ "saved_local": False,
233
+ "saved_google_sheets": False,
234
+ "detail": str(exc),
235
+ "feedback_file": feedback_file,
236
+ "logs_dir_exists": os.path.exists(LOGS_DIR),
237
+ "google_sheets_enabled": google_sheets_habilitado(),
238
+ }
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
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
+ }
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
+ )
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
+ )
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