Madras1 commited on
Commit
11c5e9f
·
verified ·
1 Parent(s): 645ad0e

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +145 -117
app.py CHANGED
@@ -1,7 +1,7 @@
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
@@ -12,13 +12,16 @@ import uuid
12
  import os
13
  import json
14
  import logging
 
 
15
 
16
  from fastapi import FastAPI, UploadFile, File, Form, HTTPException
 
17
  from typing import List, Dict, Any
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
@@ -30,61 +33,72 @@ from scipy.stats import entropy
30
  from groq import Groq
31
 
32
  # ==============================================================================
33
- # CONFIGURAÇÕES GERAIS E CACHE
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
 
45
- # A Câmara do Tesouro (Cache de Sessão)
46
  cache: Dict[str, Any] = {}
47
 
48
- # Inicialização segura do Oráculo Groq
49
  GROQ_API_KEY = os.environ.get("GROQ_API_KEY")
50
  try:
51
  if not GROQ_API_KEY:
52
- raise ValueError("GROQ_API_KEY não encontrada nos segredos do ambiente.")
53
- groq_client = Groq(api_key=GROQ_API_KEY)
54
- logging.info("Cliente Groq inicializado com sucesso.")
 
 
55
  except Exception as e:
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','à',
63
- 'seu','sua','ou','ser','quando','muito','há','nos','já','está','eu','também','só',
64
- 'pelo','pela','até','isso','ela','entre','era','depois','sem','mesmo','aos','ter',
65
- 'seus','quem','nas','me','esse','eles','estão','você','tinha','foram','essa','num',
66
- 'nem','suas','meu','às','minha','numa','pelos','elas','havia','seja','qual','será',
67
- 'nós','tenho','lhe','deles','essas','esses','pelas','este','fosse','dele','tu','te',
68
- 'vocês','vos','lhes','meus','minhas','teu','tua','teus','tuas','nosso','nossa',
69
- 'nossos','nossas','dela','delas','esta','estes','estas','aquele','aquela','aqueles',
70
- 'aquelas','isto','aquilo','estou','está','estamos','estão','estive','esteve',
71
- 'estivemos','estiveram','estava','estávamos','estavam','estivera','estivéramos',
72
- 'esteja','estejamos','estejam','estivesse','estivéssemos','estivessem','estiver',
73
- 'estivermos','estiverem','hei','há','havemos','hão','houve','houvemos','houveram',
74
- 'houvera','houvéramos','haja','hajamos','hajam','houvesse','houvéssemos','houvessem',
75
- 'houver','houvermos','houverem','houverei','houverá','houveremos','houverão',
76
- 'houveria','houveríamos','houveriam','sou','somos','são','era','éramos','eram',
77
- 'fui','foi','fomos','foram','fora','fôramos','seja','sejamos','sejam','fosse',
78
- 'fôssemos','fossem','for','formos','forem','serei','será','seremos','serão','seria',
79
- 'seríamos','seriam','tenho','tem','temos','tém','tinha','tínhamos','tinham','tive',
80
- 'teve','tivemos','tiveram','tivera','tivéramos','tenha','tenhamos','tenham',
81
- 'tivesse','tivéssemos','tivessem','tiver','tivermos','tiverem','terei','terá',
82
- 'teremos','terão','teria','teríamos','teriam','','pergunta','resposta'
83
- ]
 
 
 
 
 
 
 
 
 
84
 
85
 
86
  # ==============================================================================
87
- # FUNÇÕES DE ANÁLISE E CARREGAMENTO DE MODELOS
88
  # ==============================================================================
89
  @lru_cache(maxsize=1)
90
  def load_retriever():
@@ -96,10 +110,14 @@ def load_retriever():
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()
104
  textos = [s for line in linhas if (s := line.strip()) and len(s.split()) > 3]
105
  return textos[:n_samples]
@@ -107,29 +125,40 @@ def preparar_textos(file_bytes: bytes, n_samples: int) -> List[str]:
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
127
 
