Madras1 commited on
Commit
cf48579
·
verified ·
1 Parent(s): 3fe423f

Upload app.py

Browse files
Files changed (1) hide show
  1. app.py +375 -407
app.py CHANGED
@@ -1,407 +1,375 @@
1
- """
2
- Strand Data - Demo Backend
3
- Deploy em HuggingFace Spaces
4
-
5
- Modelo: Madras1/sbert_cosine_filter_v3
6
- Sistema de Âncora: Centróide de exemplos de alta qualidade
7
-
8
- Endpoints:
9
- - POST /classify-quality: Classifica qualidade com sBERT + âncora
10
- - POST /similarity: Retorna score de similaridade com âncora
11
- - POST /qa: Q&A sobre texto usando LLM
12
- - POST /caption: Gera descrição de imagem
13
- """
14
-
15
- import os
16
- import base64
17
- import httpx
18
- from fastapi import FastAPI, HTTPException
19
- from fastapi.middleware.cors import CORSMiddleware
20
- from pydantic import BaseModel
21
- from sentence_transformers import SentenceTransformer, util
22
- import torch
23
- import numpy as np
24
- from typing import Optional
25
-
26
- app = FastAPI(title="Strand Data Demo API")
27
-
28
- # CORS para permitir requests do frontend
29
- app.add_middleware(
30
- CORSMiddleware,
31
- allow_origins=["*"],
32
- allow_credentials=True,
33
- allow_methods=["*"],
34
- allow_headers=["*"],
35
- )
36
-
37
- # ================================
38
- # Configuração
39
- # ================================
40
-
41
- # API Keys (usar secrets do HuggingFace)
42
- CHUTES_API_KEY = os.getenv("CHUTES_API_KEY", "")
43
- OPENROUTER_API_KEY = os.getenv("OPENROUTER_API_KEY", "")
44
-
45
- # Modelo sBERT - SEU MODELO FINE-TUNED
46
- SBERT_MODEL_NAME = "Madras1/sbert_cosine_filter_v3"
47
-
48
- # Threshold de qualidade (baseado no seu pipeline)
49
- QUALITY_THRESHOLD = 0.65
50
-
51
- print(f"🧠 Carregando modelo sBERT: {SBERT_MODEL_NAME}")
52
- device = "cuda" if torch.cuda.is_available() else "cpu"
53
- print(f"📱 Device: {device}")
54
-
55
- sbert_model = SentenceTransformer(SBERT_MODEL_NAME)
56
- sbert_model.to(device)
57
- sbert_model.eval()
58
-
59
- # ================================
60
- # Sistema de Âncora de Qualidade
61
- # ================================
62
-
63
- # Possíveis caminhos do arquivo de âncora (HuggingFace Spaces pode variar)
64
- POSSIBLE_ANCHOR_PATHS = [
65
- "anchor_gold_vector.pt", # Mesmo diretório
66
- "/app/anchor_gold_vector.pt", # Docker padrão
67
- "/home/user/app/anchor_gold_vector.pt", # HF Spaces path
68
- "../anchor_gold_vector.pt", # Um nível acima
69
- ]
70
-
71
- ANCHOR_FILE_PATH = None
72
- for path in POSSIBLE_ANCHOR_PATHS:
73
- if os.path.exists(path):
74
- ANCHOR_FILE_PATH = path
75
- break
76
-
77
- print(f"⚓ Procurando vetor âncora...")
78
-
79
- if ANCHOR_FILE_PATH and os.path.exists(ANCHOR_FILE_PATH):
80
- # Carregar o centróide pré-calculado do seu dataset de ouro
81
- ANCHOR_EMBEDDING = torch.load(ANCHOR_FILE_PATH, map_location=device)
82
- print(f"✅ Vetor âncora carregado de: {ANCHOR_FILE_PATH}")
83
- print(f" Shape: {ANCHOR_EMBEDDING.shape}")
84
- else:
85
- # Fallback: calcular de exemplos hardcoded se arquivo não existir
86
- print("⚠️ Arquivo de âncora não encontrado em nenhum caminho.")
87
- print(f" Caminhos testados: {POSSIBLE_ANCHOR_PATHS}")
88
- print(" Usando exemplos de fallback...")
89
- FALLBACK_EXAMPLES = [
90
- "Este artigo apresenta uma análise detalhada dos métodos de aprendizado de máquina aplicados à visão computacional, com resultados quantitativos robustos.",
91
- "O estudo demonstra correlação significativa entre as variáveis analisadas, utilizando metodologia rigorosa e amostra representativa.",
92
- "A implementação do algoritmo proposto apresenta complexidade O(n log n), com benchmarks comparativos contra soluções estado-da-arte.",
93
- ]
94
- with torch.no_grad():
95
- fallback_embeddings = sbert_model.encode(FALLBACK_EXAMPLES, convert_to_tensor=True)
96
- ANCHOR_EMBEDDING = torch.mean(fallback_embeddings, dim=0)
97
- print(" Âncora de fallback calculada.")
98
-
99
- print(f" Threshold de qualidade: {QUALITY_THRESHOLD}")
100
-
101
- # ================================
102
- # Modelos de Request/Response
103
- # ================================
104
-
105
- class QualityRequest(BaseModel):
106
- text: str
107
-
108
- class QualityResponse(BaseModel):
109
- quality: str # "high", "medium", "low"
110
- similarity_score: float # Similaridade com âncora (0-1)
111
- score_percent: float # Score em porcentagem (0-100)
112
- threshold: float # Threshold usado
113
- verdict: str # Descrição legível
114
-
115
- class SimilarityRequest(BaseModel):
116
- text: str
117
-
118
- class SimilarityResponse(BaseModel):
119
- similarity: float
120
- is_high_quality: bool
121
-
122
- class QARequest(BaseModel):
123
- context: str
124
- question: str
125
-
126
- class QAResponse(BaseModel):
127
- answer: str
128
-
129
- class CaptionRequest(BaseModel):
130
- image_base64: str
131
-
132
- class CaptionResponse(BaseModel):
133
- caption: str
134
-
135
- # ================================
136
- # Funções de Classificação
137
- # ================================
138
-
139
- def compute_quality_score(text: str) -> tuple[float, str, str]:
140
- """
141
- Calcula score de qualidade usando similaridade de cosseno com âncora.
142
- Retorna: (similarity_score, quality_label, verdict)
143
- """
144
- with torch.no_grad():
145
- # Encode com normalização para garantir cálculo correto de cosseno
146
- text_embedding = sbert_model.encode(text, convert_to_tensor=True, normalize_embeddings=True)
147
-
148
- # Normalizar o anchor também (se não estiver normalizado)
149
- anchor_normalized = ANCHOR_EMBEDDING / torch.norm(ANCHOR_EMBEDDING)
150
-
151
- # Debug
152
- text_norm = torch.norm(text_embedding).item()
153
- anchor_norm = torch.norm(anchor_normalized).item()
154
- print(f"📊 DEBUG - Text embedding norm (deve ser ~1.0): {text_norm:.4f}")
155
- print(f"📊 DEBUG - Anchor norm (deve ser ~1.0): {anchor_norm:.4f}")
156
- print(f"📊 DEBUG - Text[:50]: {text[:50]}...")
157
-
158
- # Similaridade de cosseno (com vetores normalizados = dot product)
159
- similarity = util.cos_sim(text_embedding, anchor_normalized).item()
160
- print(f"📊 DEBUG - Similaridade calculada: {similarity:.4f}")
161
-
162
- # Classificação baseada no threshold
163
- if similarity >= QUALITY_THRESHOLD:
164
- quality = "high"
165
- verdict = "✨ Texto de ALTA qualidade! Estrutura e conteúdo técnico excelentes."
166
- elif similarity >= 0.45:
167
- quality = "medium"
168
- verdict = "📝 Qualidade MÉDIA. Tem potencial, mas pode ser aprimorado."
169
- else:
170
- quality = "low"
171
- verdict = "⚠️ Qualidade BAIXA. Requer revisão significativa."
172
-
173
- return similarity, quality, verdict
174
-
175
- # ================================
176
- # LLM Helpers
177
- # ================================
178
-
179
- async def call_llm(prompt: str, system: str = "", max_tokens: int = 500) -> str:
180
- """Chama LLM via Chutes ou OpenRouter."""
181
-
182
- messages = []
183
- if system:
184
- messages.append({"role": "system", "content": system})
185
- messages.append({"role": "user", "content": prompt})
186
-
187
- # Tentar Chutes primeiro
188
- if CHUTES_API_KEY:
189
- try:
190
- async with httpx.AsyncClient(timeout=30) as client:
191
- response = await client.post(
192
- "https://llm.chutes.ai/v1/chat/completions",
193
- headers={
194
- "Authorization": f"Bearer {CHUTES_API_KEY}",
195
- "Content-Type": "application/json"
196
- },
197
- json={
198
- "model": "deepseek-ai/DeepSeek-V3-0324",
199
- "messages": messages,
200
- "max_tokens": max_tokens,
201
- "temperature": 0.7
202
- }
203
- )
204
- if response.status_code == 200:
205
- return response.json()["choices"][0]["message"]["content"]
206
- except Exception as e:
207
- print(f"Erro Chutes: {e}")
208
-
209
- # Fallback para OpenRouter
210
- if OPENROUTER_API_KEY:
211
- try:
212
- async with httpx.AsyncClient(timeout=30) as client:
213
- response = await client.post(
214
- "https://openrouter.ai/api/v1/chat/completions",
215
- headers={
216
- "Authorization": f"Bearer {OPENROUTER_API_KEY}",
217
- "Content-Type": "application/json"
218
- },
219
- json={
220
- "model": "meta-llama/llama-3.3-70b-instruct",
221
- "messages": messages,
222
- "max_tokens": max_tokens
223
- }
224
- )
225
- if response.status_code == 200:
226
- return response.json()["choices"][0]["message"]["content"]
227
- except Exception as e:
228
- print(f"Erro OpenRouter: {e}")
229
-
230
- raise HTTPException(status_code=503, detail="Nenhuma API de LLM disponível")
231
-
232
- async def call_vision_llm(image_base64: str, prompt: str) -> str:
233
- """Chama LLM multimodal para image captioning."""
234
-
235
- # Modelos de visão para tentar (em ordem de preferência)
236
- vision_models = [
237
- "Qwen/Qwen2.5-VL-72B-Instruct-TEE", # Modelo menor, mais disponível
238
- "Qwen/Qwen2-VL-7B-Instruct",
239
- ]
240
-
241
- if CHUTES_API_KEY:
242
- for model_name in vision_models:
243
- try:
244
- print(f"🖼️ Tentando modelo de visão: {model_name}")
245
- async with httpx.AsyncClient(timeout=60) as client:
246
- response = await client.post(
247
- "https://llm.chutes.ai/v1/chat/completions",
248
- headers={
249
- "Authorization": f"Bearer {CHUTES_API_KEY}",
250
- "Content-Type": "application/json"
251
- },
252
- json={
253
- "model": model_name,
254
- "messages": [
255
- {
256
- "role": "user",
257
- "content": [
258
- {"type": "text", "text": prompt},
259
- {"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{image_base64}"}}
260
- ]
261
- }
262
- ],
263
- "max_tokens": 300
264
- }
265
- )
266
- print(f" Status: {response.status_code}")
267
- if response.status_code == 200:
268
- result = response.json()["choices"][0]["message"]["content"]
269
- print(f" ✅ Sucesso com {model_name}")
270
- return result
271
- else:
272
- print(f" ❌ Erro: {response.text[:200]}")
273
- except Exception as e:
274
- print(f" ❌ Exceção: {e}")
275
-
276
- # Fallback: OpenRouter com modelo de visão
277
- if OPENROUTER_API_KEY:
278
- try:
279
- print("🖼️ Tentando OpenRouter para visão...")
280
- async with httpx.AsyncClient(timeout=60) as client:
281
- response = await client.post(
282
- "https://openrouter.ai/api/v1/chat/completions",
283
- headers={
284
- "Authorization": f"Bearer {OPENROUTER_API_KEY}",
285
- "Content-Type": "application/json"
286
- },
287
- json={
288
- "model": "qwen/qwen-2-vl-7b-instruct",
289
- "messages": [
290
- {
291
- "role": "user",
292
- "content": [
293
- {"type": "text", "text": prompt},
294
- {"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{image_base64}"}}
295
- ]
296
- }
297
- ],
298
- "max_tokens": 300
299
- }
300
- )
301
- if response.status_code == 200:
302
- return response.json()["choices"][0]["message"]["content"]
303
- else:
304
- print(f" ❌ OpenRouter erro: {response.text[:200]}")
305
- except Exception as e:
306
- print(f" ❌ OpenRouter exceção: {e}")
307
-
308
- raise HTTPException(status_code=503, detail="API de visão não disponível. Verifique os logs.")
309
-
310
- # ================================
311
- # Endpoints
312
- # ================================
313
-
314
- @app.get("/")
315
- async def root():
316
- return {
317
- "message": "Strand Data Demo API",
318
- "status": "online",
319
- "model": SBERT_MODEL_NAME,
320
- "threshold": QUALITY_THRESHOLD
321
- }
322
-
323
- @app.get("/health")
324
- async def health():
325
- return {
326
- "status": "healthy",
327
- "model_loaded": sbert_model is not None,
328
- "device": device,
329
- "anchor_calibrated": ANCHOR_EMBEDDING is not None
330
- }
331
-
332
- @app.post("/classify-quality", response_model=QualityResponse)
333
- async def classify_quality(request: QualityRequest):
334
- """
335
- Classifica a qualidade de um texto usando sBERT + sistema de âncora.
336
- Usa similaridade de cosseno com centróide de exemplos de alta qualidade.
337
- """
338
- if not request.text.strip():
339
- raise HTTPException(status_code=400, detail="Texto não pode estar vazio")
340
-
341
- similarity, quality, verdict = compute_quality_score(request.text)
342
-
343
- return QualityResponse(
344
- quality=quality,
345
- similarity_score=round(similarity, 4),
346
- score_percent=round(similarity * 100, 2),
347
- threshold=QUALITY_THRESHOLD,
348
- verdict=verdict
349
- )
350
-
351
- @app.post("/similarity", response_model=SimilarityResponse)
352
- async def compute_similarity(request: SimilarityRequest):
353
- """
354
- Endpoint simples: retorna apenas a similaridade com a âncora.
355
- Útil para filtragem em batch.
356
- """
357
- if not request.text.strip():
358
- raise HTTPException(status_code=400, detail="Texto não pode estar vazio")
359
-
360
- with torch.no_grad():
361
- text_embedding = sbert_model.encode(request.text, convert_to_tensor=True, normalize_embeddings=True)
362
- anchor_normalized = ANCHOR_EMBEDDING / torch.norm(ANCHOR_EMBEDDING)
363
- similarity = util.cos_sim(text_embedding, anchor_normalized).item()
364
-
365
- return SimilarityResponse(
366
- similarity=round(similarity, 4),
367
- is_high_quality=similarity >= QUALITY_THRESHOLD
368
- )
369
-
370
- @app.post("/qa", response_model=QAResponse)
371
- async def question_answering(request: QARequest):
372
- """Responde perguntas sobre um texto usando LLM."""
373
-
374
- system_prompt = """Você é um assistente especializado em responder perguntas sobre textos.
375
- Responda de forma precisa e concisa, baseando-se APENAS no contexto fornecido.
376
- Se a resposta não estiver no contexto, diga "Não encontrei essa informação no texto."
377
- Responda em português."""
378
-
379
- prompt = f"""CONTEXTO:
380
- {request.context}
381
-
382
- PERGUNTA:
383
- {request.question}
384
-
385
- RESPOSTA:"""
386
-
387
- answer = await call_llm(prompt, system_prompt, max_tokens=300)
388
- return QAResponse(answer=answer.strip())
389
-
390
- @app.post("/caption", response_model=CaptionResponse)
391
- async def generate_caption(request: CaptionRequest):
392
- """Gera uma descrição/legenda para uma imagem."""
393
-
394
- prompt = """Descreva esta imagem em detalhes.
395
- Inclua: objetos principais, cores, ações, ambiente/cenário.
396
- Responda em português, em 2-3 frases."""
397
-
398
- caption = await call_vision_llm(request.image_base64, prompt)
399
- return CaptionResponse(caption=caption.strip())
400
-
401
- # ================================
402
- # Para rodar localmente
403
- # ================================
404
-
405
- if __name__ == "__main__":
406
- import uvicorn
407
- uvicorn.run(app, host="0.0.0.0", port=7860)
 
