File size: 21,897 Bytes
268bcbc
6d84c1e
 
 
 
 
 
 
b045c75
6d84c1e
 
 
 
 
e6a17c6
375af7c
bb25274
b045c75
ad3a9b1
7a7ea26
6d84c1e
 
 
 
 
 
 
 
 
 
 
5449dae
6d84c1e
375af7c
6d84c1e
0326819
6d84c1e
 
 
 
fcbcf80
 
6d84c1e
9bc61d6
0326819
4fcf99d
375af7c
268bcbc
 
 
9bc61d6
375af7c
0326819
 
 
c12c2b2
0326819
 
 
 
c12c2b2
375af7c
af8b5ef
6d84c1e
5449dae
b52f75c
b045c75
b52f75c
b045c75
 
 
 
 
 
 
b52f75c
 
 
 
9bc61d6
6d84c1e
 
b045c75
fcbcf80
b52f75c
4fcf99d
64f4947
 
 
 
 
4fcf99d
64f4947
 
 
fcbcf80
 
 
 
 
 
 
 
64f4947
 
fcbcf80
 
 
 
 
b045c75
 
 
64f4947
b045c75
 
 
fcbcf80
 
 
 
b045c75
 
fcbcf80
b045c75
64f4947
fcbcf80
64f4947
4fcf99d
1327f0a
4fcf99d
64f4947
 
 
b64ff44
fcbcf80
ad3a9b1
c12c2b2
ad3a9b1
 
b045c75
ad3a9b1
0288ae3
 
ad3a9b1
 
b64ff44
 
 
0288ae3
b64ff44
 
ad3a9b1
c12c2b2
4fcf99d
268bcbc
 
 
 
 
 
 
 
b64ff44
6d84c1e
4fcf99d
b045c75
 
e60da5a
b045c75
e60da5a
 
b045c75
 
 
 
 
 
 
 
 
 
26317bf
 
 
 
 
 
 
 
c12c2b2
26317bf
 
 
c12c2b2
26317bf
 
 
b045c75
fcbcf80
 
 
 
 
 
c12c2b2
fcbcf80
 
 
 
 
 
b045c75
fcbcf80
 
 
 
 
 
 
 
 
 
 
 
b045c75
fcbcf80
 
 
 
 
 
 
 
b045c75
fcbcf80
 
b045c75
af8b5ef
84d5c77
c12c2b2
84d5c77
c12c2b2
84d5c77
fcbcf80
84d5c77
 
 
 
 
 
 
e60da5a
84d5c77
b045c75
e60da5a
84d5c77
 
 
 
 
c12c2b2
84d5c77
c12c2b2
af8b5ef
4fcf99d
 
 
 
b045c75
4fcf99d
 
 
 
 
0288ae3
b64ff44
4fcf99d
b64ff44
0288ae3
e60da5a
ad3a9b1
e6a17c6
 
b045c75
 
 
 
 
e6a17c6
b64ff44
 
 
 
 
4fcf99d
b64ff44
26317bf
268bcbc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e60da5a
 
268bcbc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4fcf99d
9bc61d6
4fcf99d
9bc61d6
6d84c1e
9bc61d6
4fcf99d
 
9bc61d6
 
4fcf99d
9bc61d6
 
4fcf99d
9bc61d6
 
 
4fcf99d
 
1327f0a
e60da5a
4fcf99d
c12c2b2
4fcf99d
 
0326819
4fcf99d
 
0288ae3
 
e60da5a
 
6d84c1e
a5cd226
 
ad3a9b1
e60da5a
ad3a9b1
b045c75
 
 
 
a5cd226
e60da5a
a5cd226
 
 
b045c75
a5cd226
268bcbc
b045c75
 
e60da5a
b045c75
 
 
 
 
268bcbc
 
 
 
 
 
e60da5a
 
b045c75
 
 
 
 
268bcbc
b045c75
 
 
 
268bcbc
b045c75
e60da5a
268bcbc
 