128
  def calcular_metricas(textos: List[str]) -> Dict[str, Any]:
129
- logging.info("Calculando métricas globais...")
130
  if not textos: return {}
131
- vectorizer_count = CountVectorizer(stop_words=STOP_WORDS_PT, max_features=1000)
132
- vectorizer_tfidf = TfidfVectorizer(stop_words=STOP_WORDS_PT, max_features=1000)
 
 
 
133
  try:
134
  counts_matrix = vectorizer_count.fit_transform(textos)
135
  tfidf_matrix = vectorizer_tfidf.fit_transform(textos)
@@ -138,6 +167,7 @@ def calcular_metricas(textos: List[str]) -> Dict[str, Any]:
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]
@@ -155,7 +185,6 @@ def encontrar_duplicados(df: pd.DataFrame, embeddings: np.ndarray) -> Dict[str,
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)
@@ -165,18 +194,23 @@ def encontrar_duplicados(df: pd.DataFrame, embeddings: np.ndarray) -> Dict[str,
165
  for i in top_pares_idx:
166
  idx1, idx2 = triu_indices[0][i], triu_indices[1][i]
167
  if df["full_text"].iloc[idx1] != df["full_text"].iloc[idx2]:
168
- pares_semanticos.append({"similaridade": float(sim[idx1, idx2]), "texto1": df["full_text"].iloc[idx1], "texto2": df["full_text"].iloc[idx2]})
 
 
 
 
169
  return {"grupos_exatos": grupos_exatos, "pares_semanticos": pares_semanticos}
170
 
171
  def analisar_clusters(df: pd.DataFrame) -> Dict[str, Any]:
172
- logging.info("Analisando clusters individualmente (TF-IDF)...")
173
  analise = {}
174
  ids_clusters_validos = sorted([c for c in df["cluster"].unique() if c != "-1"], key=int)
175
  for cid in ids_clusters_validos:
176
  textos_cluster = df[df["cluster"] == cid]["full_text"].tolist()
177
  if len(textos_cluster) < 2: continue
178
  try:
179
- vectorizer = TfidfVectorizer(stop_words=STOP_WORDS_PT, max_features=1000)
 
180
  tfidf_matrix = vectorizer.fit_transform(textos_cluster)
181
  vocab = vectorizer.get_feature_names_out()
182
  soma = tfidf_matrix.sum(axis=0).A1
@@ -189,9 +223,14 @@ def analisar_clusters(df: pd.DataFrame) -> Dict[str, Any]:
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(...)):
@@ -205,7 +244,7 @@ async def process_api(n_samples: int = Form(10000), file: UploadFile = File(...)
205
 
206
  job_id = str(uuid.uuid4())
207
  cache[job_id] = {"embeddings": embeddings, "df": df}
208
- logging.info(f"Resultados salvos no cache com o ID: {job_id}")
209
 
210
  metricas_globais = calcular_metricas(df["full_text"].tolist())
211
  analise_de_duplicados = encontrar_duplicados(df, embeddings)
@@ -216,133 +255,130 @@ async def process_api(n_samples: int = Form(10000), file: UploadFile = File(...)
216
 
217
  return {
218
  "job_id": job_id,
219
- "metadata": {"filename": file.filename, "num_documents_processed": len(df), "num_clusters_found": n_clusters, "num_noise_points": n_ruido},
 
 
 
 
 
220
  "metrics": metricas_globais,
221
  "duplicates": analise_de_duplicados,
222
  "cluster_analysis": analise_por_cluster_tfidf,
223
  "plot_data": df[["x", "y", "z", "cluster", "full_text"]].to_dict("records"),
224
  }
225
  except Exception as e:
226
- logging.error(f"ERRO CRÍTICO EM /process/: {e}", exc_info=True)
227
- raise HTTPException(status_code=500, detail=f"Erro interno do servidor: {str(e)}")
228
 
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)
338
- raise HTTPException(status_code=500, detail=f"Erro interno na busca semântica: {str(e)}")
339
 
340
 
341
  @app.post("/describe_clusters/")
342
  async def describe_clusters_api(job_id: str = Form(...)):
343
- logging.info(f"Requisição recebida para descrever clusters do job '{job_id}'.")
344
- if groq_client is None: raise HTTPException(status_code=503, detail="O Oráculo (Groq) não está disponível.")
345
- if job_id not in cache: raise HTTPException(status_code=404, detail="Job ID não encontrado ou expirado.")
346
 
347
  try:
348
  cached_data = cache[job_id]
@@ -358,7 +394,6 @@ async def describe_clusters_api(job_id: str = Form(...)):
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]
@@ -372,28 +407,21 @@ async def describe_clusters_api(job_id: str = Form(...)):
372
  prompt_sections.append(f"Grupo {cid}:\n{doc_list}")
