Madras1 commited on
Commit
1ad31cc
·
verified ·
1 Parent(s): 2111097

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +115 -148
app.py CHANGED
@@ -1,6 +1,7 @@
1
  # ==============================================================================
2
- # API do AetherMap — VERSÃO SEMÂNTICA 4.0
3
- # Backend com busca semântica verdadeira via endpoint /search/
 
4
  # ==============================================================================
5
 
6
  import numpy as np
@@ -8,229 +9,195 @@ import pandas as pd
8
  import torch
9
  import gc
10
  import uuid
 
 
 
11
 
12
  from fastapi import FastAPI, UploadFile, File, Form, HTTPException
13
  from typing import List, Dict, Any
14
  from functools import lru_cache
15
 
 
16
  from sentence_transformers import SentenceTransformer
17
  import umap
18
  import hdbscan
19
  from sklearn.preprocessing import StandardScaler
20
  from sklearn.metrics.pairwise import cosine_similarity
21
- from sklearn.feature_extraction.text import CountVectorizer, TfidfVectorizer
22
  from scipy.stats import entropy
23
 
 
 
 
24
  # ==============================================================================
25
  # CONFIGURAÇÕES GERAIS E CACHE
26
  # ==============================================================================
 
 
 
27
  DEFAULT_MODEL = "all-MiniLM-L6-v2"
28
  BATCH_SIZE = 256
29
  UMAP_N_NEIGHBORS = 30
30
  HDBSCAN_MIN_SIZE = 50
31
 
32
- # <<< NOVA CÂMARA DO TESOURO >>>
33
- # Guarda os embeddings e textos do último processamento.
34
- # Em produção real com múltiplos usuários, usaríamos Redis ou um DB vetorial.
35
  cache: Dict[str, Any] = {}
36
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
37
  STOP_WORDS_PT = [
38
  'de','a','o','que','e','do','da','em','um','para','é','com','não','uma','os','no',
39
- 'se','na','por','mais','as','dos','como','mas','foi','ao','ele','das','tem','à',
40
- 'seu','sua','ou','ser','quando','muito','há','nos','já','está','eu','também','só',
41
- 'pelo','pela','até','isso','ela','entre','era','depois','sem','mesmo','aos','ter',
42
- 'seus','quem','nas','me','esse','eles','estão','você','tinha','foram','essa','num',
43
- 'nem','suas','meu','às','minha','numa','pelos','elas','havia','seja','qual','será',
44
- 'nós','tenho','lhe','deles','essas','esses','pelas','este','fosse','dele','tu','te',
45
- 'vocês','vos','lhes','meus','minhas','teu','tua','teus','tuas','nosso','nossa',
46
- 'nossos','nossas','dela','delas','esta','estes','estas','aquele','aquela','aqueles',
47
- 'aquelas','isto','aquilo','estou','está','estamos','estão','estive','esteve',
48
- 'estivemos','estiveram','estava','estávamos','estavam','estivera','estivéramos',
49
- 'esteja','estejamos','estejam','estivesse','estivéssemos','estivessem','estiver',
50
- 'estivermos','estiverem','hei','há','havemos','hão','houve','houvemos','houveram',
51
- 'houvera','houvéramos','haja','hajamos','hajam','houvesse','houvéssemos','houvessem',
52
- 'houver','houvermos','houverem','houverei','houverá','houveremos','houverão',
53
- 'houveria','houveríamos','houveriam','sou','somos','são','era','éramos','eram',
54
- 'fui','foi','fomos','foram','fora','fôramos','seja','sejamos','sejam','fosse',
55
- 'fôssemos','fossem','for','formos','forem','serei','será','seremos','serão','seria',
56
- 'seríamos','seriam','tenho','tem','temos','tém','tinha','tínhamos','tinham','tive',
57
- 'teve','tivemos','tiveram','tivera','tivéramos','tenha','tenhamos','tenham',
58
- 'tivesse','tivéssemos','tivessem','tiver','tivermos','tiverem','terei','terá',
59
- 'teremos','terão','teria','teríamos','teriam','dá','pergunta','resposta'
60
  ]
61
 
62
-
63
  # ==============================================================================
64
- # MODELO E FUNÇÕES DE ANÁLISE (como antes)
 
 
 