e60da5a
268bcbc
b045c75
a5cd226
 
e60da5a
a5cd226
b045c75
 
e60da5a
b045c75
 
 
e60da5a
b045c75
e60da5a
b045c75
 
e60da5a
 
b045c75
 
 
 
 
268bcbc
 
e60da5a
b045c75
e60da5a
268bcbc
 
e60da5a
268bcbc
 
 
 
b045c75
 
 
 
e60da5a
c12c2b2
e60da5a
 
 
26317bf
6d84c1e
b045c75
 
 
 
 
 
 
 
 
 
9bc61d6
4fcf99d
b045c75
 
4fcf99d
b045c75
 
4fcf99d
0326819
b045c75
fcbcf80
b045c75
 
4fcf99d
 
 
b045c75
4fcf99d
 
 
 
 
b045c75
e60da5a
4fcf99d
 
e6a17c6
4fcf99d
 
 
 
 
0326819
b045c75
 
4fcf99d
b045c75
 
4fcf99d
 
 
b045c75
4fcf99d
 
 
 
 
b045c75
e60da5a
4fcf99d
 
e6a17c6
4fcf99d
 
 
9bc61d6
c12c2b2
b045c75
4fcf99d
 
 
6d84c1e
9bc61d6
af8b5ef
 
 
e60da5a
 
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
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
from fastapi import FastAPI, HTTPException, BackgroundTasks
from fastapi.responses import Response
from pydantic import BaseModel
import torch
import open_clip
import numpy as np
import json
import os
import random
import requests
from fastapi.middleware.cors import CORSMiddleware
import base64
from datasets import load_dataset
from io import BytesIO
from PIL import Image, ImageDraw, ImageFont
from huggingface_hub import login
import google.generativeai as genai 
from typing import Optional, List, Any, Dict, Union
# IMPORTANTE: Importamos LCMScheduler
from diffusers import StableDiffusionPipeline, LCMScheduler

app = FastAPI(title="Mirage Medical Search API")

app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"], 
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

# --- 1. CONFIGURACIÓN DE MODELOS ---
MODEL_NAME = 'hf-hub:luhuitong/CLIP-ViT-L-14-448px-MedICaT-ROCO'
HF_DATASET_ID = "mdwiratathya/ROCO-radiology"
SPLIT = "train"
device = "cpu"

# Variables Globales
model = None
tokenizer = None
embeddings = None        # Image Embeddings (Visual)
text_embeddings = None   # Caption Embeddings (Semántico - NUEVO)
metadata = None
dataset_stream = None 
gemini_available = False
pipe = None 

# Variables para el juego (Neural Training)
GAME_CACHE = []

# --- 2. AUTENTICACIÓN ---
try:
    hf_token = os.environ.get('HF_TOKEN')
    if hf_token:
        login(token=hf_token)
    
    google_key = os.environ.get('GOOGLE_API_KEY')
    if google_key:
        genai.configure(api_key=google_key)
        gemini_available = True
    
except Exception as e:
    print(f"Error auth: {e}")

# --- HELPER: PLACEHOLDER ---
def create_placeholder_image(text="Image Error"):
    img = Image.new('RGB', (512, 512), color=(40, 40, 45))
    d = ImageDraw.Draw(img)
    try:
        font = ImageFont.truetype("arial.ttf", 20)
    except:
        font = None
        
    d.text((20, 200), f"No Image Available", fill=(255, 100, 100), font=font)
    d.text((20, 230), f"{text}", fill=(200, 200, 200), font=font)
    img_byte_arr = BytesIO()
    img.save(img_byte_arr, format='JPEG')
    return img_byte_arr.getvalue()

