Madras1 commited on
Commit
f40c66d
·
verified ·
1 Parent(s): 4c42499

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +114 -42
app.py CHANGED
@@ -1,7 +1,7 @@
1
  # ==============================================================================
2
- # API do AetherMap — VERSÃO SÁBIA 6.0 (O SÁBIO INVOCADO)
3
- # Backend com RAG (Retrieval-Augmented Generation) na busca semântica.
4
- # Todas as funcionalidades anteriores estão presentes e aprimoradas.
5
  # ==============================================================================
6
 
7
  import numpy as np
@@ -18,7 +18,7 @@ from typing import List, Dict, Any
18
  from functools import lru_cache
19
 
20
  # Ferramentas de Alquimia
21
- from sentence_transformers import SentenceTransformer
22
  import umap
23
  import hdbscan
24
  from sklearn.preprocessing import StandardScaler
@@ -34,7 +34,11 @@ from groq import Groq
34
  # ==============================================================================
35
  logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")
36
 
37
- DEFAULT_MODEL = "all-MiniLM-L6-v2"
 
 
 
 
38
  BATCH_SIZE = 256
39
  UMAP_N_NEIGHBORS = 30
40
 
@@ -52,7 +56,7 @@ except Exception as e:
52
  logging.error(f"FALHA CRÍTICA AO INICIALIZAR GROQ: {e}")
53
  groq_client = None
54
 