65
  # ==============================================================================
 
66
  @lru_cache(maxsize=1)
67
  def load_model():
68
  device = "cuda" if torch.cuda.is_available() else "cpu"
69
- print(f"[MODEL] Carregando modelo '{DEFAULT_MODEL}' em: {device}")
70
  return SentenceTransformer(DEFAULT_MODEL, device=device)
71
 
72
- # ... (Todas as funções de análise: preparar_textos, processar_pipeline, calcular_metricas, etc. permanecem exatamente as mesmas da versão anterior)
73
  def preparar_textos(file_bytes: bytes, n_samples: int) -> List[str]:
74
- # ... (código inalterado)
75
  linhas = file_bytes.decode("utf-8", errors="ignore").splitlines()
76
  textos = [s for line in linhas if (s := line.strip()) and len(s.split()) > 3]
77
  return textos[:n_samples]
78
 
79
  def processar_pipeline(textos: List[str]) -> (pd.DataFrame, np.ndarray):
80
- # ... (código inalterado)
81
- print(f"[PIPELINE] Iniciando pipeline para {len(textos)} textos...")
82
  model = load_model()
83
  embeddings = model.encode(textos, batch_size=BATCH_SIZE, show_progress_bar=False, convert_to_numpy=True)
 
84
  reducer = umap.UMAP(n_components=3, n_neighbors=UMAP_N_NEIGHBORS, min_dist=0.0, metric="cosine", random_state=42)
85
  emb_3d = reducer.fit_transform(embeddings)
86
  emb_3d = StandardScaler().fit_transform(emb_3d)
 
87
  clusterer = hdbscan.HDBSCAN(min_cluster_size=HDBSCAN_MIN_SIZE)
88
  clusters = clusterer.fit_predict(emb_3d)
89
- df = pd.DataFrame({"x": emb_3d[:, 0], "y": emb_3d[:, 1], "z": emb_3d[:, 2], "full_text": textos, "cluster": clusters.astype(str)})
 
 
 
 
 
90
  del reducer, clusterer, emb_3d; gc.collect()
91
  return df, embeddings
92
 
