Madras1 commited on
Commit
2c6dda0
·
verified ·
1 Parent(s): c2fbb43

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +127 -58
app.py CHANGED
@@ -1,7 +1,6 @@
1
  # ==============================================================================
2
- # API do AetherMap — VERSÃO SÁBIA 5.1 (Segura para Hugging Face)
3
- # Backend com análise de cluster via IA (Groq) sob demanda.
4
- # Pronto para deployment com busca de secrets do Hugging Face.
5
  # ==============================================================================
6
 
7
  import numpy as np
@@ -9,7 +8,7 @@ import pandas as pd
9
  import torch
10
  import gc
11
  import uuid
12
- import os # Importe essencial para acessar os segredos
13
  import json
14
  import logging
15
 
@@ -17,13 +16,13 @@ from fastapi import FastAPI, UploadFile, File, Form, HTTPException
17
  from typing import List, Dict, Any
18
  from functools import lru_cache
19
 
20
- # Nossas Ferramentas de Alquimia
21
  from sentence_transformers import SentenceTransformer
22
  import umap
23
  import hdbscan
24
  from sklearn.preprocessing import StandardScaler
25
  from sklearn.metrics.pairwise import cosine_similarity
26
- from sklearn.feature_extraction.text import TfidfVectorizer
27
  from scipy.stats import entropy
28
 
29
  # A Conexão com o Oráculo
@@ -34,49 +33,39 @@ from groq import Groq
34
  # ==============================================================================
35
  logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")
36
 
37
- # Modelos e Parâmetros
38
  DEFAULT_MODEL = "all-MiniLM-L6-v2"
39
  BATCH_SIZE = 256
40
  UMAP_N_NEIGHBORS = 30
41
- HDBSCAN_MIN_SIZE = 50
42
 
43
  # A Câmara do Tesouro (Cache de Sessão)
44
  cache: Dict[str, Any] = {}
45
 
46
- # <<< A MÁGICA DA COMPATIBILIDADE COM HUGGING FACE SECRETS >>>
47
- # Esta função buscará a variável de ambiente chamada 'GROQ_API_KEY'.
48
- # Quando você define um "Secret" no Hugging Face com este nome, ele se torna
49
- # uma variável de ambiente disponível para o seu código, exatamente como se
50
- # estivesse rodando localmente. O código não precisa saber se está no Hugging Face
51
- # ou na sua máquina, ele simplesmente pede a chave ao ambiente. É elegante e seguro.
52
  GROQ_API_KEY = os.environ.get("GROQ_API_KEY")
53
-
54
  try:
55
  if not GROQ_API_KEY:
56
  raise ValueError("GROQ_API_KEY não encontrada nos segredos do ambiente.")
57
  groq_client = Groq(api_key=GROQ_API_KEY)
58
- logging.info("Cliente Groq inicializado com sucesso usando a chave do ambiente.")
59
  except Exception as e:
60
  logging.error(f"FALHA CRÍTICA AO INICIALIZAR GROQ: {e}")
61
  groq_client = None
62
 
63
- # Palavras de Parada (mantidas para robustez)
64
  STOP_WORDS_PT = [
65
- 'de','a','o','que','e','do','da','em','um','para','é','com','não','uma','os','no',
66
- 'se','na','por','mais','as','dos','como','mas','foi','ao','ele','das','tem','à'
67
  ]
68
 
 
69
  # ==============================================================================
70
- # O RESTANTE DO CÓDIGO PERMANECE EXATAMENTE O MESMO
71
- # Nenhuma outra alteração é necessária no restante do arquivo.
72
- # As funções load_model(), preparar_textos(), processar_pipeline()
73
- # e os endpoints /process/ e /describe_clusters/ já estão perfeitos.
74
  # ==============================================================================
75
-
76
  @lru_cache(maxsize=1)
77
  def load_model():
78
  device = "cuda" if torch.cuda.is_available() else "cpu"