# --- 3. CARGA DE DATOS ---
@app.on_event("startup")
async def load_data():
    global model, tokenizer, embeddings, text_embeddings, metadata, dataset_stream, pipe
    print("--- INICIANDO MIRAGE BACKEND (v2.3 - Weighted Retrieval) ---")
    
    # 1. CARGAR CLIP
    try:
        print("👁️ Cargando CLIP...")
        model, _, _ = open_clip.create_model_and_transforms(MODEL_NAME, device=device)
        tokenizer = open_clip.get_tokenizer(MODEL_NAME)
        model.eval()
        print("✅ CLIP cargado.")
    except Exception as e:
        print(f"❌ Error CLIP: {e}")

    # 2. CARGAR METADATA (Prioridad a metadata_text.json si existe)
    print("📦 Cargando Metadata...")
    if os.path.exists("metadata_text.json"):
        print("   -> Usando metadata_text.json")
        with open("metadata_text.json", 'r') as f:
            metadata = json.load(f)
    elif os.path.exists("metadata.json"):
        print("   -> Usando metadata.json (Fallback)")
        with open("metadata.json", 'r') as f:
            metadata = json.load(f)
    else:
        print("⚠️ NO SE ENCONTRÓ METADATA.")
        metadata = [{"dataset_index": 0, "filename": "error", "caption": "Error"}]

    # 3. CARGAR EMBEDDINGS DE IMAGEN
    if os.path.exists("embeddings.npy"):
        embeddings = np.load("embeddings.npy")
        print(f"✅ Image Embeddings listos: {embeddings.shape[0]} registros.")
    else:
        print("⚠️ NO SE ENCONTRARON IMAGE EMBEDDINGS.")
        embeddings = np.zeros((1, 768))

    # 4. CARGAR EMBEDDINGS DE TEXTO (NUEVO)
    # Buscamos el archivo generado por tu script 'embeddings_text.py'
    if os.path.exists("embeddings_text.npy"):
        text_embeddings = np.load("embeddings_text.npy")
        print(f"✅ Text Embeddings listos: {text_embeddings.shape[0]} registros.")
    else:
        print("⚠️ NO SE ENCONTRARON TEXT EMBEDDINGS (embeddings_text.npy).")
        text_embeddings = None

    # 5. CARGAR DATASET
    try:
        print("📦 Cargando Dataset en RAM (1-2 mins)...")
        dataset_stream = load_dataset(HF_DATASET_ID, split=SPLIT, streaming=False) 
        print(f"✅ Dataset listo. Total: {len(dataset_stream)}")
    except Exception as e:
        print(f"❌ Error dataset: {e}")
        dataset_stream = None

    # 6. CARGAR STABLE DIFFUSION
    print("🎨 Cargando modelo generativo (LCM Mode)...")
    try:
        model_id = "Nihirc/Prompt2MedImage"
        pipe = StableDiffusionPipeline.from_pretrained(model_id, torch_dtype=torch.float32)
        print("⚡ Inyectando pesos LCM-LoRA...")
        pipe.load_lora_weights("latent-consistency/lcm-lora-sdv1-5")
        pipe.fuse_lora() 
        pipe.scheduler = LCMScheduler.from_config(pipe.scheduler.config, solver_order=2)
        pipe.safety_checker = None
        pipe.requires_safety_checker = False
        
        if device == "cpu":
            pipe = pipe.to("cpu")
            pipe.enable_attention_slicing() 
        else:
            pipe = pipe.to("cuda")
        print("✅ Generador LCM listo.")
    except Exception as e:
        print(f"❌ Error Generador: {e}")
    
    # Inicializar Cache del Juego
    try:
        print("🎮 Precalentando Cache del Juego...")
        populate_game_cache(20)
        print(f"✅ Juego listo con {len(GAME_CACHE)} preguntas.")
    except Exception as e:
        print(f"⚠️ No se pudo inicializar el juego: {e}")


# --- 4. FUNCIONES CORE ---

def translate_to_english(text, source_lang):
    if not text or not source_lang: return text
    clean_lang = source_lang.lower().strip()
    if clean_lang in ['en', 'english', 'inglés', 'ingles']: return text
    if not gemini_available: return text

    try:
        model_llm = genai.GenerativeModel('gemini-2.5-flash')
        prompt = f"Translate the following medical text from {source_lang} to English. Maintain strict medical terminology accuracy. Only output the translated text, nothing else.\n\nText: {text}"
        response = model_llm.generate_content(prompt)
        return response.text.strip()
    except Exception as e:
        print(f"❌ Translation Error: {e}")
        return text