93
- def calcular_metricas(textos: List[str]) -> Dict[str, Any]:
94
- # ... (código inalterado)
95
- print("[METRICAS] Calculando métricas globais...")
96
- if not textos: return {}
97
- vectorizer_count = CountVectorizer(stop_words=STOP_WORDS_PT, max_features=20000)
98
- vectorizer_tfidf = TfidfVectorizer(stop_words=STOP_WORDS_PT, max_features=20000)
99
- try:
100
- counts_matrix = vectorizer_count.fit_transform(textos)
101
- tfidf_matrix = vectorizer_tfidf.fit_transform(textos)
102
- except ValueError:
103
- return {"riqueza_lexical": 0, "top_tfidf_palavras": [], "top_frequencia_palavras": [], "entropia": 0.0}
104
- vocab_count = vectorizer_count.get_feature_names_out()
105
- contagens = counts_matrix.sum(axis=0).A1
106
- top_idx_freq = np.argsort(contagens)[-10:][::-1]
107
- top_frequencia = [{"palavra": vocab_count[i], "contagem": int(contagens[i])} for i in top_idx_freq]
108
- vocab_tfidf = vectorizer_tfidf.get_feature_names_out()
109
- soma_tfidf = tfidf_matrix.sum(axis=0).A1
110
- top_idx_tfidf = np.argsort(soma_tfidf)[-10:][::-1]
111
- top_tfidf = [{"palavra": vocab_tfidf[i], "score": round(float(soma_tfidf[i]), 4)} for i in top_idx_tfidf]
112
- return {"riqueza_lexical": len(vocab_count), "top_tfidf_palavras": top_tfidf, "top_frequencia_palavras": top_frequencia, "entropia": float(entropy(contagens / contagens.sum(), base=2))}
113
-
114
- def encontrar_duplicados(df: pd.DataFrame, embeddings: np.ndarray) -> Dict[str, Any]:
115
- # ... (código inalterado)
116
- print("[DUPLICADOS] Detectando duplicados...")
117
- mask = df["full_text"].duplicated(keep=False)
118
- grupos_exatos = {t: [int(i) for i in idxs] for t, idxs in df[mask].groupby("full_text").groups.items()}
119
- pares_semanticos = []
120
- histograma = {"bins": [], "contagens": []}
121
- if 2 < len(embeddings) < 5000:
122
- sim = cosine_similarity(embeddings)
123
- triu_indices = np.triu_indices_from(sim, k=1)
124
- sim_vetor = sim[triu_indices]
125
- pares_idx = np.where(sim_vetor > 0.98)[0]
126
- for i in pares_idx:
127
- idx1, idx2 = triu_indices[0][i], triu_indices[1][i]
128
- if df["full_text"].iloc[idx1] != df["full_text"].iloc[idx2]:
129
- pares_semanticos.append({"doc1_idx": int(idx1), "doc2_idx": int(idx2), "similaridade": float(sim[idx1, idx2]), "texto1": df["full_text"].iloc[idx1], "texto2": df["full_text"].iloc[idx2],})
130
- contagens, bin_edges = np.histogram(sim_vetor, bins=np.arange(0.8, 1.01, 0.05))
131
- histograma["bins"] = [f"{b:.2f}-{e:.2f}" for b, e in zip(bin_edges[:-1], bin_edges[1:])]
132
- histograma["contagens"] = contagens.tolist()
133
- return {"grupos_exatos": grupos_exatos, "pares_semanticos": pares_semanticos, "similaridade_histograma": histograma}
134
-
135
- def analisar_clusters(df: pd.DataFrame) -> Dict[str, Any]:
136
- # ... (código inalterado)
137
- print("[CLUSTERS] Analisando clusters individualmente...")
138
- analise = {}
139
- ids_clusters_validos = sorted([c for c in df["cluster"].unique() if c != "-1"], key=int)
140
- for cid in ids_clusters_validos:
141
- textos_cluster = df[df["cluster"] == cid]["full_text"].tolist()
142
- if len(textos_cluster) < 2: continue
143
- try:
144
- vectorizer = TfidfVectorizer(stop_words=STOP_WORDS_PT, max_features=1000)
145
- tfidf_matrix = vectorizer.fit_transform(textos_cluster)
146
- vocab = vectorizer.get_feature_names_out()
147
- soma = tfidf_matrix.sum(axis=0).A1
148
- top_idx = np.argsort(soma)[-5:][::-1]
149
- top_palavras = [{"palavra": vocab[i], "score": round(float(soma[i]), 4)} for i in top_idx]
150
- except ValueError:
151
- top_palavras = []
152
- analise[cid] = {"num_documentos": len(textos_cluster), "top_palavras": top_palavras}
153
- return analise
154
-
155
-
156
- # ==============================================================================
157
- # FASTAPI — DEFINIÇÃO DA API
158
- # ==============================================================================
159
- app = FastAPI(title="API do AetherMap (Versão Semântica)", version="4.0.0")
160
 
161
  @app.post("/process/")
162
  async def process_api(n_samples: int = Form(10000), file: UploadFile = File(...)):
163
- print(f"[API /process] Requisição recebida para {file.filename}.")
 
164
  try:
165
  file_bytes = await file.read()
166
  textos = preparar_textos(file_bytes, n_samples)
167
- if not textos: raise HTTPException(status_code=400, detail="Nenhum texto válido encontrado.")
 
168
 
169
  df, embeddings = processar_pipeline(textos)
170
 
171
- # <<< MODIFICAÇÃO >>> Guardando o resultado na Câmara do Tesouro
172
- job_id = str(uuid.uuid4()) # Gera um ID único para esta análise
173
- cache[job_id] = {"embeddings": embeddings, "df": df}
174
- print(f"[CACHE] Resultados salvos no cache com o ID: {job_id}")
175
-
176
- metricas_globais = calcular_metricas(df["full_text"].tolist())
177
- analise_de_duplicados = encontrar_duplicados(df, embeddings)
178
- analise_por_cluster = analisar_clusters(df)
179
 
180
  n_clusters = len(df["cluster"].unique()) - (1 if "-1" in df["cluster"].unique() else 0)
181
  n_ruido = int((df["cluster"] == "-1").sum())
182
 