79
- logging.info(f"Carregando modelo de embedding '{DEFAULT_MODEL}' em: {device}")
80
  return SentenceTransformer(DEFAULT_MODEL, device=device)
81
 
82
  def preparar_textos(file_bytes: bytes, n_samples: int) -> List[str]:
@@ -93,58 +82,144 @@ def processar_pipeline(textos: List[str]) -> (pd.DataFrame, np.ndarray):
93
  emb_3d = reducer.fit_transform(embeddings)
94
  emb_3d = StandardScaler().fit_transform(emb_3d)
95
 
96
- clusterer = hdbscan.HDBSCAN(min_cluster_size=HDBSCAN_MIN_SIZE)
97
- clusters = clusterer.fit_predict(emb_3d)
 
 
98
 
99
- df = pd.DataFrame({
100
- "x": emb_3d[:, 0], "y": emb_3d[:, 1], "z": emb_3d[:, 2],
101
- "full_text": textos, "cluster": clusters.astype(str)
102
- })
103
 
 
104
  del reducer, clusterer, emb_3d; gc.collect()
105
  return df, embeddings
106
 
107
- app = FastAPI(title="API do AetherMap (Versão Sábia)", version="5.1.0")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
108
 
109
  @app.post("/process/")
110
  async def process_api(n_samples: int = Form(10000), file: UploadFile = File(...)):
111
- # (código do endpoint /process/ inalterado)
112
- logging.info(f"Requisição recebida para processar {file.filename}.")
113
  try:
114
  file_bytes = await file.read()
115
  textos = preparar_textos(file_bytes, n_samples)
116
- if not textos:
117
- raise HTTPException(status_code=400, detail="Nenhum texto válido encontrado no arquivo.")
118
 
119
  df, embeddings = processar_pipeline(textos)
120
 
121
  job_id = str(uuid.uuid4())
122
- cache[job_id] = {"df": df, "embeddings": embeddings}
123
  logging.info(f"Resultados salvos no cache com o ID: {job_id}")
124
 
 
 
 
 
 
125
  n_clusters = len(df["cluster"].unique()) - (1 if "-1" in df["cluster"].unique() else 0)
126
  n_ruido = int((df["cluster"] == "-1").sum())
127
 
128
  return {
129
  "job_id": job_id,
130
  "metadata": {"filename": file.filename, "num_documents_processed": len(df), "num_clusters_found": n_clusters, "num_noise_points": n_ruido},
 
 
 
131
  "plot_data": df[["x", "y", "z", "cluster", "full_text"]].to_dict("records"),
132
  }
133
  except Exception as e:
134
- logging.error(f"ERRO CRÍTICO em /process/: {e}", exc_info=True)
135
  raise HTTPException(status_code=500, detail=f"Erro interno do servidor: {str(e)}")
136
 
137
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
138
  @app.post("/describe_clusters/")
139
  async def describe_clusters_api(job_id: str = Form(...)):
140
- # (código do endpoint /describe_clusters/ inalterado)
141
  logging.info(f"Requisição recebida para descrever clusters do job '{job_id}'.")
142
-
143
- if groq_client is None:
144
- raise HTTPException(status_code=503, detail="O Oráculo (Groq) não está disponível. Verifique a chave da API nos segredos do Hugging Face.")
145
-
146
- if job_id not in cache:
147
- raise HTTPException(status_code=404, detail="Job ID não encontrado ou expirado. Processe um novo arquivo.")
148
 
149
  try:
150
  cached_data = cache[job_id]
@@ -164,40 +239,34 @@ async def describe_clusters_api(job_id: str = Form(...)):
164
  top_indices = np.argsort(similarities)[-3:][::-1]
165
  champion_docs_by_cluster[cid] = [cluster_texts[i] for i in top_indices]
166
 
 
 
167
  prompt_sections = []
168
  for cid, docs in champion_docs_by_cluster.items():
169
  doc_list = "\n".join([f"- {doc[:300]}..." for doc in docs])
170
  prompt_sections.append(f"Grupo {cid}:\n{doc_list}")