def calculate_vector(text, add=None, sub=None):
    with torch.no_grad():
        text_tokens = tokenizer([text]).to(device)
        vec = model.encode_text(text_tokens)
        vec /= vec.norm(dim=-1, keepdim=True)
        if add and add.strip():
            add_vec = model.encode_text(tokenizer([add]).to(device))
            add_vec /= add_vec.norm(dim=-1, keepdim=True)
            vec = vec + add_vec
        if sub and sub.strip():
            sub_vec = model.encode_text(tokenizer([sub]).to(device))
            sub_vec /= sub_vec.norm(dim=-1, keepdim=True)
            vec = vec - sub_vec
        vec /= vec.norm(dim=-1, keepdim=True)
        return vec

def get_retrieval_and_context(query_vector, top_k, alpha=1.0):
    """
    Realiza el retrieval ponderado.
    alpha = 1.0 -> Solo similitud visual (Imagen vs Texto Query)
    alpha = 0.0 -> Solo similitud semántica (Caption vs Texto Query)
    alpha = 0.5 -> Híbrido
    """
    query_vec_np = query_vector.cpu().numpy()
    
    # Aseguramos que usamos el mínimo común de elementos para evitar errores de dimensión
    n_imgs = embeddings.shape[0]
    n_txts = text_embeddings.shape[0] if text_embeddings is not None else 0
    
    # Si tenemos ambos, el límite es el menor de los dos para alinear índices
    if text_embeddings is not None:
        limit = min(n_imgs, n_txts)
        # Slicing seguro: usamos solo hasta el 'limit'
        current_embeddings = embeddings[:limit]
        current_text_embeddings = text_embeddings[:limit]
    else:
        limit = n_imgs
        current_embeddings = embeddings
        current_text_embeddings = None

    # 1. Similitud Visual (Query vs Image Embeddings)
    # query_vec_np es (1, 768), embeddings es (N, 768) -> resultado (N,)
    sim_img = (query_vec_np @ current_embeddings.T).squeeze()
    
    # 2. Similitud Semántica (Query vs Caption Embeddings)
    sim_txt = np.zeros_like(sim_img)
    
    if current_text_embeddings is not None:
        sim_txt = (query_vec_np @ current_text_embeddings.T).squeeze()
    
    # 3. Combinación Ponderada
    # Si alpha es 1.0, sim_txt se ignora. Si alpha es 0, sim_img se ignora.
    final_scores = (alpha * sim_img) + ((1.0 - alpha) * sim_txt)
    
    # Ordenar índices (descendente)
    best_indices = final_scores.argsort()[-top_k:][::-1]
    
    real_matches = []
    retrieved_captions = []

    for idx in best_indices:
        idx = int(idx)
        # Validación de seguridad por si metadata es más corta
        if idx >= len(metadata): continue
        
        meta = metadata[idx]
        safe_index = meta.get('dataset_index', idx)
        
        real_matches.append({
            "url": f"/image/{safe_index}",
            "score": float(final_scores[idx]),
            "filename": meta.get("filename", "img"),
            "caption": meta.get("caption", ""),
            "index": safe_index 
        })

        cap = meta.get("caption", "")
        if cap and len(cap) > 5: 
            retrieved_captions.append(cap)
        
    return real_matches, retrieved_captions

def generate_llm_prompt(captions, user_text):
    if not gemini_available or not captions:
        return user_text + ". " + (captions[0] if captions else "")
    try:
        llm = genai.GenerativeModel('gemini-2.5-flash')
        prompt = f"Summarize these medical findings into a concise radiology description based on the query '{user_text}': {', '.join(captions[:3])}"
        res = llm.generate_content(prompt)
        return res.text.strip()
    except: 
        return user_text