1
+ """
2
+ Strand Data - Demo Backend
3
+ Deploy em HuggingFace Spaces
4
+
5
+ Modelo: Madras1/sbert_cosine_filter_v3
6
+ Sistema de Âncora: Centróide de exemplos de alta qualidade
7
+
8
+ Endpoints:
9
+ - POST /classify-quality: Classifica qualidade com sBERT + âncora
10
+ - POST /similarity: Retorna score de similaridade com âncora
11
+ - POST /qa: Q&A sobre texto usando LLM
12
+ - POST /caption: Gera descrição de imagem
13
+ """
14
+
15
+ import os
16
+ import base64
17
+ import httpx
18
+ from fastapi import FastAPI, HTTPException
19
+ from fastapi.middleware.cors import CORSMiddleware
20
+ from pydantic import BaseModel
21
+ from sentence_transformers import SentenceTransformer, util
22
+ import torch
23
+ import numpy as np
24
+ from typing import Optional
25
+
26
+ app = FastAPI(title="Strand Data Demo API")
27
+
28
+ # CORS para permitir requests do frontend
29
+ app.add_middleware(
30
+ CORSMiddleware,
31
+ allow_origins=["*"],
32
+ allow_credentials=True,
33
+ allow_methods=["*"],
34
+ allow_headers=["*"],
35
+ )
36
+
37
+ # ================================
38
+ # Configuração
39
+ # ================================
40
+
41
+ # API Keys (usar secrets do HuggingFace)
42
+ CHUTES_API_KEY = os.getenv("CHUTES_API_KEY", "")
43
+ OPENROUTER_API_KEY = os.getenv("OPENROUTER_API_KEY", "")
44
+
45
+ # Modelo sBERT - SEU MODELO FINE-TUNED
46
+ SBERT_MODEL_NAME = "Madras1/sbert_cosine_filter_v3"
47
+
48
+ # Threshold de qualidade (baseado no seu pipeline)
49
+ QUALITY_THRESHOLD = 0.65
50
+
51
+ print(f"🧠 Carregando modelo sBERT: {SBERT_MODEL_NAME}")
52
+ device = "cuda" if torch.cuda.is_available() else "cpu"
53
+ print(f"📱 Device: {device}")
54
+
55
+ sbert_model = SentenceTransformer(SBERT_MODEL_NAME)
56
+ sbert_model.to(device)
57
+ sbert_model.eval()
58
+
59
+ # ================================
60
+ # Sistema de Âncora de Qualidade
61
+ # ================================
62
+
63
+ # Possíveis caminhos do arquivo de âncora (HuggingFace Spaces pode variar)
64
+ POSSIBLE_ANCHOR_PATHS = [
65
+ "anchor_gold_vector.pt", # Mesmo diretório
66
+ "/app/anchor_gold_vector.pt", # Docker padrão
67
+ "/home/user/app/anchor_gold_vector.pt", # HF Spaces path
68
+ "../anchor_gold_vector.pt", # Um nível acima
69
+ ]
70
+
71
+ ANCHOR_FILE_PATH = None
72
+ for path in POSSIBLE_ANCHOR_PATHS:
73
+ if os.path.exists(path):
74
+ ANCHOR_FILE_PATH = path
75
+ break
76
+
77
+ print(f"⚓ Procurando vetor âncora...")
78
+
79
+ if ANCHOR_FILE_PATH and os.path.exists(ANCHOR_FILE_PATH):
80
+ # Carregar o centróide pré-calculado do seu dataset de ouro
81
+ ANCHOR_EMBEDDING = torch.load(ANCHOR_FILE_PATH, map_location=device)
82
+ print(f"✅ Vetor âncora carregado de: {ANCHOR_FILE_PATH}")
83
+ print(f" Shape: {ANCHOR_EMBEDDING.shape}")
84
+ else:
85
+ # Fallback: calcular de exemplos hardcoded se arquivo não existir
86
+ print("⚠️ Arquivo de âncora não encontrado em nenhum caminho.")
87
+ print(f" Caminhos testados: {POSSIBLE_ANCHOR_PATHS}")
88
+ print(" Usando exemplos de fallback...")
89
+ FALLBACK_EXAMPLES = [
90
+ "Este artigo apresenta uma análise detalhada dos métodos de aprendizado de máquina aplicados à visão computacional, com resultados quantitativos robustos.",
91
+ "O estudo demonstra correlação significativa entre as variáveis analisadas, utilizando metodologia rigorosa e amostra representativa.",
92
+ "A implementação do algoritmo proposto apresenta complexidade O(n log n), com benchmarks comparativos contra soluções estado-da-arte.",
93
+ ]
94
+ with torch.no_grad():
95
+ fallback_embeddings = sbert_model.encode(FALLBACK_EXAMPLES, convert_to_tensor=True)
96
+ ANCHOR_EMBEDDING = torch.mean(fallback_embeddings, dim=0)
97
+ print(" Âncora de fallback calculada.")
98
+
99
+ print(f" Threshold de qualidade: {QUALITY_THRESHOLD}")
100
+
101
+ # ================================
102
+ # Modelos de Request/Response
103
+ # ================================
104
+
105
+ class QualityRequest(BaseModel):
106
+ text: str
107
+
108
+ class QualityResponse(BaseModel):
109
+ quality: str # "high", "medium", "low"
110
+ similarity_score: float # Similaridade com âncora (0-1)
111
+ score_percent: float # Score em porcentagem (0-100)
112
+ threshold: float # Threshold usado
113
+ verdict: str # Descrição legível
114
+
115
+ class SimilarityRequest(BaseModel):
116
+ text: str
117
+
118
+ class SimilarityResponse(BaseModel):
119
+ similarity: float
120
+ is_high_quality: bool
121
+
122
+ class QARequest(BaseModel):
123
+ context: str
124
+ question: str
125
+
126
+ class QAResponse(BaseModel):
127
+ answer: str
128
+
129
+ class CaptionRequest(BaseModel):
130
+ image_base64: str
131
+
132
+ class CaptionResponse(BaseModel):
133
+ caption: str
134
+
135
+ # ================================
136
+ # Funções de Classificação
137
+ # ================================
138
+
139
+ def compute_quality_score(text: str) -> tuple[float, str, str]:
140
+ """
141
+ Calcula score de qualidade usando similaridade de cosseno com âncora.
142
+ Retorna: (similarity_score, quality_label, verdict)
143
+ """
144
+ with torch.no_grad():
145
+ # Encode com normalização para garantir cálculo correto de cosseno
146
+ text_embedding = sbert_model.encode(text, convert_to_tensor=True, normalize_embeddings=True)
147
+
148
+ # Normalizar o anchor também (se não estiver normalizado)
149
+ anchor_normalized = ANCHOR_EMBEDDING / torch.norm(ANCHOR_EMBEDDING)
150
+
151
+ # Debug
152
+ text_norm = torch.norm(text_embedding).item()
153
+ anchor_norm = torch.norm(anchor_normalized).item()
154
+ print(f"📊 DEBUG - Text embedding norm (deve ser ~1.0): {text_norm:.4f}")
155
+ print(f"📊 DEBUG - Anchor norm (deve ser ~1.0): {anchor_norm:.4f}")
156
+ print(f"📊 DEBUG - Text[:50]: {text[:50]}...")
157
+
158
+ # Similaridade de cosseno (com vetores normalizados = dot product)
159
+ similarity = util.cos_sim(text_embedding, anchor_normalized).item()
160
+ print(f"📊 DEBUG - Similaridade calculada: {similarity:.4f}")
161
+
162
+ # Classificação baseada no threshold
163
+ if similarity >= QUALITY_THRESHOLD:
164
+ quality = "high"
165
+ verdict = "✨ Texto de ALTA qualidade! Estrutura e conteúdo técnico excelentes."
166
+ elif similarity >= 0.45:
167
+ quality = "medium"
168
+ verdict = "📝 Qualidade MÉDIA. Tem potencial, mas pode ser aprimorado."
169
+ else:
170
+ quality = "low"
171
+ verdict = "⚠️ Qualidade BAIXA. Requer revisão significativa."
172
+
173
+ return similarity, quality, verdict
174
+
175
+ # ================================
176
+ # LLM Helpers
177
+ # ================================
178
+
179
+ async def call_llm(prompt: str, system: str = "", max_tokens: int = 500) -> str:
180
+ """Chama LLM via Chutes ou OpenRouter."""
181
+
182
+ messages = []
183
+ if system:
184
+ messages.append({"role": "system", "content": system})
185
+ messages.append({"role": "user", "content": prompt})
186
+
187
+ # Tentar Chutes primeiro
188
+ if CHUTES_API_KEY:
189
+ try:
190
+ async with httpx.AsyncClient(timeout=30) as client:
191
+ response = await client.post(
192
+ "https://llm.chutes.ai/v1/chat/completions",
193
+ headers={
194
+ "Authorization": f"Bearer {CHUTES_API_KEY}",
195
+ "Content-Type": "application/json"
196
+ },
197
+ json={
198
+ "model": "deepseek-ai/DeepSeek-V3-0324",
199
+ "messages": messages,
200
+ "max_tokens": max_tokens,
201
+ "temperature": 0.7
202
+ }
203
+ )
204
+ if response.status_code == 200:
205
+ return response.json()["choices"][0]["message"]["content"]
206
+ except Exception as e:
207
+ print(f"Erro Chutes: {e}")
208
+
209
+ # Fallback para OpenRouter
210
+ if OPENROUTER_API_KEY:
211
+ try:
212
+ async with httpx.AsyncClient(timeout=30) as client:
213
+ response = await client.post(
214
+ "https://openrouter.ai/api/v1/chat/completions",
215
+ headers={
216
+ "Authorization": f"Bearer {OPENROUTER_API_KEY}",
217
+ "Content-Type": "application/json"
218
+ },
219
+ json={
220
+ "model": "nex-agi/deepseek-v3.1-nex-n1:free",
221
+ "messages": messages,
222
+ "max_tokens": max_tokens
223
+ }
224
+ )
225
+ if response.status_code == 200:
226
+ return response.json()["choices"][0]["message"]["content"]
227
+ except Exception as e:
228
+ print(f"Erro OpenRouter: {e}")
229
+
230
+ raise HTTPException(status_code=503, detail="Nenhuma API de LLM disponível")
231
+
232
+ async def call_vision_llm(image_base64: str, prompt: str) -> str:
233
+ """Chama LLM multimodal para image captioning."""
234
+
235
+ # Modelos de visão na Chutes (em ordem de preferência)
236
+ vision_models = [
237
+ "Qwen/Qwen2.5-VL-72B-Instruct-TEE", # TEE
238
+ "Qwen/Qwen3-VL-235B-A22B-Instruct", # Qwen3
239
+ ]
240
+
241
+ if CHUTES_API_KEY:
242
+ for model_name in vision_models:
243
+ try:
244
+ print(f"🖼️ Tentando modelo de visão: {model_name}")
245
+ async with httpx.AsyncClient(timeout=60) as client:
246
+ response = await client.post(
247
+ "https://llm.chutes.ai/v1/chat/completions",
248
+ headers={
249
+ "Authorization": f"Bearer {CHUTES_API_KEY}",
250
+ "Content-Type": "application/json"
251
+ },
252
+ json={
253
+ "model": model_name,
254
+ "messages": [
255
+ {
256
+ "role": "user",
257
+ "content": [
258
+ {"type": "text", "text": prompt},
259
+ {"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{image_base64}"}}
260
+ ]
261
+ }
262
+ ],
263
+ "max_tokens": 300
264
+ }
265
+ )
266
+ print(f" Status: {response.status_code}")
267
+ if response.status_code == 200:
268
+ result = response.json()["choices"][0]["message"]["content"]
269
+ print(f" ✅ Sucesso com {model_name}")
270
+ return result
271
+ else:
272
+ print(f" ❌ Erro: {response.text[:200]}")
273
+ except Exception as e:
274
+ print(f" ❌ Exceção: {e}")
275
+
276
+ raise HTTPException(status_code=503, detail="API de visão não disponível. Verifique os logs do container.")
277
+
278
+ # ================================
279
+ # Endpoints
280
+ # ================================
281
+
282
+ @app.get("/")
283
+ async def root():
284
+ return {
285
+ "message": "Strand Data Demo API",
286
+ "status": "online",
287
+ "model": SBERT_MODEL_NAME,
288
+ "threshold": QUALITY_THRESHOLD
289
+ }
290
+
291
+ @app.get("/health")
292
+ async def health():
293
+ return {
294
+ "status": "healthy",
295
+ "model_loaded": sbert_model is not None,
296
+ "device": device,
297
+ "anchor_calibrated": ANCHOR_EMBEDDING is not None
298
+ }
299
+
300
+ @app.post("/classify-quality", response_model=QualityResponse)
301
+ async def classify_quality(request: QualityRequest):
302
+ """
303
+ Classifica a qualidade de um texto usando sBERT + sistema de âncora.
304
+ Usa similaridade de cosseno com centróide de exemplos de alta qualidade.
305
+ """
306
+ if not request.text.strip():
307
+ raise HTTPException(status_code=400, detail="Texto não pode estar vazio")
308
+
309
+ similarity, quality, verdict = compute_quality_score(request.text)
310
+
311
+ return QualityResponse(
312
+ quality=quality,
313
+ similarity_score=round(similarity, 4),
314
+ score_percent=round(similarity * 100, 2),
315
+ threshold=QUALITY_THRESHOLD,
316
+ verdict=verdict
317
+ )
318
+
319
+ @app.post("/similarity", response_model=SimilarityResponse)
320
+ async def compute_similarity(request: SimilarityRequest):
321
+ """
322
+ Endpoint simples: retorna apenas a similaridade com a âncora.
323
+ Útil para filtragem em batch.
324
+ """
325
+ if not request.text.strip():
326
+ raise HTTPException(status_code=400, detail="Texto não pode estar vazio")
327
+
328
+ with torch.no_grad():
329
+ text_embedding = sbert_model.encode(request.text, convert_to_tensor=True, normalize_embeddings=True)
330
+ anchor_normalized = ANCHOR_EMBEDDING / torch.norm(ANCHOR_EMBEDDING)
331
+ similarity = util.cos_sim(text_embedding, anchor_normalized).item()
332
+
333
+ return SimilarityResponse(
334
+ similarity=round(similarity, 4),
335
+ is_high_quality=similarity >= QUALITY_THRESHOLD
336
+ )
337
+
338
+ @app.post("/qa", response_model=QAResponse)
339
+ async def question_answering(request: QARequest):
340
+ """Responde perguntas sobre um texto usando LLM."""
341
+
342
+ system_prompt = """Você é um assistente especializado em responder perguntas sobre textos.
343
+ Responda de forma precisa e concisa, baseando-se APENAS no contexto fornecido.
344
+ Se a resposta não estiver no contexto, diga "Não encontrei essa informação no texto."
345
+ Responda em português."""
346
+
347
+ prompt = f"""CONTEXTO:
348
+ {request.context}
349
+
350
+ PERGUNTA:
351
+ {request.question}
352
+
353
+ RESPOSTA:"""
354
+
355
+ answer = await call_llm(prompt, system_prompt, max_tokens=300)
356
+ return QAResponse(answer=answer.strip())
357
+
358
+ @app.post("/caption", response_model=CaptionResponse)
359
+ async def generate_caption(request: CaptionRequest):
360
+ """Gera uma descrição/legenda para uma imagem."""
361
+
362
+ prompt = """Descreva esta imagem em detalhes.
363
+ Inclua: objetos principais, cores, ações, ambiente/cenário.
364
+ Responda em português, em 2-3 frases."""
365
+
366
+ caption = await call_vision_llm(request.image_base64, prompt)
367
+ return CaptionResponse(caption=caption.strip())
368
+
369
+ # ================================
370
+ # Para rodar localmente
371
+ # ================================
372
+
373
+ if __name__ == "__main__":
374
+ import uvicorn
375
+ uvicorn.run(app, host="0.0.0.0", port=7860)