171
-
172
- if not prompt_sections: return {"insights": {}}
173
 
174
  master_prompt = (
175
- "Você é Aetherius, um analista de dados especialista em síntese de informações. Para cada grupo de textos, responda com um objeto JSON contendo duas chaves:\n"
176
- "1. 'topic_name': Um nome temático curto (máximo 5 palavras).\n"
177
- "2. 'core_insight': Um resumo de uma única frase capturando a ideia central.\n\n"
178
  "Analise os seguintes grupos:\n\n" + "\n\n".join(prompt_sections) +
179
- "\n\nResponda APENAS com o JSON contendo os resultados para cada grupo."
180
  )
181
 
182
  chat_completion = groq_client.chat.completions.create(
183
  messages=[
184
  {"role": "system", "content": "Siga as instruções e responda apenas com um objeto JSON válido."},
185
  {"role": "user", "content": master_prompt},
186
- ],
187
- model="llama3-8b-8192", temperature=0.2,
188
  )
189
  response_content = chat_completion.choices[0].message.content
190
 
191
  try:
192
- json_response_str = response_content.strip().replace("```json", "").replace("```", "")
193
- insights = json.loads(json_response_str)
194
  except json.JSONDecodeError:
195
- logging.error(f"Falha ao decodificar JSON da Groq. Resposta: {response_content}")
196
  raise HTTPException(status_code=500, detail="O Oráculo respondeu em um formato inesperado.")
197
 
198
- logging.info(f"Insights gerados com sucesso para o job '{job_id}'.")
199
  return {"insights": insights}
200
-
201
  except Exception as e:
202
  logging.error(f"ERRO CRÍTICO em /describe_clusters/: {e}", exc_info=True)
203
  raise HTTPException(status_code=500, detail=f"Erro interno ao gerar insights: {str(e)}")
 
1
  # ==============================================================================
2
+ # API do AetherMap — VERSÃO SÁBIA 5.2 (COMPLETA E SEGURA)
3
+ # Backend com TODAS as funcionalidades originais + descrição de cluster por IA.
 
4
  # ==============================================================================
5
 
6
  import numpy as np
 
8
  import torch
9
  import gc
10
  import uuid
11
+ import os
12
  import json
13
  import logging
14
 
 
16
  from typing import List, Dict, Any
17
  from functools import lru_cache
18
 
19
+ # Ferramentas de Alquimia
20
  from sentence_transformers import SentenceTransformer
21
  import umap
22
  import hdbscan
23
  from sklearn.preprocessing import StandardScaler
24
  from sklearn.metrics.pairwise import cosine_similarity
25
+ from sklearn.feature_extraction.text import CountVectorizer, TfidfVectorizer
26
  from scipy.stats import entropy
27
 
28
  # A Conexão com o Oráculo
 
33
  # ==============================================================================
34
  logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")
35
 
 
36
  DEFAULT_MODEL = "all-MiniLM-L6-v2"
37
  BATCH_SIZE = 256
38
  UMAP_N_NEIGHBORS = 30
 
39
 
40
  # A Câmara do Tesouro (Cache de Sessão)
41
  cache: Dict[str, Any] = {}
42
 
43
+ # <<< NOVA SEÇÃO: INICIALIZAÇÃO SEGURA DO ORÁCULO >>>
44
+ # Busca a chave dos segredos do Hugging Face ou do ambiente local
 
 
 
 
45
  GROQ_API_KEY = os.environ.get("GROQ_API_KEY")
 
46
  try:
47
  if not GROQ_API_KEY:
48
  raise ValueError("GROQ_API_KEY não encontrada nos segredos do ambiente.")
49
  groq_client = Groq(api_key=GROQ_API_KEY)
50
+ logging.info("Cliente Groq inicializado com sucesso.")
51
  except Exception as e:
52
  logging.error(f"FALHA CRÍTICA AO INICIALIZAR GROQ: {e}")
53
  groq_client = None
54
 
