345 lines
15 KiB
Python
345 lines
15 KiB
Python
"""Analyzer inteligente — Gemini 3.1 Flash Lite como orquestador de queries.
|
|
Clasifica la pregunta, detecta entidades, y decide qué capas activar.
|
|
No busca en Neo4j — solo analiza la pregunta y devuelve un plan de ejecución.
|
|
"""
|
|
|
|
import json
|
|
import re
|
|
import os
|
|
from google import genai
|
|
|
|
# Reuse existing preprocessing
|
|
try:
|
|
from services.query import preprocess
|
|
except ImportError:
|
|
preprocess = None
|
|
|
|
_client = None
|
|
|
|
def _get_client():
|
|
global _client
|
|
if _client is None:
|
|
_client = genai.Client(api_key=os.getenv("GCP_API_KEY", ""))
|
|
return _client
|
|
|
|
|
|
ANALYZER_PROMPT = """Sos un analizador de queries medicas. Dada una pregunta, devolvé SOLO un JSON válido (sin markdown, sin texto extra).
|
|
|
|
Formato EXACTO (respetá los valores de tipo y buscar_en tal cual):
|
|
{{
|
|
"entidades": [
|
|
{{"texto": "...", "tipo": "VALOR_TIPO", "buscar_en": "VALOR_LABEL"}}
|
|
],
|
|
"intencion": "VALOR_INTENCION",
|
|
"capas": ["CAPAS"],
|
|
"sub_queries": ["reformulacion 1", "reformulacion 2", "reformulacion 3"],
|
|
"ambigua": false,
|
|
"clarificacion": null
|
|
}}
|
|
|
|
Si la pregunta es AMBIGUA o DEMASIADO AMPLIA (ej: "resumen de todo el curso", "todo sobre farmacología", "preparame para el examen"), setear:
|
|
"ambigua": true,
|
|
"clarificacion": {{"pregunta": "pregunta para el usuario", "opciones": ["opcion 1", "opcion 2", "opcion 3"]}}
|
|
Igualmente, SIEMPRE generar entidades, sub_queries y capas con la mejor interpretación posible. La clarificación es un COMPLEMENTO, no un reemplazo de la búsqueda.
|
|
|
|
Valores EXACTOS permitidos para tipo y buscar_en:
|
|
- tipo=patologia, buscar_en=Patologia (enfermedades: otitis, neumonía, diabetes, HTA, psoriasis)
|
|
- tipo=farmaco, buscar_en=Farmaco (fármacos específicos: furosemida, amoxicilina, ibuprofeno, metformina)
|
|
- tipo=clase_farmacologica, buscar_en=CategoriaATC (clases de fármacos: diuréticos, betalactámicos, AINEs, corticoides, opioides)
|
|
- tipo=sistema_corporal, buscar_en=CategoriaSNOMED (sistemas: cardiovascular, respiratorio, nervioso, digestivo)
|
|
- tipo=anatomia, buscar_en=EstructuraAnatomica (estructuras: oído medio, retina, riñón, hígado, membrana timpánica)
|
|
- tipo=procedimiento, buscar_en=Procedimiento (técnicas: otoscopía, ECG, Rx tórax, toma de TA)
|
|
- tipo=signo, buscar_en=Signo (signos clínicos: fiebre, edema, soplo, cianosis)
|
|
- tipo=sintoma, buscar_en=Sintoma (síntomas: otalgia, cefalea, disnea, dolor torácico)
|
|
- tipo=metodo_dx, buscar_en=MetodoDx (métodos diagnósticos: hemograma, ecografía, audiometría)
|
|
- tipo=actividad_academica, buscar_en=Actividad (TPs, seminarios, talleres, guías, contenidos UP)
|
|
|
|
Valores para intencion: tratamiento|diagnostico|anatomia|fisiologia|clinica|etiologia|clasificacion|procedimiento|actividad|bibliografia|general
|
|
|
|
Valores para capas: ONTOLOGY, GRAPH, BIBLIOGRAPHY, ACTIVITIES, DAGS
|
|
|
|
Reglas para decidir capas:
|
|
- BIBLIOGRAPHY y ACTIVITIES siempre incluir
|
|
- Si hay patologia, farmaco, clase_farmacologica o sistema_corporal → agregar ONTOLOGY y GRAPH
|
|
- Si hay signo, sintoma, procedimiento, metodo_dx o anatomia → agregar ONTOLOGY y GRAPH (procedimientos, signos y anatomía también están clasificados en SNOMED)
|
|
- Si pregunta "qué hago ante", "cómo manejar", "paciente con" → agregar DAGS
|
|
- Si pregunta "fisiopatología", "mecanismo", "por qué se produce" → agregar DAGS
|
|
- Si pregunta "clasificación", "qué tipo", "a qué clase pertenece" → agregar ONTOLOGY
|
|
|
|
sub_queries: genera 3 reformulaciones especializadas para búsqueda en textos médicos, cubriendo diferentes aspectos del tema (definición, clínica, tratamiento, etc.)
|
|
|
|
EJEMPLOS:
|
|
|
|
Q: "qué diuréticos se usan en insuficiencia cardíaca"
|
|
{{"entidades":[{{"texto":"diuréticos","tipo":"clase_farmacologica","buscar_en":"CategoriaATC"}},{{"texto":"insuficiencia cardíaca","tipo":"patologia","buscar_en":"Patologia"}}],"intencion":"tratamiento","capas":["ONTOLOGY","GRAPH","BIBLIOGRAPHY","ACTIVITIES"],"sub_queries":["diuréticos tratamiento insuficiencia cardíaca","furosemida hidroclorotiazida espironolactona IC dosis","insuficiencia cardíaca manejo farmacológico guías"]}}
|
|
|
|
Q: "preparame para el TP de otoscopía"
|
|
{{"entidades":[{{"texto":"TP otoscopía","tipo":"actividad_academica","buscar_en":"Actividad"}},{{"texto":"otoscopía","tipo":"procedimiento","buscar_en":"Procedimiento"}}],"intencion":"procedimiento","capas":["ACTIVITIES","GRAPH","ONTOLOGY","BIBLIOGRAPHY"],"sub_queries":["otoscopía técnica pasos procedimiento","guía TP otoscopía lista cotejo","membrana timpánica hallazgos normales patológicos otoscopía"]}}
|
|
|
|
Q: "paciente con otalgia y fiebre qué hago"
|
|
{{"entidades":[{{"texto":"otalgia","tipo":"sintoma","buscar_en":"Sintoma"}},{{"texto":"fiebre","tipo":"signo","buscar_en":"Signo"}}],"intencion":"clinica","capas":["DAGS","GRAPH","ONTOLOGY","BIBLIOGRAPHY","ACTIVITIES"],"sub_queries":["otalgia fiebre diagnóstico diferencial","otitis media aguda diagnóstico tratamiento","manejo clínico otalgia aguda evaluación otoscópica"]}}
|
|
|
|
Q: "contenidos unidad 1"
|
|
{{"entidades":[{{"texto":"unidad 1","tipo":"actividad_academica","buscar_en":"Actividad"}},{{"texto":"contenidos","tipo":"actividad_academica","buscar_en":"Actividad"}}],"intencion":"actividad","capas":["ACTIVITIES","BIBLIOGRAPHY"],"sub_queries":["contenidos unidad 1 temas unidad","guía contenidos first unit","unit 1 core topics fundamentals introduction"],"ambigua":false,"clarificacion":null}}
|
|
|
|
Q: "resumen completo del curso"
|
|
{{"entidades":[{{"texto":"course","tipo":"actividad_academica","buscar_en":"Actividad"}}],"intencion":"actividad","capas":["ACTIVITIES","BIBLIOGRAPHY"],"sub_queries":["contenidos curso temas unidades","guía contenidos del curso"],"ambigua":true,"clarificacion":{{"pregunta":"The course has multiple units. ¿Sobre cuál querés el resumen?","opciones":["Unit 1: Introduction and fundamentals","Unit 2: Core concepts","Unit 3: Advanced topics","Unit 4: Applied knowledge","All units"]}}}}
|
|
|
|
Pregunta: {pregunta}"""
|
|
|
|
|
|
# Normalización post-Gemini: mapea tipos genéricos a los del sistema
|
|
_TYPE_NORMALIZE = {
|
|
# Fármacos
|
|
"medicamento": ("farmaco", "Farmaco"),
|
|
"medicina": ("farmaco", "Farmaco"),
|
|
"droga": ("farmaco", "Farmaco"),
|
|
"drug": ("farmaco", "Farmaco"),
|
|
"farmaco": ("farmaco", "Farmaco"),
|
|
"fármaco": ("farmaco", "Farmaco"),
|
|
# Clases farmacológicas
|
|
"clase de farmaco": ("clase_farmacologica", "CategoriaATC"),
|
|
"clase farmacologica": ("clase_farmacologica", "CategoriaATC"),
|
|
"grupo farmacologico": ("clase_farmacologica", "CategoriaATC"),
|
|
"clase_farmacologica": ("clase_farmacologica", "CategoriaATC"),
|
|
# Patologías
|
|
"enfermedad": ("patologia", "Patologia"),
|
|
"patologia": ("patologia", "Patologia"),
|
|
"patología": ("patologia", "Patologia"),
|
|
"condicion": ("patologia", "Patologia"),
|
|
"trastorno": ("patologia", "Patologia"),
|
|
"sindrome": ("patologia", "Patologia"),
|
|
"disease": ("patologia", "Patologia"),
|
|
# Anatomía
|
|
"organo": ("anatomia", "EstructuraAnatomica"),
|
|
"órgano": ("anatomia", "EstructuraAnatomica"),
|
|
"estructura": ("anatomia", "EstructuraAnatomica"),
|
|
"anatomia": ("anatomia", "EstructuraAnatomica"),
|
|
"anatomía": ("anatomia", "EstructuraAnatomica"),
|
|
"estructura_anatomica": ("anatomia", "EstructuraAnatomica"),
|
|
# Sistemas
|
|
"sistema": ("sistema_corporal", "CategoriaSNOMED"),
|
|
"sistema_corporal": ("sistema_corporal", "CategoriaSNOMED"),
|
|
"aparato": ("sistema_corporal", "CategoriaSNOMED"),
|
|
# Procedimientos
|
|
"procedimiento": ("procedimiento", "Procedimiento"),
|
|
"tecnica": ("procedimiento", "Procedimiento"),
|
|
"estudio": ("procedimiento", "Procedimiento"),
|
|
# Signos y síntomas
|
|
"signo": ("signo", "Signo"),
|
|
"sintoma": ("sintoma", "Sintoma"),
|
|
"síntoma": ("sintoma", "Sintoma"),
|
|
"hallazgo": ("signo", "Signo"),
|
|
# Diagnóstico
|
|
"metodo_dx": ("metodo_dx", "MetodoDx"),
|
|
"metodo diagnostico": ("metodo_dx", "MetodoDx"),
|
|
"prueba": ("metodo_dx", "MetodoDx"),
|
|
"test": ("metodo_dx", "MetodoDx"),
|
|
# Actividades
|
|
"actividad_academica": ("actividad_academica", "Actividad"),
|
|
"actividad": ("actividad_academica", "Actividad"),
|
|
"tp": ("actividad_academica", "Actividad"),
|
|
"documento": ("actividad_academica", "Actividad"),
|
|
# Tipos genéricos que Gemini inventa
|
|
"agente": ("patologia", "Agente"),
|
|
"agente_infeccioso": ("patologia", "Agente"),
|
|
"microorganismo": ("patologia", "Agente"),
|
|
"virus": ("patologia", "Agente"),
|
|
"bacteria": ("patologia", "Agente"),
|
|
"parasito": ("patologia", "Agente"),
|
|
"parametro": ("metodo_dx", "Parametro"),
|
|
"valor": ("metodo_dx", "Parametro"),
|
|
"laboratorio": ("metodo_dx", "MetodoDx"),
|
|
"hallazgo": ("signo", "Hallazgo"),
|
|
"grupo_farmacologico": ("clase_farmacologica", "GrupoFarmacologico"),
|
|
"clase": ("clase_farmacologica", "CategoriaATC"),
|
|
"categoria": ("clase_farmacologica", "CategoriaATC"),
|
|
"tratamiento": ("farmaco", "Farmaco"),
|
|
"cirugia": ("procedimiento", "Procedimiento"),
|
|
"cirugía": ("procedimiento", "Procedimiento"),
|
|
"imagen": ("metodo_dx", "MetodoDx"),
|
|
"radiografia": ("metodo_dx", "MetodoDx"),
|
|
"ecografia": ("metodo_dx", "MetodoDx"),
|
|
"analisis": ("metodo_dx", "MetodoDx"),
|
|
}
|
|
|
|
_BUSCAR_EN_NORMALIZE = {
|
|
"medicamentos": "Farmaco",
|
|
"enfermedades": "Patologia",
|
|
"farmacos": "Farmaco",
|
|
"organos": "EstructuraAnatomica",
|
|
"estructuras": "EstructuraAnatomica",
|
|
"procedimientos": "Procedimiento",
|
|
"signos": "Signo",
|
|
"sintomas": "Sintoma",
|
|
"sistemas": "CategoriaSNOMED",
|
|
"actividades": "Actividad",
|
|
"patologias": "Patologia",
|
|
}
|
|
|
|
|
|
def _normalize_entities(entities: list) -> list:
|
|
"""Normaliza tipos genéricos de Gemini a los labels exactos del sistema."""
|
|
for ent in entities:
|
|
tipo = ent.get("tipo", "").lower().strip()
|
|
buscar = ent.get("buscar_en", "").strip()
|
|
|
|
# Normalizar tipo
|
|
if tipo in _TYPE_NORMALIZE:
|
|
ent["tipo"], ent["buscar_en"] = _TYPE_NORMALIZE[tipo]
|
|
|
|
# Normalizar buscar_en si Gemini devolvió algo genérico
|
|
buscar_lower = buscar.lower()
|
|
if buscar_lower in _BUSCAR_EN_NORMALIZE:
|
|
ent["buscar_en"] = _BUSCAR_EN_NORMALIZE[buscar_lower]
|
|
|
|
return entities
|
|
|
|
|
|
def analyze_query(pregunta: str) -> dict:
|
|
"""Analiza una pregunta médica y devuelve plan de ejecución."""
|
|
|
|
# 1. Preprocessing existente (sinónimos, intención básica)
|
|
pre = None
|
|
if preprocess:
|
|
try:
|
|
pre = preprocess(pregunta)
|
|
except Exception:
|
|
pass
|
|
|
|
expandida = pre.get("expandida", pregunta) if pre else pregunta
|
|
|
|
# 2. Gemini clasifica via Google GenAI
|
|
client = _get_client()
|
|
try:
|
|
resp = client.models.generate_content(
|
|
model="gemini-2.5-flash",
|
|
contents=ANALYZER_PROMPT.format(pregunta=pregunta),
|
|
config={
|
|
"temperature": 0.1,
|
|
"max_output_tokens": 4096,
|
|
"response_mime_type": "application/json",
|
|
},
|
|
)
|
|
raw_text = resp.text
|
|
|
|
# Log raw for debugging
|
|
import logging
|
|
logging.info(f"Gemini raw ({len(raw_text)} chars): {raw_text[:200]}")
|
|
|
|
# Limpiar markdown
|
|
text = raw_text.strip()
|
|
if "```" in text:
|
|
text = re.sub(r'```\w*\n?', '', text)
|
|
text = text.strip()
|
|
|
|
# Extraer JSON: buscar primer { y último }
|
|
first_brace = text.find('{')
|
|
last_brace = text.rfind('}')
|
|
if first_brace >= 0 and last_brace > first_brace:
|
|
text = text[first_brace:last_brace+1]
|
|
|
|
result = json.loads(text)
|
|
|
|
# Normalizar keys (Gemini puede usar nombres diferentes)
|
|
if "entidades" not in result:
|
|
# Buscar keys alternativas
|
|
for alt_key in ["entities", "entidad", "detected_entities"]:
|
|
if alt_key in result:
|
|
result["entidades"] = result.pop(alt_key)
|
|
break
|
|
else:
|
|
result["entidades"] = []
|
|
|
|
if "capas" not in result:
|
|
for alt_key in ["layers", "capa", "activated_layers"]:
|
|
if alt_key in result:
|
|
result["capas"] = result.pop(alt_key)
|
|
break
|
|
else:
|
|
result["capas"] = ["BIBLIOGRAPHY"]
|
|
|
|
if "intencion" not in result:
|
|
for alt_key in ["intent", "intention"]:
|
|
if alt_key in result:
|
|
result["intencion"] = result.pop(alt_key)
|
|
break
|
|
else:
|
|
result["intencion"] = "general"
|
|
|
|
if "sub_queries" not in result:
|
|
result["sub_queries"] = [pregunta]
|
|
|
|
# Normalizar tipos genéricos de Gemini a labels del sistema
|
|
if result.get("entidades"):
|
|
result["entidades"] = _normalize_entities(result["entidades"])
|
|
|
|
except Exception as e:
|
|
# Log the error AND the raw response for debugging
|
|
import logging
|
|
try:
|
|
raw = response.text[:300] if 'response' in dir() and hasattr(response, 'text') else 'no response'
|
|
except:
|
|
raw = 'no response'
|
|
logging.error(f"Gemini analyzer failed: {type(e).__name__}: {str(e)[:200]} | Raw: {raw}")
|
|
# Fallback determinista
|
|
result = _fallback_analysis(pregunta, pre)
|
|
|
|
# 3. Combinar con preprocessing existente
|
|
analysis = {
|
|
"original": pregunta,
|
|
"intencion": result.get("intencion", "general"),
|
|
"expandida": expandida,
|
|
"sub_queries": result.get("sub_queries", [pregunta]),
|
|
"entidades_detectadas": result.get("entidades", []),
|
|
"capas": result.get("capas", ["BIBLIOGRAPHY"]),
|
|
}
|
|
|
|
# Propagar clarificación si Gemini detectó ambigüedad
|
|
if result.get("ambigua"):
|
|
analysis["ambigua"] = True
|
|
analysis["clarificacion"] = result.get("clarificacion")
|
|
|
|
# BIBLIOGRAPHY y ACTIVITIES siempre activas (somos un sistema para estudiantes)
|
|
for capa_base in ["BIBLIOGRAPHY", "ACTIVITIES"]:
|
|
if capa_base not in analysis["capas"]:
|
|
analysis["capas"].append(capa_base)
|
|
|
|
return analysis
|
|
|
|
|
|
def _fallback_analysis(pregunta: str, pre: dict = None) -> dict:
|
|
"""Análisis determinista como fallback si Gemini falla."""
|
|
pregunta_lower = pregunta.lower()
|
|
|
|
capas = ["BIBLIOGRAPHY", "ACTIVITIES"]
|
|
entidades = []
|
|
intencion = pre.get("intencion", "general") if pre else "general"
|
|
|
|
# Detectar actividades
|
|
activity_keywords = ["tp", "seminario", "taller", "acreditación", "acreditacion", "guía", "guia", "lista de cotejo"]
|
|
if any(kw in pregunta_lower for kw in activity_keywords):
|
|
capas.append("ACTIVITIES")
|
|
entidades.append({"texto": pregunta, "tipo": "actividad_academica", "buscar_en": "Actividad"})
|
|
|
|
# Detectar intención clínica
|
|
clinical_keywords = ["qué hago", "que hago", "cómo manejar", "como manejar", "ante un paciente", "manejo de"]
|
|
if any(kw in pregunta_lower for kw in clinical_keywords):
|
|
capas.append("DAGS")
|
|
|
|
# Detectar intención ontológica
|
|
onto_keywords = ["clasificación", "clasificacion", "qué tipo", "que tipo", "a qué clase", "pertenece"]
|
|
if any(kw in pregunta_lower for kw in onto_keywords):
|
|
capas.append("ONTOLOGY")
|
|
|
|
# Default: siempre grafo
|
|
if "GRAPH" not in capas:
|
|
capas.append("GRAPH")
|
|
|
|
return {
|
|
"entidades": entidades,
|
|
"intencion": intencion,
|
|
"capas": list(set(capas)),
|
|
"sub_queries": [pregunta],
|
|
}
|