def generate_synthetic_image(prompt, steps=5, guidance=1.5):
    global pipe
    if pipe is None: return None
    try:
        NEGATIVE_PROMPT = "painting, artistic, drawing, illustration, blur, low quality, distorted, abstract, text, watermark, grid, noise, glitch"
        image = pipe(prompt[:77], height=512, width=512, num_inference_steps=steps, guidance_scale=guidance, negative_prompt=NEGATIVE_PROMPT).images[0]
        
        draw = ImageDraw.Draw(image)
        text = "Created by MIRAGE OS"
        try: font = ImageFont.load_default() 
        except: font = None
        bbox = draw.textbbox((0, 0), text, font=font)
        text_w, text_h = bbox[2] - bbox[0], bbox[3] - bbox[1]
        draw.text((image.width - text_w - 20, image.height - text_h - 15), text, fill=(255, 225, 210), font=font)

        buffered = BytesIO()
        image.save(buffered, format="JPEG")
        img_str = base64.b64encode(buffered.getvalue()).decode("utf-8")
        return f"data:image/jpeg;base64,{img_str}"
    except Exception as e:
        print(f"Error Gen Image: {e}")
        return None

def fetch_image_from_stream(index):
    if dataset_stream is None: return None
    try:
        idx = int(index)
        return dataset_stream[idx]['image']
    except Exception: return None

# --- FUNCIONES DE CACHE DEL JUEGO ---
def populate_game_cache(count=15):
    global GAME_CACHE
    if dataset_stream is None: return
    new_questions = []
    try:
        total_items = len(dataset_stream)
        for _ in range(count):
            mode = random.choice(["text_to_image", "image_to_text"])
            indices = random.sample(range(total_items), 4)
            target_idx = indices[0]
            random.shuffle(indices)
            target_data = dataset_stream[target_idx]
            target_caption = target_data['caption']
            
            options = []
            for idx in indices:
                item = dataset_stream[idx]
                options.append({
                    "id": idx,
                    "content": f"/image/{idx}" if mode == "text_to_image" else item['caption']
                })
            question = {
                "mode": mode,
                "prompt": target_caption if mode == "text_to_image" else f"/image/{target_idx}",
                "correct_id": target_idx,
                "options": options
            }
            new_questions.append(question)
        GAME_CACHE.extend(new_questions)
        print(f"🎮 Game Cache Refilled. Total items: {len(GAME_CACHE)}")
    except Exception as e:
        print(f"❌ Error refilling game cache: {e}")

# --- ENDPOINTS ---
@app.get("/")
def root(): return {"status": "online"}

@app.get("/image/{index}")
def get_image(index: str):
    if index in ["None", "", "undefined"]: return Response(content=create_placeholder_image("Invalid"), media_type="image/jpeg")
    if dataset_stream is None: return Response(content=create_placeholder_image("Loading..."), media_type="image/jpeg")
    try:
        idx_int = int(float(index))
        if idx_int < 0 or idx_int >= len(dataset_stream): return Response(content=create_placeholder_image("Out of Bounds"), media_type="image/jpeg")
        img = fetch_image_from_stream(idx_int)
        if img:
            if img.mode != 'RGB': img = img.convert('RGB')
            b = BytesIO()
            img.save(b, format='JPEG')
            return Response(content=b.getvalue(), media_type="image/jpeg")
    except Exception: pass
    return Response(content=create_placeholder_image("Error"), media_type="image/jpeg")

# --- MODELOS PYDANTIC ---
class GenerationRequest(BaseModel):
    original_text: str
    sub_concept: Optional[str] = None
    add_concept: Optional[str] = None
    top_k: int = 3
    gen_text: bool = True
    gen_image: bool = True
    guidance_scale: float = 1.5   
    num_inference_steps: int = 5
    language: Optional[str] = "en"
    alpha: Optional[float] = 1.0

class ChatRequest(BaseModel):
    message: str
    history: Optional[str] = ""
    context: Optional[Dict[str, Any]] = None