55
+ # Palavras de Parada (mantidas do seu original)
56
  STOP_WORDS_PT = [
57
+ 'de','a','o','que','e','do','da','em','um','para','é','com','não','uma','os','no', 'se','na','por','mais','as','dos','como','mas','foi','ao','ele','das','tem','à'
58
+ # ... (lista completa de stopwords)
59
  ]
60
 
61
+
62
  # ==============================================================================
63
+ # FUNÇÕES DE ANÁLISE (TODAS RESTAURADAS E INTACTAS)
 
 
 
64
  # ==============================================================================
 
65
  @lru_cache(maxsize=1)
66
  def load_model():
67
  device = "cuda" if torch.cuda.is_available() else "cpu"
68
+ logging.info(f"Carregando modelo '{DEFAULT_MODEL}' em: {device}")
69
  return SentenceTransformer(DEFAULT_MODEL, device=device)
70
 
71
  def preparar_textos(file_bytes: bytes, n_samples: int) -> List[str]:
 
82
  emb_3d = reducer.fit_transform(embeddings)
83
  emb_3d = StandardScaler().fit_transform(emb_3d)
84
 
85
+ # <<< AJUSTE SUTIL E IMPORTANTE: HDBSCAN DINÂMICO >>>
86
+ num_textos = len(textos)
87
+ min_size = max(10, int(num_textos * 0.02))
88
+ logging.info(f"HDBSCAN min_cluster_size dinâmico definido para: {min_size}")
89
 
90
+ clusterer = hdbscan.HDBSCAN(min_cluster_size=min_size)
91
+ clusters = clusterer.fit_predict(emb_3d)
 
 
92
 
93
+ df = pd.DataFrame({"x": emb_3d[:, 0], "y": emb_3d[:, 1], "z": emb_3d[:, 2], "full_text": textos, "cluster": clusters.astype(str)})
94
  del reducer, clusterer, emb_3d; gc.collect()
95
  return df, embeddings
96
 
