Ana2012 commited on
Commit
50136de
·
verified ·
1 Parent(s): 5e4fe38

Update app/memory.py

Browse files
Files changed (1) hide show
  1. app/memory.py +261 -75
app/memory.py CHANGED
@@ -1,82 +1,268 @@
1
- import csv
2
  import os
3
- import tempfile
4
- from datetime import datetime, timezone
5
- from pathlib import Path
6
 
 
 
 
 
 
7
 
8
- DEFAULT_LOGS_DIR = Path(os.getenv("LOGS_DIR", "/data/logs"))
9
- FALLBACK_LOGS_DIR = Path(tempfile.gettempdir()) / "tcc2_agent" / "logs"
 
 
 
 
 
10
 
11
 
12
- def _resolve_logs_dir():
13
- preferred_dir = os.environ.get("LOGS_DIR", "").strip()
14
- candidates = [Path(preferred_dir)] if preferred_dir else []
15
- candidates.extend([DEFAULT_LOGS_DIR, FALLBACK_LOGS_DIR])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
 
17
- for directory in candidates:
18
  try:
19
- directory.mkdir(parents=True, exist_ok=True)
20
- return directory
21
- except OSError:
22
- continue
23
-
24
- raise OSError(
25
- "Nao foi possivel criar um diretorio para armazenar a memoria negativa. "
26
- "Defina LOGS_DIR para um caminho gravavel."
27
- )
28
-
29
-
30
- def caminho_memoria_negativa():
31
- return str(_resolve_logs_dir() / "negative_memory.csv")
32
-
33
-
34
- def inicializar_memoria_negativa():
35
- memory_file = caminho_memoria_negativa()
36
-
37
- if not os.path.exists(memory_file):
38
- with open(memory_file, "w", newline="", encoding="utf-8") as f:
39
- writer = csv.writer(f)
40
- writer.writerow([
41
- "timestamp",
42
- "search_id",
43
- "rank",
44
- "query",
45
- "product_id",
46
- "product_name",
47
- "rating",
48
- "motivo",
49
- ])
50
-
51
-
52
- def salvar_memoria_negativa(
53
- query,
54
- product_id,
55
- product_name,
56
- rating,
57
- motivo="feedback_negativo",
58
- search_id=None,
59
- rank=None,
60
- ):
61
- inicializar_memoria_negativa()
62
- memory_file = caminho_memoria_negativa()
63
-
64
- row = [
65
- datetime.now(timezone.utc).isoformat(), # padrão UTC
66
- search_id or "",
67
- rank if rank is not None else "",
68
- query or "",
69
- product_id or "",
70
- product_name or "",
71
- rating if rating is not None else "",
72
- motivo or "feedback_negativo",
73
- ]
74
-
75
- try:
76
- with open(memory_file, "a", newline="", encoding="utf-8") as f:
77
- writer = csv.writer(f)
78
- writer.writerow(row)
79
- except Exception as e:
80
- print("Erro ao salvar memoria negativa:", e)
81
-
82
- return {"status": "ok"}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 .memory import caminho_memoria_negativa
10
+ from .utils import (
11
+ bonus_lexical,
12
+ inferir_categoria_consulta,
13
+ limpar_texto,
14
+ mapear_categoria,
15
+ )
16
 
17
 