55
- # Palavras de Parada
56
  STOP_WORDS_PT = [
57
  'de','a','o','que','e','do','da','em','um','para','é','com','não','uma','os','no',
58
  'se','na','por','mais','as','dos','como','mas','foi','ao','ele','das','tem','à',
@@ -80,13 +84,20 @@ STOP_WORDS_PT = [
80
 
81
 
82
  # ==============================================================================
83
- # FUNÇÕES DE ANÁLISE (INTACTAS DA VERSÃO COMPLETA)
84
  # ==============================================================================
85
  @lru_cache(maxsize=1)
86
- def load_model():
 
 
 
 
 
 
87
  device = "cuda" if torch.cuda.is_available() else "cpu"
88
- logging.info(f"Carregando modelo '{DEFAULT_MODEL}' em: {device}")
89
- return SentenceTransformer(DEFAULT_MODEL, device=device)
 
90
 
91
  def preparar_textos(file_bytes: bytes, n_samples: int) -> List[str]:
92
  linhas = file_bytes.decode("utf-8", errors="ignore").splitlines()
@@ -95,16 +106,21 @@ def preparar_textos(file_bytes: bytes, n_samples: int) -> List[str]:
95
 
96
  def processar_pipeline(textos: List[str]) -> (pd.DataFrame, np.ndarray):
97
  logging.info(f"Iniciando pipeline para {len(textos)} textos...")
98
- model = load_model()
99
  embeddings = model.encode(textos, batch_size=BATCH_SIZE, show_progress_bar=False, convert_to_numpy=True)
 
 
100
  reducer = umap.UMAP(n_components=3, n_neighbors=UMAP_N_NEIGHBORS, min_dist=0.0, metric="cosine", random_state=42)
101
  emb_3d = reducer.fit_transform(embeddings)
102
  emb_3d = StandardScaler().fit_transform(emb_3d)
 
103
  num_textos = len(textos)
104
  min_size = max(10, int(num_textos * 0.02))
105
  logging.info(f"HDBSCAN min_cluster_size dinâmico definido para: {min_size}")
 
106
  clusterer = hdbscan.HDBSCAN(min_cluster_size=min_size)
107
  clusters = clusterer.fit_predict(emb_3d)
 
108
  df = pd.DataFrame({"x": emb_3d[:, 0], "y": emb_3d[:, 1], "z": emb_3d[:, 2], "full_text": textos, "cluster": clusters.astype(str)})
109
  del reducer, clusterer, emb_3d; gc.collect()
110
  return df, embeddings
@@ -119,12 +135,14 @@ def calcular_metricas(textos: List[str]) -> Dict[str, Any]:
119
  tfidf_matrix = vectorizer_tfidf.fit_transform(textos)
120
  except ValueError:
121
  return {"riqueza_lexical": 0, "top_tfidf_palavras": [], "entropia": 0.0}
 
122
  vocab_count = vectorizer_count.get_feature_names_out()
123
  contagens = counts_matrix.sum(axis=0).A1
124
  vocab_tfidf = vectorizer_tfidf.get_feature_names_out()
125
  soma_tfidf = tfidf_matrix.sum(axis=0).A1
126
  top_idx_tfidf = np.argsort(soma_tfidf)[-10:][::-1]
127
  top_tfidf = [{"palavra": vocab_tfidf[i], "score": round(float(soma_tfidf[i]), 4)} for i in top_idx_tfidf]
 
128
  return {
129
  "riqueza_lexical": len(vocab_count),
130
  "top_tfidf_palavras": top_tfidf,
@@ -136,6 +154,8 @@ def encontrar_duplicados(df: pd.DataFrame, embeddings: np.ndarray) -> Dict[str,
136
  mask = df["full_text"].duplicated(keep=False)
137
  grupos_exatos = {t: [int(i) for i in idxs] for t, idxs in df[mask].groupby("full_text").groups.items()}
138
  pares_semanticos = []
 
 
139
  if 2 < len(embeddings) < 5000:
140
  sim = cosine_similarity(embeddings)
141
  triu_indices = np.triu_indices_from(sim, k=1)
@@ -169,9 +189,9 @@ def analisar_clusters(df: pd.DataFrame) -> Dict[str, Any]:
169
 
170
 
171
  # ==============================================================================
172
- # FASTAPI — DEFINIÇÃO DA API (COMPLETA)
173
  # ==============================================================================
174
- app = FastAPI(title="API do AetherMap (O Sábio Invocado)", version="6.0.0")
175
 
176
  @app.post("/process/")
177
  async def process_api(n_samples: int = Form(10000), file: UploadFile = File(...)):
@@ -209,60 +229,109 @@ async def process_api(n_samples: int = Form(10000), file: UploadFile = File(...)
209
 
210
  @app.post("/search/")
211
  async def search_api(query: str = Form(...), job_id: str = Form(...)):
212
- logging.info(f"Busca RAG recebida para a query '{query}' no job '{job_id}'.")
 
 
 
 
213
  if job_id not in cache:
214
  raise HTTPException(status_code=404, detail="Job ID não encontrado ou expirado.")
215
 
216
  try:
217
- # ETAPA 1: RECUPERAÇÃO (RETRIEVAL)
218
- model = load_model()
 
 
219
  cached_data = cache[job_id]
220
  df = cached_data["df"]
221
  corpus_embeddings = cached_data["embeddings"]
222
 
 
 
223
  query_embedding = model.encode([query], convert_to_numpy=True)
224
  similarities = cosine_similarity(query_embedding, corpus_embeddings)[0]
225
 
226
- top_k = 10
227
- top_indices = np.argsort(similarities)[-top_k:][::-1]
 
 
 
 
228
 
229
- results = [
230
- {"index": int(i), "score": float(similarities[i])}
231
- for i in top_indices if similarities[i] > 0.3
232
- ]
 
 
 
 
 
 
 
 
 
 
233
 
234
- if not results:
235
- return {"summary": "Não foram encontrados resultados relevantes para sua busca.", "results": []}
 
 
 
 
236
 
237
- # ETAPA 2: GERAÇÃO AUMENTADA (AUGMENTED GENERATION)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
238
  summary = ""
239
  if groq_client:
240
- context_docs = [df.iloc[res["index"]]["full_text"] for res in results[:5]]
241
- context_str = "\n\n".join([f"Documento de referência {i+1}:\n'''{doc}'''" for i, doc in enumerate(context_docs)])
242
-
243
  rag_prompt = (
244
- "Você é Aetherius, um Sábio Conselheiro. Sua tarefa é responder à pergunta do usuário de forma concisa e direta, "
245
- "baseando-se **estritamente** nas informações contidas nos documentos de referência fornecidos. "
246
- "Não use nenhum conhecimento externo. Responda em uma ou duas frases.\n\n"
247
- f"**Pergunta do Usuário:** \"{query}\"\n\n"
248
- f"{context_str}\n\n"
249
- "**Sua Resposta Direta:**"
 
 
 
 
 
250
  )
251
 
252
  try:
 
253
  chat_completion = groq_client.chat.completions.create(
254
  messages=[{"role": "user", "content": rag_prompt}],
255
- model="moonshotai/kimi-k2-instruct-0905",
256
- temperature=0.2,
 
257
  )
258
  summary = chat_completion.choices[0].message.content.strip()
259
- logging.info(f"Resumo RAG gerado com sucesso.")
260
  except Exception as e:
261
- logging.warning(f"Falha ao gerar resumo RAG com a Groq: {e}")
262
- summary = "O Oráculo está indisponível para gerar um resumo, mas aqui estão os documentos encontrados."
263
 
264
- logging.info(f"Encontrados {len(results)} resultados. Resumo: {summary[:50]}...")
265
- return {"summary": summary, "results": results}
266
 
267
  except Exception as e:
268
  logging.error(f"ERRO CRÍTICO EM /search/: {e}", exc_info=True)
@@ -288,6 +357,8 @@ async def describe_clusters_api(job_id: str = Form(...)):
288
  cluster_embeddings = embeddings[mask]
289
  cluster_texts = df[mask]["full_text"].tolist()
290
  if len(cluster_texts) < 3: continue
 
 
291
  centroid = np.mean(cluster_embeddings, axis=0)
292
  similarities = cosine_similarity([centroid], cluster_embeddings)[0]
293
  top_indices = np.argsort(similarities)[-3:][::-1]
@@ -307,11 +378,12 @@ async def describe_clusters_api(job_id: str = Form(...)):
307
  "\n\nResponda APENAS com o JSON."
308
  )
309
 
 
310
  chat_completion = groq_client.chat.completions.create(
311
  messages=[
312
  {"role": "system", "content": "Siga as instruções e responda apenas com um objeto JSON válido."},
313
  {"role": "user", "content": master_prompt},
314
- ], model="meta-llama/llama-4-maverick-17b-128e-instruct", temperature=0.2,
315
  )
316
  response_content = chat_completion.choices[0].message.content
317
 
 
1
  # ==============================================================================
2
+ # API do AetherMap — VERSÃO 6.5 (THE COMMAND KILLER)
3
+ # Backend com RAG em Dois Estágios (Retrieval + Reranking) e Citações Nativas.
4
+ # Arquitetura otimizada por Berta & Gabriel.
5
  # ==============================================================================
6
 
7
  import numpy as np
 
18
  from functools import lru_cache
19
 
20
  # Ferramentas de Alquimia
21
+ from sentence_transformers import SentenceTransformer, CrossEncoder # <--- A ARMA SECRETA
22
  import umap
23
  import hdbscan
24
  from sklearn.preprocessing import StandardScaler
 
34
  # ==============================================================================
35
  logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")
36
 
37
+ # Bi-Encoder para varredura rápida
38
+ RETRIEVAL_MODEL = "all-MiniLM-L6-v2"
39
+ # Cross-Encoder para precisão cirúrgica (Reranking)
40
+ RERANKER_MODEL = "cross-encoder/ms-marco-MiniLM-L-6-v2"
41
+
42
  BATCH_SIZE = 256
43
  UMAP_N_NEIGHBORS = 30
44
 
 
56
  logging.error(f"FALHA CRÍTICA AO INICIALIZAR GROQ: {e}")
57
  groq_client = None
58
 
59
+ # Palavras de Parada (Mantidas da versão anterior)
60
  STOP_WORDS_PT = [
61
  'de','a','o','que','e','do','da','em','um','para','é','com','não','uma','os','no',
62
  'se','na','por','mais','as','dos','como','mas','foi','ao','ele','das','tem','à',
 
84
 
85
 
86
  # ==============================================================================
87
+ # FUNÇÕES DE ANÁLISE E CARREGAMENTO DE MODELOS
88
  # ==============================================================================
89
  @lru_cache(maxsize=1)
90
+ def load_retriever():
91
+ device = "cuda" if torch.cuda.is_available() else "cpu"
92
+ logging.info(f"Carregando Retriever '{RETRIEVAL_MODEL}' em: {device}")
93
+ return SentenceTransformer(RETRIEVAL_MODEL, device=device)
94
+
95
+ @lru_cache(maxsize=1)
96
+ def load_reranker():
97
  device = "cuda" if torch.cuda.is_available() else "cpu"
98
+ logging.info(f"Carregando Reranker '{RERANKER_MODEL}' em: {device}")
99
+ # O CrossEncoder processa par (query, doc) juntos. É mais lento, mas MUITO mais preciso.
100
+ return CrossEncoder(RERANKER_MODEL, device=device)
101
 
102
  def preparar_textos(file_bytes: bytes, n_samples: int) -> List[str]:
103
  linhas = file_bytes.decode("utf-8", errors="ignore").splitlines()
 
106
 
107
  def processar_pipeline(textos: List[str]) -> (pd.DataFrame, np.ndarray):
108
  logging.info(f"Iniciando pipeline para {len(textos)} textos...")
109
+ model = load_retriever()
110
  embeddings = model.encode(textos, batch_size=BATCH_SIZE, show_progress_bar=False, convert_to_numpy=True)
111
+
112
+ # Redução dimensional e Clustering
113
  reducer = umap.UMAP(n_components=3, n_neighbors=UMAP_N_NEIGHBORS, min_dist=0.0, metric="cosine", random_state=42)
114
  emb_3d = reducer.fit_transform(embeddings)
115
  emb_3d = StandardScaler().fit_transform(emb_3d)
116
+
117
  num_textos = len(textos)
118
  min_size = max(10, int(num_textos * 0.02))
119
  logging.info(f"HDBSCAN min_cluster_size dinâmico definido para: {min_size}")
120
+
121
  clusterer = hdbscan.HDBSCAN(min_cluster_size=min_size)
122
  clusters = clusterer.fit_predict(emb_3d)
123
+
124
  df = pd.DataFrame({"x": emb_3d[:, 0], "y": emb_3d[:, 1], "z": emb_3d[:, 2], "full_text": textos, "cluster": clusters.astype(str)})
125
  del reducer, clusterer, emb_3d; gc.collect()
126
  return df, embeddings
 
135
  tfidf_matrix = vectorizer_tfidf.fit_transform(textos)
136
  except ValueError:
137
  return {"riqueza_lexical": 0, "top_tfidf_palavras": [], "entropia": 0.0}
138
+
139
  vocab_count = vectorizer_count.get_feature_names_out()
140
  contagens = counts_matrix.sum(axis=0).A1
141
  vocab_tfidf = vectorizer_tfidf.get_feature_names_out()
142
  soma_tfidf = tfidf_matrix.sum(axis=0).A1
143
  top_idx_tfidf = np.argsort(soma_tfidf)[-10:][::-1]
144
  top_tfidf = [{"palavra": vocab_tfidf[i], "score": round(float(soma_tfidf[i]), 4)} for i in top_idx_tfidf]
145
+
146
  return {
147
  "riqueza_lexical": len(vocab_count),
148
  "top_tfidf_palavras": top_tfidf,
 
154
  mask = df["full_text"].duplicated(keep=False)
155
  grupos_exatos = {t: [int(i) for i in idxs] for t, idxs in df[mask].groupby("full_text").groups.items()}
156
  pares_semanticos = []
157
+
158
+ # Só roda similaridade pesada se não for gigante
159
  if 2 < len(embeddings) < 5000:
160
  sim = cosine_similarity(embeddings)
161
  triu_indices = np.triu_indices_from(sim, k=1)
 
189
 
190
 
191
  # ==============================================================================
192
+ # FASTAPI — DEFINIÇÃO DA API
193
  # ==============================================================================
194
+ app = FastAPI(title="API do AetherMap 6.5 (The Command Killer)", version="6.5.0")
195
 
196
  @app.post("/process/")
197
  async def process_api(n_samples: int = Form(10000), file: UploadFile = File(...)):
 
229
 
230
  @app.post("/search/")
231
  async def search_api(query: str = Form(...), job_id: str = Form(...)):
232
+ """
233
+ RAG 2.0: Recuperação Híbrida (Bi-Encoder + Cross-Encoder Reranking)
234
+ Fornece citações precisas estilo Command R+.
235
+ """
236
+ logging.info(f"Busca RAG 2.0 recebida para: '{query}' [Job: {job_id}]")
237
  if job_id not in cache:
238
  raise HTTPException(status_code=404, detail="Job ID não encontrado ou expirado.")
239
 
240
  try:
241
+ # --- FASE 1: RECUPERAÇÃO (RETRIEVAL - BI-ENCODER) ---
242
+ model = load_retriever()
243
+ reranker = load_reranker() # Carrega o Juiz
244
+
245
  cached_data = cache[job_id]
246
  df = cached_data["df"]
247
  corpus_embeddings = cached_data["embeddings"]
248
 
249
+ # Passo 1: Busca inicial ampla com Cosseno (Pega 50 candidatos)
250
+ # Isso garante que não perdemos nada relevante que tenha palavras-chave parecidas
251
  query_embedding = model.encode([query], convert_to_numpy=True)
252
  similarities = cosine_similarity(query_embedding, corpus_embeddings)[0]
253
 
254
+ top_k_retrieval = 50
255
+ top_indices = np.argsort(similarities)[-top_k_retrieval:][::-1]
256
+
257
+ # Prepara dados para o Reranker: Lista de pares [Query, Doc]
258
+ candidate_docs = []
259
+ candidate_indices = []
260
 
261
+ # Filtro de corte mínimo para não passar lixo total pro reranker
262
+ for idx in top_indices:
263
+ if similarities[idx] > 0.15: # Threshold leve
264
+ doc_text = df.iloc[int(idx)]["full_text"]
265
+ candidate_docs.append([query, doc_text])
266
+ candidate_indices.append(int(idx))
267
+
268
+ if not candidate_docs:
269
+ return {"summary": "Não foram encontrados documentos minimamente relevantes.", "results": []}
270
+
271
+ # --- FASE 2: REORDENAÇÃO (RERANKING - CROSS-ENCODER) ---
272
+ # O modelo Cross-Encoder lê a pergunta e o documento juntos e dá um score de verdade.
273
+ logging.info(f"Reordenando {len(candidate_docs)} documentos com Cross-Encoder...")
274
+ rerank_scores = reranker.predict(candidate_docs)
275
 
276
+ # Ordena pelos scores do reranker (do maior para o menor)
277
+ rerank_results = sorted(
278
+ zip(candidate_indices, rerank_scores),
279
+ key=lambda x: x[1],
280
+ reverse=True
281
+ )
282
 
283
+ # Agora pegamos o Top 5 da "Nata da Nata" para enviar ao Kimi
284
+ final_top_k = 5
285
+ final_results = []
286
+ context_parts = []
287
+
288
+ for rank, (idx, score) in enumerate(rerank_results[:final_top_k]):
289
+ doc_text = df.iloc[idx]["full_text"]
290
+ # Montamos o contexto COM O ID EXPLÍCITO para forçar a citação
291
+ context_parts.append(f"[ID: {rank+1}] DOCUMENTO:\n{doc_text}\n---------------------")
292
+
293
+ final_results.append({
294
+ "index": idx,
295
+ "score": float(score), # Score do Reranker (Confiança semântica)
296
+ "cosine_score": float(similarities[idx]), # Score original (para debug)
297
+ "citation_id": rank + 1
298
+ })
299
+
300
+ context_str = "\n".join(context_parts)
301
+
302
+ # --- FASE 3: GERAÇÃO CITADA (READER - KIMI K2) ---
303
  summary = ""
304
  if groq_client:
305
+ # Prompt de Sistema projetado para emular o comportamento do Command R+
 
 
306
  rag_prompt = (
307
+ "INSTRUÇÃO DE SISTEMA:\n"
308
+ "Você é o Aetherius, um motor de busca semântica de alta precisão.\n"
309
+ "Sua missão é responder à pergunta do usuário baseando-se ESTRITAMENTE nos documentos fornecidos.\n\n"
310
+ "REGRAS DE OURO:\n"
311
+ "1. CITAÇÕES OBRIGATÓRIAS: Toda afirmação factual deve ser seguida da fonte no formato [ID: x].\n"
312
+ " Exemplo: 'O lucro subiu 10% [ID: 1], mas a margem caiu [ID: 2].'\n"
313
+ "2. HONESTIDADE INTELECTUAL: Se a resposta não estiver no contexto, diga 'Não encontrei informações suficientes nos documentos'.\n"
314
+ "3. ESTILO: Seja direto, técnico e conciso. Fale em Português do Brasil.\n\n"
315
+ f"CONTEXTO RECUPERADO (Ordenado por Relevância):\n{context_str}\n\n"
316
+ f"PERGUNTA DO USUÁRIO: \"{query}\"\n\n"
317
+ "RESPOSTA:"
318
  )
319
 
320
  try:
321
+ # Usando Kimi K2 pois ele tem ótimo raciocínio lógico para síntese
322
  chat_completion = groq_client.chat.completions.create(
323
  messages=[{"role": "user", "content": rag_prompt}],
324
+ model="moonshotai/kimi-k2-instruct-0905",
325
+ temperature=0.1, # Temperatura baixa é CRUCIAL para não inventar citações
326
+ max_tokens=1024
327
  )
328
  summary = chat_completion.choices[0].message.content.strip()
329
+ logging.info(f"Resumo gerado com sucesso.")
330
  except Exception as e:
331
+ logging.warning(f"Falha ao gerar resumo com a Groq: {e}")
332
+ summary = "O Oráculo está indisponível, mas os documentos mais relevantes estão listados abaixo."
333
 
334
+ return {"summary": summary, "results": final_results}
 
335
 
336
  except Exception as e:
337
  logging.error(f"ERRO CRÍTICO EM /search/: {e}", exc_info=True)
 
357
  cluster_embeddings = embeddings[mask]
358
  cluster_texts = df[mask]["full_text"].tolist()
359
  if len(cluster_texts) < 3: continue
360
+
361
+ # Pega os documentos mais próximos do centróide do cluster
362
  centroid = np.mean(cluster_embeddings, axis=0)
363
  similarities = cosine_similarity([centroid], cluster_embeddings)[0]
364
  top_indices = np.argsort(similarities)[-3:][::-1]
 
378
  "\n\nResponda APENAS com o JSON."
379
  )
380
 
381
+ # Mantendo Llama para tarefa de JSON simples, pois é rápido e segue bem formato
382
  chat_completion = groq_client.chat.completions.create(
383
  messages=[
384
  {"role": "system", "content": "Siga as instruções e responda apenas com um objeto JSON válido."},
385
  {"role": "user", "content": master_prompt},
386
+ ], model="meta-llama/llama-3.3-70b-versatile", temperature=0.2, # Atualizei para o Llama 3.3 que é melhor
387
  )
388
  response_content = chat_completion.choices[0].message.content
389