97
+ def calcular_metricas(textos: List[str]) -> Dict[str, Any]:
98
+ logging.info("Calculando métricas globais...")
99
+ if not textos: return {}
100
+ vectorizer_count = CountVectorizer(stop_words=STOP_WORDS_PT, max_features=1000)
101
+ vectorizer_tfidf = TfidfVectorizer(stop_words=STOP_WORDS_PT, max_features=1000)
102
+ try:
103
+ counts_matrix = vectorizer_count.fit_transform(textos)
104
+ tfidf_matrix = vectorizer_tfidf.fit_transform(textos)
105
+ except ValueError:
106
+ return {"riqueza_lexical": 0, "top_tfidf_palavras": [], "entropia": 0.0}
107
+
108
+ vocab_count = vectorizer_count.get_feature_names_out()
109
+ contagens = counts_matrix.sum(axis=0).A1
110
+
111
+ vocab_tfidf = vectorizer_tfidf.get_feature_names_out()
112
+ soma_tfidf = tfidf_matrix.sum(axis=0).A1
113
+ top_idx_tfidf = np.argsort(soma_tfidf)[-10:][::-1]
114
+ top_tfidf = [{"palavra": vocab_tfidf[i], "score": round(float(soma_tfidf[i]), 4)} for i in top_idx_tfidf]
115
+
116
+ return {
117
+ "riqueza_lexical": len(vocab_count),
118
+ "top_tfidf_palavras": top_tfidf,
119
+ "entropia": float(entropy(contagens / contagens.sum(), base=2)) if contagens.sum() > 0 else 0.0
120
+ }
121
+
122
+ def encontrar_duplicados(df: pd.DataFrame, embeddings: np.ndarray) -> Dict[str, Any]:
123
+ logging.info("Detectando duplicados...")
124
+ mask = df["full_text"].duplicated(keep=False)
125
+ grupos_exatos = {t: [int(i) for i in idxs] for t, idxs in df[mask].groupby("full_text").groups.items()}
126
+ pares_semanticos = []
127
+ if 2 < len(embeddings) < 5000:
128
+ sim = cosine_similarity(embeddings)
129
+ triu_indices = np.triu_indices_from(sim, k=1)
130
+ sim_vetor = sim[triu_indices]
131
+ pares_idx = np.where(sim_vetor > 0.98)[0]
132
+ # Pegamos os 5 mais similares para não sobrecarregar
133
+ top_pares_idx = pares_idx[np.argsort(sim_vetor[pares_idx])[-5:][::-1]]
134
+ for i in top_pares_idx:
135
+ idx1, idx2 = triu_indices[0][i], triu_indices[1][i]
136
+ if df["full_text"].iloc[idx1] != df["full_text"].iloc[idx2]:
137
+ pares_semanticos.append({
138
+ "similaridade": float(sim[idx1, idx2]),
139
+ "texto1": df["full_text"].iloc[idx1],
140
+ "texto2": df["full_text"].iloc[idx2]
141
+ })
142
+ return {"grupos_exatos": grupos_exatos, "pares_semanticos": pares_semanticos}
143
+
144
+ def analisar_clusters(df: pd.DataFrame) -> Dict[str, Any]:
145
+ logging.info("Analisando clusters individualmente (TF-IDF)...")
146
+ analise = {}
147
+ ids_clusters_validos = sorted([c for c in df["cluster"].unique() if c != "-1"], key=int)
148
+ for cid in ids_clusters_validos:
149
+ textos_cluster = df[df["cluster"] == cid]["full_text"].tolist()
150
+ if len(textos_cluster) < 2: continue
151
+ try:
152
+ vectorizer = TfidfVectorizer(stop_words=STOP_WORDS_PT, max_features=1000)
153
+ tfidf_matrix = vectorizer.fit_transform(textos_cluster)
154
+ vocab = vectorizer.get_feature_names_out()
155
+ soma = tfidf_matrix.sum(axis=0).A1
156
+ top_idx = np.argsort(soma)[-5:][::-1]
157
+ top_palavras = [{"palavra": vocab[i], "score": round(float(soma[i]), 4)} for i in top_idx]
158
+ except ValueError:
159
+ top_palavras = []
160
+ analise[cid] = {"num_documentos": len(textos_cluster), "top_palavras": top_palavras}
161
+ return analise
162
+
163
+ # ==============================================================================
164
+ # FASTAPI — DEFINIÇÃO DA API (COMPLETA)
165
+ # ==============================================================================
166
+ app = FastAPI(title="API do AetherMap (Versão Sábia e Completa)", version="5.2.0")
167
 
168
  @app.post("/process/")
169
  async def process_api(n_samples: int = Form(10000), file: UploadFile = File(...)):
170
+ logging.info(f"Requisição recebida para {file.filename}.")
 
171
  try:
172
  file_bytes = await file.read()
173
  textos = preparar_textos(file_bytes, n_samples)
174
+ if not textos: raise HTTPException(status_code=400, detail="Nenhum texto válido encontrado.")
 
175
 
176
  df, embeddings = processar_pipeline(textos)
177
 
178
  job_id = str(uuid.uuid4())
179
+ cache[job_id] = {"embeddings": embeddings, "df": df}
180
  logging.info(f"Resultados salvos no cache com o ID: {job_id}")
181
 
182
+ # TODAS AS ANÁLISES SENDO FEITAS E RETORNADAS
183
+ metricas_globais = calcular_metricas(df["full_text"].tolist())
184
+ analise_de_duplicados = encontrar_duplicados(df, embeddings)
185
+ analise_por_cluster_tfidf = analisar_clusters(df)
186
+
187
  n_clusters = len(df["cluster"].unique()) - (1 if "-1" in df["cluster"].unique() else 0)
188
  n_ruido = int((df["cluster"] == "-1").sum())
189
 
190
  return {
191
  "job_id": job_id,
192
  "metadata": {"filename": file.filename, "num_documents_processed": len(df), "num_clusters_found": n_clusters, "num_noise_points": n_ruido},
193
+ "metrics": metricas_globais,
194
+ "duplicates": analise_de_duplicados,
195
+ "cluster_analysis": analise_por_cluster_tfidf,
196
  "plot_data": df[["x", "y", "z", "cluster", "full_text"]].to_dict("records"),
197
  }