18
+ BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
19
+ DATA_DIR = os.path.join(BASE_DIR, "data")
20
+ PATH_PRODUCTS = os.path.join(DATA_DIR, "produtos_finetunado.csv")
21
+ PATH_EMBEDDINGS = os.path.join(DATA_DIR, "embeddings_produtos_finetunado.npy")
22
+
23
+ MODEL_NAME = os.getenv("HF_MODEL_REPO", "Ana2012/bertimbau-buscador").strip()
24
+ HF_API_TOKEN = os.getenv("HF_API_TOKEN", "").strip()
25
+
26
+
27
+ class SearchEngine:
28
+ def __init__(self):
29
+ self.device = "cuda" if torch.cuda.is_available() else "cpu"
30
+ self.model = None
31
+ self.df_produtos = None
32
+ self.emb_produtos = None
33
+ self.df_negative_memory = pd.DataFrame()
34
+ self.negative_memory_mtime = None
35
+
36
+ def load(self):
37
+ self._load_products()
38
+ self._load_model()
39
+ self._load_embeddings()
40
+ self._refresh_negative_memory(force=True)
41
+
42
+ def _load_products(self):
43
+ df = pd.read_csv(PATH_PRODUCTS)
44
+ df.columns = df.columns.str.strip().str.lower()
45
+
46
+ df["product_name"] = df["product_name"].fillna("").astype(str)
47
+ df["description"] = df["description"].fillna("").astype(str)
48
+ df["categoria_principal"] = df["categoria_principal"].fillna("").astype(str)
49
+ df["category_names_text"] = df["category_names_text"].fillna("").astype(str)
50
+ df["region"] = df["region"].fillna("").astype(str)
51
+ df["neighborhood"] = df["neighborhood"].fillna("").astype(str)
52
+
53
+ df["product_name_limpo"] = df["product_name"].apply(limpar_texto)
54
+ df["description_limpa"] = df["description"].apply(limpar_texto)
55
+ df["categoria_principal_limpa"] = df["categoria_principal"].apply(limpar_texto)
56
+ df["category_names_text_limpo"] = df["category_names_text"].apply(limpar_texto)
57
+ df["region_limpa"] = df["region"].apply(limpar_texto)
58
+ df["neighborhood_limpo"] = df["neighborhood"].apply(limpar_texto)
59
+
60
+ df["texto_busca_reforcado"] = (
61
+ "produto " + df["product_name_limpo"] + " "
62
+ + "categoria " + df["categoria_principal_limpa"] + " "
63
+ + "categorias " + df["category_names_text_limpo"] + " "
64
+ + "bairro " + df["neighborhood_limpo"] + " "
65
+ + "regiao " + df["region_limpa"] + " "
66
+ + "descricao " + df["description_limpa"]
67
+ ).str.strip()
68
+
69
+ df["categoria_grupo"] = df["categoria_principal"].apply(mapear_categoria)
70
+
71
+ self.df_produtos = df
72
+
73
+ def _load_model(self):
74
+ kwargs = {"device": self.device}
75
+ if HF_API_TOKEN:
76
+ kwargs["token"] = HF_API_TOKEN
77
+
78
+ self.model = SentenceTransformer(MODEL_NAME, **kwargs)
79
+
80
+ def _load_embeddings(self):
81
+ self.emb_produtos = np.load(PATH_EMBEDDINGS)
82
+
83
+ if self.emb_produtos.ndim != 2:
84
+ raise RuntimeError("O arquivo de embeddings precisa conter uma matriz 2D.")
85
+
86
+ def runtime_info(self):
87
+ return {
88
+ "model_repo": MODEL_NAME,
89
+ "device": self.device,
90
+ "products_loaded": 0 if self.df_produtos is None else int(len(self.df_produtos)),
91
+ "embeddings_loaded": 0 if self.emb_produtos is None else int(len(self.emb_produtos)),
92
+ "embedding_dim": 0 if self.emb_produtos is None else int(self.emb_produtos.shape[1]),
93
+ }
94
+
95
+ def _refresh_negative_memory(self, force=False):
96
+ memory_path = caminho_memoria_negativa()
97
+
98
+ if not os.path.exists(memory_path):
99
+ self.df_negative_memory = pd.DataFrame()
100
+ self.negative_memory_mtime = None
101
+ return
102
+
103
+ current_mtime = os.path.getmtime(memory_path)
104
+ if not force and self.negative_memory_mtime == current_mtime:
105
+ return
106
 
 
107
  try:
108
+ df = pd.read_csv(memory_path)
109
+ except Exception:
110
+ self.df_negative_memory = pd.DataFrame()
111
+ self.negative_memory_mtime = current_mtime
112
+ return
113
+
114
+ df.columns = df.columns.str.strip().str.lower()
115
+
116
+ for col in ["query", "product_id", "product_name", "motivo", "rating"]:
117
+ if col not in df.columns:
118
+ df[col] = ""
119
+
120
+ df["query"] = df["query"].fillna("").astype(str)
121
+ df["query_limpa"] = df["query"].apply(limpar_texto)
122
+ df["product_id"] = df["product_id"].fillna("").astype(str)
123
+ df["product_name"] = df["product_name"].fillna("").astype(str)
124
+ df["motivo"] = df["motivo"].fillna("").astype(str)
125
+ df["rating_num"] = pd.to_numeric(df["rating"], errors="coerce")
126
+
127
+ self.df_negative_memory = df
128
+ self.negative_memory_mtime = current_mtime
129
+
130
+ def _similaridade_consulta(self, query_atual, query_memoria):
131
+ if not query_atual or not query_memoria:
132
+ return 0.0
133
+
134
+ if query_atual == query_memoria:
135
+ return 1.0
136
+
137
+ termos_atuais = set(query_atual.split())
138
+ termos_memoria = set(query_memoria.split())
139
+
140
+ if not termos_atuais or not termos_memoria:
141
+ return 0.0
142
+
143
+ intersecao = len(termos_atuais & termos_memoria)
144
+ if intersecao == 0:
145
+ return 0.0
146
+
147
+ return intersecao / max(len(termos_atuais), len(termos_memoria))
148
+
149
+ def _calcular_penalidade_feedback(self, query_text, df_filtrado):
150
+ self._refresh_negative_memory()
151
+
152
+ if self.df_negative_memory.empty:
153
+ return np.zeros(len(df_filtrado))
154
+
155
+ query_limpa = limpar_texto(query_text)
156
+ memorias = self.df_negative_memory[
157
+ self.df_negative_memory["product_id"].isin(df_filtrado["product_id"].astype(str))
158
+ ]
159
+
160
+ if memorias.empty:
161
+ return np.zeros(len(df_filtrado))
162
+
163
+ penalidades = {}
164
+
165
+ for _, memoria in memorias.iterrows():
166
+ similaridade = self._similaridade_consulta(query_limpa, memoria["query_limpa"])
167
+ if similaridade <= 0:
168
+ continue
169
+
170
+ penalidade = 0.08 + (0.12 * similaridade)
171
+
172
+ if memoria["motivo"] == "nao_foi_util":
173
+ penalidade += 0.04
174
+
175
+ if pd.notna(memoria["rating_num"]) and memoria["rating_num"] <= 2:
176
+ penalidade += 0.04
177
+
178
+ product_id = memoria["product_id"]
179
+ penalidades[product_id] = min(
180
+ penalidades.get(product_id, 0.0) + penalidade,
181
+ 0.45,
182
+ )
183
+
184
+ return df_filtrado["product_id"].astype(str).map(
185
+ lambda x: penalidades.get(x, 0.0)
186
+ ).values
187
+
188
+ def gerar_embedding_unico(self, texto):
189
+ embedding = self.model.encode(
190
+ texto,
191
+ convert_to_numpy=True,
192
+ normalize_embeddings=False,
193
+ show_progress_bar=False,
194
+ )
195
+ return np.asarray(embedding, dtype=np.float32)
196
+
197
+ def buscar(self, query_text, top_k=5):
198
+ top_k = max(1, int(top_k or 5))
199
+
200
+ query_limpa = limpar_texto(query_text)
201
+ categoria = inferir_categoria_consulta(query_limpa)
202
+
203
+ if categoria is not None:
204
+ mask = self.df_produtos["categoria_grupo"] == categoria
205
+ df_filtrado = self.df_produtos[mask].copy()
206
+ idx_filtrado = df_filtrado.index.tolist()
207
+ else:
208
+ df_filtrado = self.df_produtos.copy()
209
+ idx_filtrado = df_filtrado.index.tolist()
210
+
211
+ if len(df_filtrado) == 0:
212
+ df_filtrado = self.df_produtos.copy()
213
+ idx_filtrado = df_filtrado.index.tolist()
214
+
215
+ emb_query = self.gerar_embedding_unico(query_text).reshape(1, -1)
216
+ emb_base = self.emb_produtos[idx_filtrado]
217
+
218
+ if emb_base.shape[1] != emb_query.shape[1]:
219
+ raise RuntimeError(
220
+ "Dimensao incompatível entre os embeddings salvos e o embedding da consulta. "
221
+ "Regenere o arquivo .npy com o mesmo modelo SentenceTransformer."
222
+ )
223
+
224
+ sims = cosine_similarity(emb_query, emb_base)[0]
225
+
226
+ bonus = df_filtrado.apply(
227
+ lambda row: bonus_lexical(
228
+ query_text,
229
+ row["product_name"],
230
+ row["categoria_principal"],
231
+ row["neighborhood"],
232
+ row["region"],
233
+ row["description"],
234
+ row["texto_busca_reforcado"],
235
+ ),
236
+ axis=1,
237
+ ).values
238
+
239
+ penalidade_feedback = self._calcular_penalidade_feedback(query_text, df_filtrado)
240
+ score_final = sims + bonus - penalidade_feedback
241
+
242
+ top_idx_local = np.argsort(score_final)[::-1][:top_k]
243
+
244
+ resultados = []
245
+ for rank, idx_local in enumerate(top_idx_local, start=1):
246
+ idx_global = idx_filtrado[idx_local]
247
+ prod = self.df_produtos.iloc[idx_global]
248
+
249
+ resultados.append({
250
+ "rank": rank,
251
+ "establishment_id": str(prod["establishment_id"]),
252
+ "product_id": str(prod["product_id"]),
253
+ "product_name": prod["product_name"],
254
+ "categoria_principal": prod["categoria_principal"],
255
+ "categoria_grupo": prod["categoria_grupo"],
256
+ "region": prod["region"],
257
+ "neighborhood": prod["neighborhood"],
258
+ "score_semantico": float(sims[idx_local]),
259
+ "bonus_lexical": float(bonus[idx_local]),
260
+ "penalidade_feedback": float(penalidade_feedback[idx_local]),
261
+ "score_final": float(score_final[idx_local]),
262
+ })
263
+
264
+ return {
265
+ "query": query_text,
266
+ "categoria_inferida": categoria,
267
+ "resultados": resultados,
268
+ }