373
 
374
  master_prompt = (
375
- "Você é Aetherius, um analista de dados especialista. Para cada grupo de textos, responda com um objeto JSON com duas chaves: "
376
- "'topic_name' (um nome temático curto, máx 5 palavras) e 'core_insight' (um resumo de uma frase da ideia central).\n\n"
377
- "Analise os seguintes grupos:\n\n" + "\n\n".join(prompt_sections) +
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
 
390
- try:
391
- insights = json.loads(response_content.strip().replace("```json", "").replace("```", ""))
392
- except json.JSONDecodeError:
393
- logging.error(f"Falha ao decodificar JSON da Groq. Resposta: {response_content}")
394
- raise HTTPException(status_code=500, detail="O Oráculo respondeu em um formato inesperado.")
395
-
396
  return {"insights": insights}
 
397
  except Exception as e:
398
- logging.error(f"ERRO CRÍTICO em /describe_clusters/: {e}", exc_info=True)
399
- raise HTTPException(status_code=500, detail=f"Erro interno ao gerar insights: {str(e)}")
 
1
  # ==============================================================================
2
+ # API do AetherMap — VERSÃO 6.5 GOLD (THE COMMAND KILLER + NLTK CLEANUP)
3
+ # Backend com RAG Híbrido (Bi-Encoder + Cross-Encoder), Citações Nativas
4
+ # e Stopwords Dinâmicas (PT/EN).
5
  # ==============================================================================
6
 
7
  import numpy as np
 
12
  import os
13
  import json
14
  import logging
15
+ import nltk
16
+ from nltk.corpus import stopwords
17
 
18
  from fastapi import FastAPI, UploadFile, File, Form, HTTPException
19
+ from fastapi.responses import JSONResponse
20
  from typing import List, Dict, Any
21
  from functools import lru_cache
22
 
23
+ # Ferramentas de Alquimia (ML & NLP)
24
+ from sentence_transformers import SentenceTransformer, CrossEncoder
25
  import umap
26
  import hdbscan
27
  from sklearn.preprocessing import StandardScaler
 
33
  from groq import Groq
34
 
35
  # ==============================================================================
36
+ # CONFIGURAÇÕES GERAIS E LOGGING
37
  # ==============================================================================
38
  logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")
39
 
40
+ # Modelos de IA
41
+ RETRIEVAL_MODEL = "all-MiniLM-L6-v2" # Rápido para varredura inicial
42
+ RERANKER_MODEL = "cross-encoder/ms-marco-MiniLM-L-6-v2" # Preciso para reordenação
 
43
 
44
+ # Parâmetros de Processamento
45
  BATCH_SIZE = 256
46
  UMAP_N_NEIGHBORS = 30
47
 
48
+ # Cache de Sessão (Na memória RAM)
49
  cache: Dict[str, Any] = {}
50
 
51
+ # Inicialização do Cliente Groq
52
  GROQ_API_KEY = os.environ.get("GROQ_API_KEY")
53
  try:
54
  if not GROQ_API_KEY:
55
+ logging.warning("GROQ_API_KEY não encontrada. Funcionalidades de LLM estarão indisponíveis.")
56
+ groq_client = None
57
+ else:
58
+ groq_client = Groq(api_key=GROQ_API_KEY)
59
+ logging.info("Cliente Groq inicializado com sucesso.")
60
  except Exception as e:
61
+ logging.error(f"FALHA AO INICIALIZAR GROQ: {e}")
62
  groq_client = None
63
 
64
+
65
+ # ==============================================================================
66
+ # GERENCIAMENTO INTELIGENTE DE STOP WORDS (NLTK)
67
+ # ==============================================================================
68
+ def carregar_stopwords():
69
+ """
70
+ Carrega stop words em Português e Inglês usando NLTK.
71
+ Remove a necessidade de listas hardcoded gigantes.
72
+ """
73
+ logging.info("Verificando dicionários de Stop Words (NLTK)...")
74
+ try:
75
+ nltk.data.find('corpora/stopwords')
76
+ except LookupError:
77
+ logging.info("Baixando corpus de stopwords...")
78
+ nltk.download('stopwords')
79
+
80
+ # Carrega listas oficiais
81
+ pt_stops = set(stopwords.words('portuguese'))
82
+ en_stops = set(stopwords.words('english'))
83
+
84
+ # Palavras customizadas do domínio AetherMap/Web
85
+ custom_stops = {
86
+ '', 'pergunta', 'resposta', 'aethermap', 'documento',
87
+ 'id', 'sobre', 'texto', 'análise', 'dados', 'cluster',
88
+ 'http', 'https', 'www', 'com', 'br', 'html', 'org'
89
+ }
90
+
91
+ # União de todos os conjuntos
92
+ final_stops = list(pt_stops | en_stops | custom_stops)
93
+ logging.info(f"Total de Stop Words carregadas: {len(final_stops)}")
94
+ return final_stops
95
+
96
+ # Variável global para ser usada nos Vectorizers
97
+ STOP_WORDS_MULTILINGUAL = carregar_stopwords()
98
 
99
 
100
  # ==============================================================================
101
+ # CARREGAMENTO DE MODELOS (COM CACHE)
102
  # ==============================================================================
103
  @lru_cache(maxsize=1)
104
  def load_retriever():
 
110
  def load_reranker():
111
  device = "cuda" if torch.cuda.is_available() else "cpu"
112
  logging.info(f"Carregando Reranker '{RERANKER_MODEL}' em: {device}")
 
113
  return CrossEncoder(RERANKER_MODEL, device=device)
114
 
115
+
116
+ # ==============================================================================
117
+ # PIPELINE DE PROCESSAMENTO DE DADOS
118
+ # ==============================================================================
119
  def preparar_textos(file_bytes: bytes, n_samples: int) -> List[str]:
120
+ # Decodifica e limpa linhas vazias ou muito curtas
121
  linhas = file_bytes.decode("utf-8", errors="ignore").splitlines()
122
  textos = [s for line in linhas if (s := line.strip()) and len(s.split()) > 3]
123
  return textos[:n_samples]
 
125
  def processar_pipeline(textos: List[str]) -> (pd.DataFrame, np.ndarray):
126
  logging.info(f"Iniciando pipeline para {len(textos)} textos...")
127
  model = load_retriever()
128
+
129
+ # 1. Gerar Embeddings
130
  embeddings = model.encode(textos, batch_size=BATCH_SIZE, show_progress_bar=False, convert_to_numpy=True)
131
 
132
+ # 2. Redução Dimensional (UMAP)
133
  reducer = umap.UMAP(n_components=3, n_neighbors=UMAP_N_NEIGHBORS, min_dist=0.0, metric="cosine", random_state=42)
134
  emb_3d = reducer.fit_transform(embeddings)
135
  emb_3d = StandardScaler().fit_transform(emb_3d)
136
 
137
+ # 3. Clustering (HDBSCAN Dinâmico)
138
  num_textos = len(textos)
139
  min_size = max(10, int(num_textos * 0.02))
140
+ logging.info(f"HDBSCAN min_cluster_size definido para: {min_size}")
141
 
142
  clusterer = hdbscan.HDBSCAN(min_cluster_size=min_size)
143
  clusters = clusterer.fit_predict(emb_3d)
144
 
145
+ # 4. Criar DataFrame
146
+ df = pd.DataFrame({
147
+ "x": emb_3d[:, 0], "y": emb_3d[:, 1], "z": emb_3d[:, 2],
148
+ "full_text": textos, "cluster": clusters.astype(str)
149
+ })
150
+
151
  del reducer, clusterer, emb_3d; gc.collect()
152
  return df, embeddings
153
 
154
  def calcular_metricas(textos: List[str]) -> Dict[str, Any]:
155
+ logging.info("Calculando métricas globais com Stopwords NLTK...")
156
  if not textos: return {}
157
+
158
+ # Usando a nova lista STOP_WORDS_MULTILINGUAL
159
+ vectorizer_count = CountVectorizer(stop_words=STOP_WORDS_MULTILINGUAL, max_features=1000)
160
+ vectorizer_tfidf = TfidfVectorizer(stop_words=STOP_WORDS_MULTILINGUAL, max_features=1000)
161
+
162
  try:
163
  counts_matrix = vectorizer_count.fit_transform(textos)
164
  tfidf_matrix = vectorizer_tfidf.fit_transform(textos)
 
167
 
168
  vocab_count = vectorizer_count.get_feature_names_out()
169
  contagens = counts_matrix.sum(axis=0).A1
170
+
171
  vocab_tfidf = vectorizer_tfidf.get_feature_names_out()
172
  soma_tfidf = tfidf_matrix.sum(axis=0).A1
173
  top_idx_tfidf = np.argsort(soma_tfidf)[-10:][::-1]
 
185
  grupos_exatos = {t: [int(i) for i in idxs] for t, idxs in df[mask].groupby("full_text").groups.items()}
186
  pares_semanticos = []
187
 
 
188
  if 2 < len(embeddings) < 5000:
189
  sim = cosine_similarity(embeddings)
190
  triu_indices = np.triu_indices_from(sim, k=1)
 
194
  for i in top_pares_idx:
195
  idx1, idx2 = triu_indices[0][i], triu_indices[1][i]
196
  if df["full_text"].iloc[idx1] != df["full_text"].iloc[idx2]:
197
+ pares_semanticos.append({
198
+ "similaridade": float(sim[idx1, idx2]),
199
+ "texto1": df["full_text"].iloc[idx1],
200
+ "texto2": df["full_text"].iloc[idx2]
201
+ })
202
  return {"grupos_exatos": grupos_exatos, "pares_semanticos": pares_semanticos}
203
 
204
  def analisar_clusters(df: pd.DataFrame) -> Dict[str, Any]:
205
+ logging.info("Analisando clusters (TF-IDF NLTK)...")
206
  analise = {}
207
  ids_clusters_validos = sorted([c for c in df["cluster"].unique() if c != "-1"], key=int)
208
  for cid in ids_clusters_validos:
209
  textos_cluster = df[df["cluster"] == cid]["full_text"].tolist()
210
  if len(textos_cluster) < 2: continue
211
  try:
212
+ # Usando a nova lista aqui também
213
+ vectorizer = TfidfVectorizer(stop_words=STOP_WORDS_MULTILINGUAL, max_features=1000)
214
  tfidf_matrix = vectorizer.fit_transform(textos_cluster)
215
  vocab = vectorizer.get_feature_names_out()
216
  soma = tfidf_matrix.sum(axis=0).A1
 
223
 
224
 
225
  # ==============================================================================
226
+ # API FASTAPI
227
  # ==============================================================================
228
+ app = FastAPI(title="AetherMap API 6.5", version="6.5.0", description="Backend Semantic Search with Reranking & Citations")
229
+
230
+ # Rota Raiz para evitar o "Not Found" feio
231
+ @app.get("/")
232
+ async def root():
233
+ return {"status": "online", "message": "Aether Map API 6.5 está operante. Use /docs para testar."}
234
 
235
  @app.post("/process/")
236
  async def process_api(n_samples: int = Form(10000), file: UploadFile = File(...)):
 
244
 
245
  job_id = str(uuid.uuid4())
246
  cache[job_id] = {"embeddings": embeddings, "df": df}
247
+ logging.info(f"Job criado: {job_id}")
248
 
249
  metricas_globais = calcular_metricas(df["full_text"].tolist())
250
  analise_de_duplicados = encontrar_duplicados(df, embeddings)
 
255
 
256
  return {
257
  "job_id": job_id,
258
+ "metadata": {
259
+ "filename": file.filename,
260
+ "num_documents_processed": len(df),
261
+ "num_clusters_found": n_clusters,
262
+ "num_noise_points": n_ruido
263
+ },
264
  "metrics": metricas_globais,
265
  "duplicates": analise_de_duplicados,
266
  "cluster_analysis": analise_por_cluster_tfidf,
267
  "plot_data": df[["x", "y", "z", "cluster", "full_text"]].to_dict("records"),
268
  }
269
  except Exception as e:
270
+ logging.error(f"ERRO EM /process/: {e}", exc_info=True)
271
+ raise HTTPException(status_code=500, detail=str(e))
272
 
273
 
274
  @app.post("/search/")
275
  async def search_api(query: str = Form(...), job_id: str = Form(...)):
276
  """
277
+ ENDPOINT DE BUSCA (RAG Híbrido com Citações)
278
+ 1. Retrieval (Bi-Encoder) -> Top 50
279
+ 2. Reranking (Cross-Encoder) -> Top 5
280
+ 3. Generation (Kimi K2) -> Resposta citada
281
  """
282
+ logging.info(f"Busca: '{query}' [Job: {job_id}]")
283
  if job_id not in cache:
284
+ raise HTTPException(status_code=404, detail="Job ID não encontrado.")
285
 
286
  try:
 
287
  model = load_retriever()
288
+ reranker = load_reranker()
289
 
290
  cached_data = cache[job_id]
291
  df = cached_data["df"]
292
  corpus_embeddings = cached_data["embeddings"]
293
 
294
+ # FASE 1: Varredura Ampla (Cosseno)
 
295
  query_embedding = model.encode([query], convert_to_numpy=True)
296
  similarities = cosine_similarity(query_embedding, corpus_embeddings)[0]
297
 
298
+ # Pega Top 50 candidatos (com filtro mínimo de relevância)
299
  top_k_retrieval = 50
300
  top_indices = np.argsort(similarities)[-top_k_retrieval:][::-1]
301
 
 
302
  candidate_docs = []
303
  candidate_indices = []
304
 
 
305
  for idx in top_indices:
306
+ if similarities[idx] > 0.15: # Filtro de ruído básico
307
  doc_text = df.iloc[int(idx)]["full_text"]
308
  candidate_docs.append([query, doc_text])
309
  candidate_indices.append(int(idx))
310
 
311
  if not candidate_docs:
312
+ return {"summary": "Não foram encontrados documentos relevantes.", "results": []}
313
 
314
+ # FASE 2: Reranking (O Juiz)
315
+ logging.info(f"Reranking {len(candidate_docs)} documentos...")
 
316
  rerank_scores = reranker.predict(candidate_docs)
317
 
 
318
  rerank_results = sorted(
319
  zip(candidate_indices, rerank_scores),
320
  key=lambda x: x[1],
321
  reverse=True
322
  )
323
 
324
+ # Seleciona Top 5 Campeões
325
  final_top_k = 5
326
  final_results = []
327
  context_parts = []
328
 
329
  for rank, (idx, score) in enumerate(rerank_results[:final_top_k]):
330
  doc_text = df.iloc[idx]["full_text"]
331
+ # Montagem do Contexto com ID para Citação
332
  context_parts.append(f"[ID: {rank+1}] DOCUMENTO:\n{doc_text}\n---------------------")
333
 
334
  final_results.append({
335
  "index": idx,
336
+ "score": float(score),
337
+ "cosine_score": float(similarities[idx]),
338
  "citation_id": rank + 1
339
  })
340
 
341
+ # FASE 3: Geração com Citações (Kimi K2)
 
 
342
  summary = ""
343
  if groq_client:
344
+ context_str = "\n".join(context_parts)
345
  rag_prompt = (
346
  "INSTRUÇÃO DE SISTEMA:\n"
347
  "Você é o Aetherius, um motor de busca semântica de alta precisão.\n"
348
  "Sua missão é responder à pergunta do usuário baseando-se ESTRITAMENTE nos documentos fornecidos.\n\n"
349
+ "REGRAS OBRIGATÓRIAS:\n"
350
+ "1. CITAÇÕES: Toda afirmação deve ter fonte [ID: x]. Ex: 'O lucro subiu [ID: 1].'\n"
351
+ "2. HONESTIDADE: Se não estiver no texto, diga que não encontrou.\n"
352
+ "3. IDIOMA: Português do Brasil.\n\n"
353
+ f"CONTEXTO RECUPERADO:\n{context_str}\n\n"
 
354
  f"PERGUNTA DO USUÁRIO: \"{query}\"\n\n"
355
  "RESPOSTA:"
356
  )
357
 
358
  try:
 
359
  chat_completion = groq_client.chat.completions.create(
360
  messages=[{"role": "user", "content": rag_prompt}],
361
+ model="moonshotai/kimi-k2-instruct-0905", # Seu modelo Kimi
362
+ temperature=0.1,
363
  max_tokens=1024
364
  )
365
  summary = chat_completion.choices[0].message.content.strip()
 
366
  except Exception as e:
367
+ logging.warning(f"Erro na geração do LLM: {e}")
368
+ summary = "Não foi possível gerar o resumo automático, mas os documentos estão listados abaixo."
369
 
370
  return {"summary": summary, "results": final_results}
371
 
372
  except Exception as e:
373
+ logging.error(f"ERRO EM /search/: {e}", exc_info=True)
374
+ raise HTTPException(status_code=500, detail=str(e))
375
 
376
 
377
  @app.post("/describe_clusters/")
378
  async def describe_clusters_api(job_id: str = Form(...)):
379
+ logging.info(f"Descrevendo clusters para Job: {job_id}")
380
+ if not groq_client: raise HTTPException(status_code=503, detail="Groq indisponível.")
381
+ if job_id not in cache: raise HTTPException(status_code=404, detail="Job não encontrado.")
382
 
383
  try:
384
  cached_data = cache[job_id]
 
394
  cluster_texts = df[mask]["full_text"].tolist()
395
  if len(cluster_texts) < 3: continue
396
 
 
397
  centroid = np.mean(cluster_embeddings, axis=0)
398
  similarities = cosine_similarity([centroid], cluster_embeddings)[0]
399
  top_indices = np.argsort(similarities)[-3:][::-1]
 
407
  prompt_sections.append(f"Grupo {cid}:\n{doc_list}")
408
 
409
  master_prompt = (
410
+ "Analise os grupos de texto abaixo. Para cada grupo, retorne um JSON com 'topic_name' e 'core_insight'.\n"
411
+ "Responda APENAS o JSON válido.\n\n" + "\n\n".join(prompt_sections)
 
 
412
  )
413
 
 
414
  chat_completion = groq_client.chat.completions.create(
415
  messages=[
416
+ {"role": "system", "content": "JSON Output Only."},
417
  {"role": "user", "content": master_prompt},
418
+ ], model="meta-llama/llama-3.3-70b-versatile", temperature=0.2,
419
  )
 
420
 
421
+ response_content = chat_completion.choices[0].message.content
422
+ insights = json.loads(response_content.strip().replace("```json", "").replace("```", ""))
 
 
 
 
423
  return {"insights": insights}
424
+
425
  except Exception as e:
426
+ logging.error(f"ERRO EM /describe_clusters/: {e}", exc_info=True)
427
+ raise HTTPException(status_code=500, detail=str(e))