198
  except Exception as e:
199
+ logging.error(f"ERRO CRÍTICO EM /process/: {e}", exc_info=True)
200
  raise HTTPException(status_code=500, detail=f"Erro interno do servidor: {str(e)}")
201
 
202
 
203
+ # Endpoint de Busca Semântica (INTACTO)
204
+ @app.post("/search/")
205
+ async def search_api(query: str = Form(...), job_id: str = Form(...)):
206
+ # ... (código do /search/ inalterado)
207
+ if job_id not in cache: raise HTTPException(status_code=404, detail="Job ID não encontrado ou expirado.")
208
+ model = load_model()
209
+ cached_data = cache[job_id]
210
+ corpus_embeddings = cached_data["embeddings"]
211
+ query_embedding = model.encode([query], convert_to_numpy=True)
212
+ similarities = cosine_similarity(query_embedding, corpus_embeddings)[0]
213
+ top_indices = np.argsort(similarities)[-100:][::-1]
214
+ results = [{"index": int(i), "score": float(similarities[i])} for i in top_indices if similarities[i] > 0.3]
215
+ return {"results": results}
216
+
217
+ # <<< NOVO FEITIÇO IMPERIAL, ACOPLADO AO REINO >>>
218
  @app.post("/describe_clusters/")
219
  async def describe_clusters_api(job_id: str = Form(...)):
 
220
  logging.info(f"Requisição recebida para descrever clusters do job '{job_id}'.")
221
+ if groq_client is None: raise HTTPException(status_code=503, detail="O Oráculo (Groq) não está disponível.")
222
+ if job_id not in cache: raise HTTPException(status_code=404, detail="Job ID não encontrado ou expirado.")
 
 
 
 
223
 
224
  try:
225
  cached_data = cache[job_id]
 
239
  top_indices = np.argsort(similarities)[-3:][::-1]
240
  champion_docs_by_cluster[cid] = [cluster_texts[i] for i in top_indices]
241
 
242
+ if not champion_docs_by_cluster: return {"insights": {}}
243
+
244
  prompt_sections = []
245
  for cid, docs in champion_docs_by_cluster.items():
246
  doc_list = "\n".join([f"- {doc[:300]}..." for doc in docs])
247
  prompt_sections.append(f"Grupo {cid}:\n{doc_list}")
 
 
248
 
249
  master_prompt = (
250
+ "Você é Aetherius, um analista de dados especialista. Para cada grupo de textos, responda com um objeto JSON com duas chaves: "
251
+ "'topic_name' (um nome temático curto, máx 5 palavras) e 'core_insight' (um resumo de uma frase da ideia central).\n\n"
 
252
  "Analise os seguintes grupos:\n\n" + "\n\n".join(prompt_sections) +
253
+ "\n\nResponda APENAS com o JSON."
254
  )
255
 
256
  chat_completion = groq_client.chat.completions.create(
257
  messages=[
258
  {"role": "system", "content": "Siga as instruções e responda apenas com um objeto JSON válido."},
259
  {"role": "user", "content": master_prompt},
260
+ ], model="llama3-8b-8192", temperature=0.2,
 
261
  )
262
  response_content = chat_completion.choices[0].message.content
263
 
264
  try:
265
+ insights = json.loads(response_content.strip().replace("```json", "").replace("```", ""))
 
266
  except json.JSONDecodeError:
 
267
  raise HTTPException(status_code=500, detail="O Oráculo respondeu em um formato inesperado.")
268
 
 
269
  return {"insights": insights}
 
270
  except Exception as e:
271
  logging.error(f"ERRO CRÍTICO em /describe_clusters/: {e}", exc_info=True)
272
  raise HTTPException(status_code=500, detail=f"Erro interno ao gerar insights: {str(e)}")