class RateRequest(BaseModel):
    query: str
    image_index: int
    caption: Optional[str] = ""

# --- ENDPOINT CHATBOT (Async es correcto aquí porque llama a Gemini IO) ---
@app.post("/chat_medical")
async def chat_medical(req: ChatRequest):
    if not gemini_available:
        raise HTTPException(status_code=503, detail="Gemini AI not configured")
    try:
        top_image_data = None
        context_str = ""
        if req.context:
            context_str = f"""\n--- CURRENT SYSTEM CONTEXT ---\nUser is viewing search results for: "{req.context.get('query', 'Unknown')}"\nRETRIEVED EVIDENCE:\n"""
            for i, match in enumerate(req.context.get('matches', [])[:3]):
                context_str += f"\n{i+1}. Findings: {match.get('caption', 'No details')} (Relevance: {match.get('score', 0):.2f})"
            if req.context.get('synthetic_prompt'):
                context_str += f"\n\nGENERATED SYNTHESIS:\n{req.context.get('synthetic_prompt')}"
            context_str += "\n--- END CONTEXT ---\n"
            
            top_img_url = req.context.get('top_match_image', None)
            if top_img_url and "image/" in str(top_img_url):
                try:
                    img_id = int(str(top_img_url).split("/")[-1])
                    top_image_data = fetch_image_from_stream(img_id)
                    if top_image_data: print(f"📎 Attached context image ID: {img_id}")
                except Exception as e: print(f"⚠️ Could not attach context image: {e}")

        MEDICAL_SYSTEM_PROMPT = f"""
        ROLE: Elite Senior MD & Radiologist. 
        KNOWLEDGE: Global Medical Atlases, Clinical Guidelines.
        TASK: Answer medical queries.
        CONTEXT AWARENESS: { "User has provided specific search results (and potentially an image) to discuss." if req.context else "General inquiry." }
        STRICT RULES:
        1. OUTPUT: English only. Professional, clinical tone.
        2. ACCURACY: Reference standard medical consensus.
        3. BREVITY: Concise and direct.
        4. IMAGE ANALYSIS: If an image is provided, prioritizing analyzing it relative to the query.
        """
        model_llm = genai.GenerativeModel('gemini-2.5-flash', system_instruction=MEDICAL_SYSTEM_PROMPT)
        final_text_prompt = f"{context_str}\nPrevious conversation:\n{req.history}\n\nCurrent User Question: {req.message}"
        inputs = [final_text_prompt]
        if top_image_data: inputs.append(top_image_data)
        response = model_llm.generate_content(inputs)
        return {"response": response.text, "status": "success"}
    except Exception as e:
        print(f"❌ Error Chat: {e}")
        return {"response": "I encountered an error processing your request.", "status": "error"}

@app.post("/rate_match")
async def rate_match(req: RateRequest):
    if not gemini_available: return {"score": 0, "reason": "AI Service Unavailable"}
    try:
        image = fetch_image_from_stream(req.image_index)
        model_vision = genai.GenerativeModel('gemini-2.5-flash')
        prompt = f"""You are a strict medical auditor.\nQuery: "{req.query}"\nRetrieved Image Caption: "{req.caption}"\nTask: Rate the relevance of this retrieval to the query from 0 to 100.\nOutput format JSON: {{ "score": int, "reason": "brief explanation" }}"""
        inputs = [prompt]
        if image: inputs.append(image)
        response = model_vision.generate_content(inputs)
        text = response.text.strip().replace("```json", "").replace("```", "")
        try: return json.loads(text)
        except: return {"score": 50, "reason": response.text[:100]}
    except Exception as e:
        print(f"Rating Error: {e}")
        return {"score": 0, "reason": "Error processing rating"}

