File size: 11,830 Bytes
c817084
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
"""
AetherMap Client
Client para integração com AetherMap API - busca semântica, NER e análise de grafos.
"""
import httpx
import json
import io
from typing import List, Dict, Any, Optional
from dataclasses import dataclass, field
from datetime import datetime
import logging

from app.config import settings

logger = logging.getLogger(__name__)


# URL base do AetherMap (HuggingFace Space)
AETHERMAP_URL = getattr(settings, 'aethermap_url', 'https://madras1-aethermap.hf.space')


@dataclass
class ProcessResult:
    """Resultado do processamento de documentos"""
    job_id: str
    num_documents: int
    num_clusters: int
    num_noise: int
    metrics: Dict[str, Any] = field(default_factory=dict)
    cluster_analysis: Dict[str, Any] = field(default_factory=dict)


@dataclass
class SearchResult:
    """Resultado de busca semântica"""
    summary: str  # Resposta RAG gerada pelo LLM
    results: List[Dict[str, Any]] = field(default_factory=list)


@dataclass 
class EntityNode:
    """Nó de entidade no grafo"""
    entity: str
    entity_type: str
    docs: int
    degree: int = 0
    centrality: float = 0.0
    role: str = "peripheral"  # hub, connector, peripheral


@dataclass
class EntityEdge:
    """Aresta do grafo de entidades"""
    source_entity: str
    target_entity: str
    weight: int
    reason: str


@dataclass
class EntityGraphResult:
    """Resultado da extração de entidades"""
    nodes: List[EntityNode] = field(default_factory=list)
    edges: List[EntityEdge] = field(default_factory=list)
    hubs: List[Dict[str, Any]] = field(default_factory=list)
    insights: Dict[str, Any] = field(default_factory=dict)


@dataclass
class GraphAnalysis:
    """Análise do grafo via LLM"""
    analysis: str
    key_entities: List[str] = field(default_factory=list)
    relationships: List[str] = field(default_factory=list)