183
- resposta = {
184
- "job_id": job_id, # Enviamos o ID para o frontend
185
  "metadata": {"filename": file.filename, "num_documents_processed": len(df), "num_clusters_found": n_clusters, "num_noise_points": n_ruido},
186
- "metrics": metricas_globais,
187
- "duplicates": analise_de_duplicados,
188
- "cluster_analysis": analise_por_cluster,
189
  "plot_data": df[["x", "y", "z", "cluster", "full_text"]].to_dict("records"),
190
  }
191
-
192
- print("[API /process] Processamento finalizado com sucesso.")
193
- return resposta
194
-
195
  except Exception as e:
196
- import traceback
197
- print("[ERRO] ERRO CRÍTICO EM /process:", e)
198
- traceback.print_exc()
199
  raise HTTPException(status_code=500, detail=f"Erro interno do servidor: {str(e)}")
200
 
201
 
202
- # <<< NOVO FEITIÇO IMPERIAL >>>
203
- @app.post("/search/")
204
- async def search_api(query: str = Form(...), job_id: str = Form(...)):
205
- print(f"[API /search] Busca recebida para a query '{query}' no job '{job_id}'.")
 
 
 
206
 
207
  if job_id not in cache:
208
- raise HTTPException(status_code=404, detail="Job ID não encontrado ou expirado. Por favor, processe um novo arquivo.")
209
 
210
  try:
211
- model = load_model()
212
  cached_data = cache[job_id]
213
- corpus_embeddings = cached_data["embeddings"]
 
214
 
215
- # 1. Cria o embedding da busca
216
- query_embedding = model.encode([query], convert_to_numpy=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
217
 
218
- # 2. Calcula a similaridade de cosseno
219
- similarities = cosine_similarity(query_embedding, corpus_embeddings)[0]
 
 
 
 
 
 
 
220
 
221
- # 3. Encontra os top 100 resultados com score acima de um limiar
222
- top_indices = np.argsort(similarities)[-100:][::-1]
 
 
 
 
 
 
223
 
224
- results = [
225
- {"index": int(i), "score": float(similarities[i])}
226
- for i in top_indices if similarities[i] > 0.3
227
- ]
228
-
229
- print(f"[API /search] Encontrados {len(results)} resultados semanticamente relevantes.")
230
- return {"results": results}
 
 
231
 
232
  except Exception as e:
233
- import traceback
234
- print("[ERRO] ERRO CRÍTICO EM /search:", e)
235
- traceback.print_exc()
236
- raise HTTPException(status_code=500, detail=f"Erro interno na busca semântica: {str(e)}")
 
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
  import torch
10
  import gc
11
  import uuid
12
+ import os # Importe essencial para acessar os segredos
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
+ # 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
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
+ # 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]:
 
83
  linhas = file_bytes.decode("utf-8", errors="ignore").splitlines()
84
  textos = [s for line in linhas if (s := line.strip()) and len(s.split()) > 3]
85
  return textos[:n_samples]
86
 
87
  def processar_pipeline(textos: List[str]) -> (pd.DataFrame, np.ndarray):
88
+ logging.info(f"Iniciando pipeline para {len(textos)} textos...")
 
89
  model = load_model()
90
  embeddings = model.encode(textos, batch_size=BATCH_SIZE, show_progress_bar=False, convert_to_numpy=True)
91
+
92
  reducer = umap.UMAP(n_components=3, n_neighbors=UMAP_N_NEIGHBORS, min_dist=0.0, metric="cosine", random_state=42)
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]
151
+ df = cached_data["df"]
152
+ embeddings = cached_data["embeddings"]
153
 
154
+ champion_docs_by_cluster = {}
155
+ cluster_ids = sorted([c for c in df["cluster"].unique() if c != "-1"], key=int)
156
+
157
+ for cid in cluster_ids:
158
+ mask = df["cluster"] == cid
159
+ cluster_embeddings = embeddings[mask]
160
+ cluster_texts = df[mask]["full_text"].tolist()
161
+ if len(cluster_texts) < 3: continue
162
+ centroid = np.mean(cluster_embeddings, axis=0)
163
+ similarities = cosine_similarity([centroid], cluster_embeddings)[0]
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)}")