@app.get("/game/quiz")
def get_game_quiz(background_tasks: BackgroundTasks):
    global GAME_CACHE
    if dataset_stream is None: raise HTTPException(status_code=503, detail="Dataset not loaded")
    try:
        if not GAME_CACHE: populate_game_cache(20)
        if GAME_CACHE:
            question = GAME_CACHE.pop(0)
            if len(GAME_CACHE) < 5: background_tasks.add_task(populate_game_cache, 15)
            return question
        else:
            populate_game_cache(1)
            return GAME_CACHE.pop(0)
    except Exception as e:
        print(f"Game Error: {e}")
        raise HTTPException(status_code=500, detail="Game generation failed")

# --- ENDPOINTS SIN ASYNC (CRÍTICO PARA EVITAR BLOQUEO DE MAIN THREAD) ---
@app.post("/generate_comparison")
def generate_comparison(req: GenerationRequest):
    # NOTA: Al quitar 'async', FastAPI ejecuta esto en un threadpool,
    # permitiendo que el Event Loop principal siga sirviendo imágenes (/image/{id})
    if not model: raise HTTPException(status_code=503, detail="Loading...")
    try:
        final_query = req.original_text
        final_add = req.add_concept
        final_sub = req.sub_concept
        
        if req.language and req.language.lower() not in ['en', 'english']:
            final_query = translate_to_english(req.original_text, req.language)
            if req.add_concept: final_add = translate_to_english(req.add_concept, req.language)
            if req.sub_concept: final_sub = translate_to_english(req.sub_concept, req.language)
            
        print(f"⚡ Procesando: '{final_query}' (Lang: {req.language}, Alpha: {req.alpha})")
        
        response_data = {
            "original_text": final_query,
            "modified_text": final_query,
            "original": {},
            "modified": None,
            "input_lang_detected": req.language
        }

        # 2. PROCESAR ORIGINAL
        # Se pasa alpha desde el request
        vec_orig = calculate_vector(final_query)
        match_orig, caps_orig = get_retrieval_and_context(vec_orig, req.top_k, alpha=req.alpha if req.alpha is not None else 1.0)
        
        prompt_orig = ""
        if req.gen_text:
            prompt_orig = generate_llm_prompt(caps_orig, final_query)
        else:
            prompt_orig = "LLM generation skipped."
            
        img_orig_b64 = ""
        if req.gen_image:
            p_to_use = prompt_orig if req.gen_text else final_query
            img_orig_b64 = generate_synthetic_image(p_to_use, steps=req.num_inference_steps, guidance=req.guidance_scale)

        response_data["original"] = {
            "real_match": match_orig, 
            "synthetic": {
                "image_base64": img_orig_b64,
                "generated_prompt": prompt_orig
            }
        }

        # 3. PROCESAR MODIFICADO (Dual Search)
        has_dual = (final_add and final_add.strip()) and (final_sub and final_sub.strip())
        if has_dual:
            vec_mod = calculate_vector(final_query, final_add, final_sub)
            match_mod, caps_mod = get_retrieval_and_context(vec_mod, req.top_k, alpha=req.alpha if req.alpha is not None else 1.0)
            
            prompt_mod = ""
            if req.gen_text:
                prompt_mod = generate_llm_prompt(caps_mod, f"{final_query} plus {final_add} minus {final_sub}")
            else:
                prompt_mod = "LLM generation skipped."

            img_mod_b64 = ""
            if req.gen_image:
                p_to_use_mod = prompt_mod if req.gen_text else f"{final_query} {final_add}"
                img_mod_b64 = generate_synthetic_image(p_to_use_mod, steps=req.num_inference_steps, guidance=req.guidance_scale)
                
            response_data["modified"] = {
                "real_match": match_mod,
                "synthetic": {
                    "image_base64": img_mod_b64,
                    "generated_prompt": prompt_mod
                }
            }
            response_data["modified_text"] = f"{final_query} + {final_add} - {final_sub}"

        return response_data

    except Exception as e:
        print(f"🔥 Error: {e}")
        raise HTTPException(status_code=500, detail=str(e))

@app.post("/search")
def search(req: GenerationRequest):
    return generate_comparison(req)