class AetherMapClient:
    """
    Client para AetherMap API.
    
    Funcionalidades:
    - Processamento de documentos (embeddings + clusters)
    - Busca semântica RAG (FAISS + BM25 + reranking + LLM)
    - Extração de entidades NER
    - Análise de grafo via LLM
    """
    
    def __init__(self, base_url: str = None, timeout: float = 1800.0):
        self.base_url = (base_url or AETHERMAP_URL).rstrip('/')
        self.timeout = timeout
        self._current_job_id: Optional[str] = None
    
    @property
    def current_job_id(self) -> Optional[str]:
        """Retorna o job_id atual"""
        return self._current_job_id
    
    async def process_documents(
        self,
        texts: List[str],
        fast_mode: bool = True,
        min_cluster_size: int = 0,
        min_samples: int = 0
    ) -> ProcessResult:
        """
        Processa uma lista de textos gerando embeddings e clusters.
        
        Args:
            texts: Lista de textos/documentos
            fast_mode: Se True, usa PCA (rápido). Se False, usa UMAP (preciso)
            min_cluster_size: Tamanho mínimo do cluster (0=auto)
            min_samples: Mínimo de amostras (0=auto)
            
        Returns:
            ProcessResult com job_id e métricas
        """
        # Criar arquivo TXT em memória
        content = "\n".join(texts)
        file_bytes = content.encode('utf-8')
        
        try:
            async with httpx.AsyncClient(timeout=self.timeout) as client:
                files = {
                    'file': ('documents.txt', io.BytesIO(file_bytes), 'text/plain')
                }
                data = {
                    'n_samples': str(len(texts)),
                    'fast_mode': 'true' if fast_mode else 'false',
                    'min_cluster_size': str(min_cluster_size),
                    'min_samples': str(min_samples)
                }
                
                logger.info(f"AetherMap: Processando {len(texts)} documentos para {self.base_url}/process/")
                
                response = await client.post(
                    f"{self.base_url}/process/",
                    files=files,
                    data=data
                )
                
                logger.info(f"AetherMap: Response status {response.status_code}")
                
                if response.status_code != 200:
                    error_text = response.text[:500] if response.text else "No response body"
                    logger.error(f"AetherMap error: {response.status_code} - {error_text}")
                    raise Exception(f"AetherMap error: {response.status_code} - {error_text}")
                
                result = response.json()
                
                self._current_job_id = result.get('job_id')
                metadata = result.get('metadata', {})
                
                logger.info(f"AetherMap: Job criado {self._current_job_id}")
                
                return ProcessResult(
                    job_id=self._current_job_id or "unknown",
                    num_documents=metadata.get('num_documents_processed', len(texts)),
                    num_clusters=metadata.get('num_clusters_found', 0),
                    num_noise=metadata.get('num_noise_points', 0),
                    metrics=result.get('metrics', {}),
                    cluster_analysis=result.get('cluster_analysis', {})
                )
        except httpx.TimeoutException:
            logger.error(f"AetherMap: Timeout ao conectar com {self.base_url}")
            raise Exception(f"Timeout: AetherMap Space pode estar dormindo. Tente novamente em alguns segundos.")
        except httpx.ConnectError as e:
            logger.error(f"AetherMap: Erro de conexão: {e}")
            raise Exception(f"Erro de conexão com AetherMap: {e}")
        except Exception as e:
            logger.error(f"AetherMap: Erro inesperado: {e}")
            raise
    
    async def semantic_search(
        self,
        query: str,
        job_id: str = None,
        turbo_mode: bool = False
    ) -> SearchResult:
        """
        Busca semântica RAG híbrida nos documentos processados.
        
        Args:
            query: Termo de busca
            job_id: ID do job (se não fornecido, usa o último)
            turbo_mode: Se True, busca mais rápida (menos precisa)
            
        Returns:
            SearchResult com resumo e resultados
        """
        job_id = job_id or self._current_job_id
        if not job_id:
            raise ValueError("Nenhum job_id disponível. Processe documentos primeiro.")
        
        async with httpx.AsyncClient(timeout=self.timeout) as client:
            data = {
                'query': query,
                'job_id': job_id,
                'turbo_mode': 'true' if turbo_mode else 'false'
            }
            
            logger.info(f"AetherMap: Buscando '{query}'...")
            
            response = await client.post(
                f"{self.base_url}/search/",
                data=data
            )
            
            if response.status_code != 200:
                raise Exception(f"AetherMap search error: {response.status_code} - {response.text}")
            
            result = response.json()
            
            return SearchResult(
                summary=result.get('summary', ''),
                results=result.get('results', [])
            )
    
    async def extract_entities(self, job_id: str = None) -> EntityGraphResult:
        """
        Extrai entidades nomeadas (NER) e cria grafo de conexões.
        
        Args:
            job_id: ID do job (se não fornecido, usa o último)
            
        Returns:
            EntityGraphResult com nós, arestas e insights
        """
        job_id = job_id or self._current_job_id
        if not job_id:
            raise ValueError("Nenhum job_id disponível. Processe documentos primeiro.")
        
        async with httpx.AsyncClient(timeout=self.timeout) as client:
            data = {'job_id': job_id}
            
            logger.info(f"AetherMap: Extraindo entidades...")
            
            response = await client.post(
                f"{self.base_url}/entity_graph/",
                data=data
            )
            
            if response.status_code != 200:
                raise Exception(f"AetherMap entity_graph error: {response.status_code} - {response.text}")
            
            result = response.json()
            
            # Converter para dataclasses
            nodes = [
                EntityNode(
                    entity=n.get('entity', ''),
                    entity_type=n.get('type', ''),
                    docs=n.get('docs', 0),
                    degree=n.get('degree', 0),
                    centrality=n.get('centrality', 0.0),
                    role=n.get('role', 'peripheral')
                )
                for n in result.get('nodes', [])
            ]
            
            edges = [
                EntityEdge(
                    source_entity=e.get('source_entity', ''),
                    target_entity=e.get('target_entity', ''),
                    weight=e.get('weight', 0),
                    reason=e.get('reason', '')
                )
                for e in result.get('edges', [])
            ]
            
            return EntityGraphResult(
                nodes=nodes,
                edges=edges,
                hubs=result.get('hubs', []),
                insights=result.get('insights', {})
            )
    
    async def analyze_graph(self, job_id: str = None) -> GraphAnalysis:
        """
        Usa LLM para analisar o Knowledge Graph e extrair insights.
        
        Args:
            job_id: ID do job (se não fornecido, usa o último)
            
        Returns:
            GraphAnalysis com análise textual
        """
        job_id = job_id or self._current_job_id
        if not job_id:
            raise ValueError("Nenhum job_id disponível. Processe documentos primeiro.")
        
        async with httpx.AsyncClient(timeout=self.timeout) as client:
            data = {'job_id': job_id}
            
            logger.info(f"AetherMap: Analisando grafo com LLM...")
            
            response = await client.post(
                f"{self.base_url}/analyze_graph/",
                data=data
            )
            
            if response.status_code != 200:
                raise Exception(f"AetherMap analyze_graph error: {response.status_code} - {response.text}")
            
            result = response.json()
            
            return GraphAnalysis(
                analysis=result.get('analysis', ''),
                key_entities=result.get('key_entities', []),
                relationships=result.get('relationships', [])
            )
    
    async def describe_clusters(self, job_id: str = None) -> Dict[str, Any]:
        """
        Usa LLM para descrever cada cluster encontrado.
        
        Args:
            job_id: ID do job (se não fornecido, usa o último)
            
        Returns:
            Dict com insights por cluster
        """
        job_id = job_id or self._current_job_id
        if not job_id:
            raise ValueError("Nenhum job_id disponível. Processe documentos primeiro.")
        
        async with httpx.AsyncClient(timeout=self.timeout) as client:
            data = {'job_id': job_id}
            
            logger.info(f"AetherMap: Descrevendo clusters...")
            
            response = await client.post(
                f"{self.base_url}/describe_clusters/",
                data=data
            )
            
            if response.status_code != 200:
                raise Exception(f"AetherMap describe_clusters error: {response.status_code} - {response.text}")
            
            return response.json()


# Instância global do client
aethermap = AetherMapClient()