esteban7856 commited on
Commit
bb2215f
·
verified ·
1 Parent(s): 402edf5

correcciones

Browse files
app/__pycache__/main.cpython-311.pyc CHANGED
Binary files a/app/__pycache__/main.cpython-311.pyc and b/app/__pycache__/main.cpython-311.pyc differ
 
app/main.py CHANGED
@@ -1,138 +1,140 @@
1
- # app/main.py
2
- from fastapi import FastAPI
3
- from pydantic import BaseModel
4
- import os, json, re, torch
5
- from huggingface_hub import hf_hub_download
6
- from transformers import AutoTokenizer
7
- from model.model import BETO_LSTM, TOKENIZER_ID
8
- from app.utils.synonym_dict import synonym_dict, normalize_text
9
- from fastapi.middleware.cors import CORSMiddleware
10
- from app.services.message_service import generate_diagnosis_message
11
-
12
- # Configuración CORS
13
- app = FastAPI(title="Prediagnóstico Médico")
14
- app.add_middleware(
15
- CORSMiddleware,
16
- allow_origins=["*"],
17
- allow_credentials=True,
18
- allow_methods=["*"],
19
- allow_headers=["*"],
20
- )
21
-
22
- # Configuración del modelo en Hugging Face
23
- REPO_ID = "esteban7856/respiratorio-beto"
24
- REVISION = "main"
25
- MODEL_FILE = "best_model.pt"
26
- LMAP_FILE = "label_mapping.json"
27
- HF_TOKEN = os.getenv("HF_TOKEN")
28
-
29
- # Hiperparámetros
30
- MAX_LEN = 64
31
- THRESHOLD = 0.55
32
-
33
- # Descarga de artefactos
34
- model_path = hf_hub_download(REPO_ID, MODEL_FILE, revision=REVISION, token=HF_TOKEN)
35
- lmap_path = hf_hub_download(REPO_ID, LMAP_FILE, revision=REVISION, token=HF_TOKEN)
36
-
37
- with open(lmap_path, "r", encoding="utf-8") as f:
38
- id2label = {int(k): v for k, v in json.load(f).items()}
39
- NUM_CLASSES = len(id2label)
40
-
41
- # Carga del modelo
42
- tokenizer = AutoTokenizer.from_pretrained(TOKENIZER_ID)
43
- device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
44
-
45
- model = BETO_LSTM(hidden_dim=256, bidirectional=True, num_classes=NUM_CLASSES, freeze_bert=True)
46
- state = torch.load(model_path, map_location="cpu")
47
- model.load_state_dict(state)
48
- model.to(device).eval()
49
-
50
- class InputText(BaseModel):
51
- text: str
52
-
53
- # Limpieza de saludos
54
- GREET_PATTERNS = [
55
- r"^\s*hola[!,.\s]*", r"^\s*buenos dias[!,.\s]*",
56
- r"^\s*buenas tardes[!,.\s]*", r"^\s*buenas noches[!,.\s]*",
57
- r"^\s*buen dia[!,.\s]*"
58
- ]
59
-
60
- def strip_greetings(text: str) -> str:
61
- """Elimina saludos iniciales del texto."""
62
- for pattern in GREET_PATTERNS:
63
- text = re.sub(pattern, "", text, flags=re.IGNORECASE)
64
- return text.strip()
65
-
66
- def contains_symptom(text: str) -> bool:
67
- """Verifica si el texto contiene síntomas respiratorios."""
68
- symptoms = {
69
- "fiebre", "tos", "dificultad para respirar", "dolor de garganta",
70
- "congestión nasal", "estornudos", "dolor de cabeza", "dolor muscular",
71
- "escalofríos", "fatiga", "sibilancias", "dolor en el pecho",
72
- "secreción nasal", "malestar general", "dolor de cuerpo"
73
- }
74
- text_lower = text.lower()
75
- return any(symptom in text_lower for symptom in symptoms)
76
-
77
- @app.post("/predict")
78
- def predict(data: InputText):
79
- texto_original = data.text
80
-
81
- # Normalización del texto
82
- texto_norm = normalize_text(texto_original.lower(), synonym_dict)
83
- texto_proc = strip_greetings(texto_norm)
84
-
85
- # Tokenización
86
- inputs = tokenizer(
87
- texto_proc,
88
- return_tensors="pt",
89
- truncation=True,
90
- padding=True,
91
- max_length=MAX_LEN
92
- )
93
- inputs = {k: v.to(device) for k, v in inputs.items()}
94
-
95
- # Inferencia
96
- with torch.no_grad():
97
- logits = model(inputs["input_ids"], inputs["attention_mask"])
98
- probs = torch.softmax(logits, dim=1)[0].cpu()
99
-
100
- pmax, pred = torch.max(probs, dim=0)
101
- final_pred = int(pred.item())
102
- final_conf = float(pmax.item())
103
-
104
- # Lógica de predicción
105
- if contains_symptom(texto_proc):
106
- if final_pred == 3 or final_conf < THRESHOLD:
107
- probs012 = probs[:3]
108
- best012 = int(torch.argmax(probs012).item())
109
- final_pred = best012
110
- final_conf = float(probs012[best012].item())
111
- else:
112
- if final_pred != 3 and final_conf < THRESHOLD:
113
- final_pred = 3
114
-
115
- # Obtener el diagnóstico
116
- diagnostico = id2label[final_pred]
117
-
118
- # Generar mensaje usando el servicio
119
- mensaje_info = generate_diagnosis_message(
120
- original_text=texto_original,
121
- diagnosis=diagnostico,
122
- confidence=final_conf
123
- )
124
-
125
- # Retornar respuesta
126
- return {
127
- "texto_original": texto_original,
128
- "texto_normalizado": texto_proc,
129
- "diagnóstico": diagnostico,
130
- "probabilidad": mensaje_info["probabilidad"],
131
- "nivel_confianza": mensaje_info["nivel_confianza"],
132
- "mensaje": mensaje_info["mensaje"],
133
- "sugerencia": mensaje_info["sugerencia"]
134
- }
135
-
136
- if __name__ == "__main__":
137
- import uvicorn
 
 
138
  uvicorn.run(app, host="0.0.0.0", port=8000)
 
1
+ # app/main.py
2
+ from fastapi import FastAPI
3
+ from pydantic import BaseModel
4
+ import os, json, re, torch
5
+ from huggingface_hub import hf_hub_download
6
+ from transformers import AutoTokenizer
7
+ from model.model import BETO_LSTM, TOKENIZER_ID
8
+ from app.utils.synonym_dict import synonym_dict, normalize_text
9
+ from fastapi.middleware.cors import CORSMiddleware
10
+ from app.services.message_service import generate_diagnosis_message
11
+
12
+ # Configuración CORS
13
+ app = FastAPI(title="Prediagnóstico Médico")
14
+ app.add_middleware(
15
+ CORSMiddleware,
16
+ allow_origins=["*"],
17
+ allow_credentials=True,
18
+ allow_methods=["*"],
19
+ allow_headers=["*"],
20
+ )
21
+
22
+ # Configuración del modelo en Hugging Face
23
+ REPO_ID = "esteban7856/respiratorio-beto"
24
+ REVISION = "main"
25
+ MODEL_FILE = "best_model.pt"
26
+ LMAP_FILE = "label_mapping.json"
27
+ HF_TOKEN = os.getenv("HF_TOKEN")
28
+
29
+ # Hiperparámetros
30
+ MAX_LEN = 64
31
+ THRESHOLD = 0.55
32
+
33
+ # Descarga de artefactos
34
+ model_path = hf_hub_download(REPO_ID, MODEL_FILE, revision=REVISION, token=HF_TOKEN)
35
+ lmap_path = hf_hub_download(REPO_ID, LMAP_FILE, revision=REVISION, token=HF_TOKEN)
36
+
37
+ with open(lmap_path, "r", encoding="utf-8") as f:
38
+ id2label = {int(k): v for k, v in json.load(f).items()}
39
+ NUM_CLASSES = len(id2label)
40
+
41
+ # Carga del modelo
42
+ tokenizer = AutoTokenizer.from_pretrained(TOKENIZER_ID)
43
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
44
+
45
+ model = BETO_LSTM(hidden_dim=256, bidirectional=True, num_classes=NUM_CLASSES, freeze_bert=True)
46
+ state = torch.load(model_path, map_location="cpu")
47
+ model.load_state_dict(state)
48
+ model.to(device).eval()
49
+
50
+ class InputText(BaseModel):
51
+ text: str
52
+
53
+ # Limpieza de saludos
54
+ GREET_PATTERNS = [
55
+ r"^\s*hola[!,.\s]*", r"^\s*buenos dias[!,.\s]*",
56
+ r"^\s*buenas tardes[!,.\s]*", r"^\s*buenas noches[!,.\s]*",
57
+ r"^\s*buen dia[!,.\s]*"
58
+ ]
59
+
60
+ def strip_greetings(text: str) -> str:
61
+ """Elimina saludos iniciales del texto."""
62
+ for pattern in GREET_PATTERNS:
63
+ text = re.sub(pattern, "", text, flags=re.IGNORECASE)
64
+ return text.strip()
65
+
66
+ def contains_symptom(text: str) -> bool:
67
+ """Verifica si el texto contiene síntomas respiratorios."""
68
+ symptoms = {
69
+ "fiebre", "tos", "dificultad para respirar", "dolor de garganta",
70
+ "congestión nasal", "estornudos", "dolor de cabeza", "dolor muscular",
71
+ "escalofríos", "fatiga", "sibilancias", "dolor en el pecho",
72
+ "secreción nasal", "malestar general", "dolor de cuerpo"
73
+ }
74
+ text_lower = text.lower()
75
+ return any(symptom in text_lower for symptom in symptoms)
76
+
77
+ @app.post("/predict")
78
+ def predict(data: InputText):
79
+ texto_original = data.text
80
+
81
+ # Normalización del texto
82
+ texto_norm = normalize_text(texto_original.lower(), synonym_dict)
83
+ texto_proc = strip_greetings(texto_norm)
84
+
85
+ # Tokenización
86
+ inputs = tokenizer(
87
+ texto_proc,
88
+ return_tensors="pt",
89
+ truncation=True,
90
+ padding=True,
91
+ max_length=MAX_LEN
92
+ )
93
+ inputs = {k: v.to(device) for k, v in inputs.items()}
94
+
95
+ # Inferencia
96
+ with torch.no_grad():
97
+ logits = model(inputs["input_ids"], inputs["attention_mask"])
98
+ probs = torch.softmax(logits, dim=1)[0].cpu()
99
+
100
+ pmax, pred = torch.max(probs, dim=0)
101
+ final_pred = int(pred.item())
102
+ final_conf = float(pmax.item())
103
+
104
+ # Lógica de predicción
105
+ if contains_symptom(texto_proc):
106
+ if final_pred == 3 or final_conf < THRESHOLD:
107
+ probs012 = probs[:3]
108
+ best012 = int(torch.argmax(probs012).item())
109
+ final_pred = best012
110
+ final_conf = float(probs012[best012].item())
111
+ else:
112
+ if final_pred != 3 and final_conf < THRESHOLD:
113
+ final_pred = 3
114
+
115
+ # Obtener el diagnóstico
116
+ diagnostico = id2label[final_pred]
117
+
118
+ # Generar mensaje usando el servicio
119
+ mensaje_info = generate_diagnosis_message(
120
+ original_text=texto_original,
121
+ diagnosis=diagnostico,
122
+ confidence=final_conf
123
+ )
124
+
125
+ # Retornar respuesta
126
+ return {
127
+ "texto_original": texto_original,
128
+ "texto_normalizado": texto_norm, # Texto después de normalize_text
129
+ "texto_procesado": texto_proc, # Texto después de strip_greetings
130
+ "diagnostico": diagnostico,
131
+ "confianza": final_conf,
132
+ "mensaje": mensaje_info["mensaje"],
133
+ "sugerencia": mensaje_info["sugerencia"],
134
+ "nivel_confianza": mensaje_info["nivel_confianza"],
135
+ "probabilidad": mensaje_info["probabilidad"]
136
+ }
137
+
138
+ if __name__ == "__main__":
139
+ import uvicorn
140
  uvicorn.run(app, host="0.0.0.0", port=8000)
app/utils/__pycache__/synonym_dict.cpython-311.pyc CHANGED
Binary files a/app/utils/__pycache__/synonym_dict.cpython-311.pyc and b/app/utils/__pycache__/synonym_dict.cpython-311.pyc differ
 
app/utils/synonym_dict.py CHANGED
@@ -1,160 +1,246 @@
1
- # app/utils/synonym_dict.py
2
- import re
3
- import unicodedata
4
- from difflib import get_close_matches
5
-
6
- # === Diccionario original de sinónimos (tal como lo definiste) ===
7
- synonym_dict = {
8
- "rinorrea": ["mocos como agua", "agua en la nariz", "nariz mocosa", "goteo de mocos como agua"],
9
- "fiebre": ["temperatura alta", "calor", "alta temperatura", "calor intenso"],
10
- "tos seca esporadica": ["tos espontanea", "a veces tos"],
11
- "tos con expectoración": ["tos con flema", "tos con moco", "tos con expectoración"],
12
- "alzas térmicas": ["temperaturas altas", "calor intenso"],
13
- "piel pálida": ["piel pálida"],
14
- "piel y mucosas pálidas": ["mucosas pálidas"],
15
- "disnea": ["dificultad para respirar", "respiración rápida", "respiración difícil", "respiración dificultada"],
16
- "somnolienta": ["cansancio", "sueño", "agotado"],
17
- "cefalea": ["dolor de cabeza", "dolor de cabeza intenso", "dolor de cabeza severo", "dolor de cabeza fuerte"],
18
- "tos seca sin secreciones": ["tos sin flema", "tos irritativa"],
19
- "tos seca": ["tos seca sin secreciones"],
20
- "hiporexia": ["rechaza alimentos", "no quiere comer", "no quiere lactar", "no tiene apetito"],
21
- "disfonía": ["dificultad para hablar", "habla con dificultad", "ronco", "voz ronca"],
22
- "malestar general": ["malestar", "no se siente bien", "malestar generalizado"],
23
- "aumento de frecuencia respiratoria": ["frecuencia respiratoria aumentada", "respiración rápida", "respiración difícil"],
24
- "sibilancias": ["silbido al respirar", "sonido al respirar", "respiración con silbido", "resoplido", "silbido"],
25
- "astenica": ["sensación de debilidad", "falta de energía", "cansancio"],
26
- "eructos fétidos": ["eructos de mal olor", "eructos fuertes", "eructos intensos"],
27
- "febril": ["temperatura alta", "calor corporal"],
28
- }
29
-
30
-
31
- # === Normalización básica ===
32
-
33
- def remove_accents(text: str) -> str:
34
- """Elimina tildes/acentos del texto."""
35
- return ''.join(
36
- c for c in unicodedata.normalize('NFD', text)
37
- if unicodedata.category(c) != 'Mn'
38
- )
39
-
40
-
41
- def basic_cleanup(text: str) -> str:
42
- """
43
- Limpieza básica:
44
- - minúsculas
45
- - sin tildes
46
- - quitar signos raros
47
- - colapsar letras repetidas (fieeebre -> fiebre)
48
- """
49
- if not isinstance(text, str):
50
- text = str(text)
51
-
52
- text = text.lower()
53
- text = remove_accents(text)
54
- # dejar solo letras, números, ñ y espacios
55
- text = re.sub(r'[^a-z0-9ñ\s]', ' ', text)
56
- # colapsar letras repetidas de 3+ a 2
57
- text = re.sub(r'(.)\1{2,}', r'\1\1', text)
58
- # espacios múltiples
59
- text = re.sub(r'\s+', ' ', text).strip()
60
- return text
61
-
62
-
63
- # === Normalizar diccionario y construir vocabulario ===
64
-
65
- def normalize_synonym_dict(sd: dict) -> dict:
66
- """
67
- Devuelve una versión normalizada (sin tildes, minúsculas) del diccionario.
68
- """
69
- new_sd = {}
70
- for term, synonyms in sd.items():
71
- norm_term = basic_cleanup(term)
72
- norm_syns = [basic_cleanup(s) for s in synonyms]
73
- # quitar duplicados y el propio término
74
- norm_syns = sorted({s for s in norm_syns if s and s != norm_term})
75
- new_sd[norm_term] = norm_syns
76
- return new_sd
77
-
78
-
79
- synonym_dict_norm = normalize_synonym_dict(synonym_dict)
80
-
81
-
82
- def build_vocab(sd: dict) -> set:
83
- """
84
- Construye un vocabulario de palabras a partir de términos y sinónimos.
85
- """
86
- vocab = set()
87
- for term, synonyms in sd.items():
88
- frases = [term] + synonyms
89
- for frase in frases:
90
- for palabra in frase.split():
91
- vocab.add(palabra)
92
- return vocab
93
-
94
-
95
- VOCAB = build_vocab(synonym_dict_norm)
96
-
97
-
98
- # === Corrección ortográfica fuzzy ===
99
-
100
- def correct_spelling(text: str, vocab: set, cutoff: float = 0.8) -> str:
101
- """
102
- Corrige palabras que no estén en el vocabulario usando similitud aproximada.
103
- """
104
- tokens = text.split()
105
- corrected = []
106
- for tok in tokens:
107
- if tok in vocab:
108
- corrected.append(tok)
109
- else:
110
- matches = get_close_matches(tok, list(vocab), n=1, cutoff=cutoff)
111
- if matches:
112
- corrected.append(matches[0])
113
- else:
114
- corrected.append(tok)
115
- return " ".join(corrected)
116
-
117
-
118
- # === Aplicar sinónimos -> término médico canónico ===
119
-
120
- def normalize_with_synonyms(text: str, sd_norm: dict) -> str:
121
- """
122
- Reemplaza frases sinónimas por el término médico canónico.
123
- text ya debe estar normalizado (basic_cleanup + correct_spelling).
124
- """
125
- replacements = []
126
-
127
- for medical_term, synonyms in sd_norm.items():
128
- # si ya está el término médico, no tocamos sus sinónimos
129
- if re.search(r'\b' + re.escape(medical_term) + r'\b', text):
130
- continue
131
- for synonym in synonyms:
132
- if synonym:
133
- replacements.append((synonym, medical_term))
134
-
135
- # primero las frases más largas
136
- replacements.sort(key=lambda x: len(x[0]), reverse=True)
137
-
138
- for synonym, medical_term in replacements:
139
- pattern = r'\b' + re.escape(synonym) + r'\b'
140
- text = re.sub(pattern, medical_term, text)
141
-
142
- return text
143
-
144
-
145
- # === Función principal utilizada por la API y el entrenamiento ===
146
-
147
- def normalize_text(user_text: str, _unused_dict=None) -> str:
148
- """
149
- Pipeline robusto:
150
- 1) limpieza básica (acentos, ruido, letras repetidas)
151
- 2) corrección ortográfica aproximada (fuzzy)
152
- 3) mapeo de sinónimos a términos médicos canónicos
153
-
154
- La firma mantiene el parámetro synonym_dict por compatibilidad,
155
- pero internamente usamos synonym_dict_norm global.
156
- """
157
- text = basic_cleanup(user_text)
158
- text = correct_spelling(text, VOCAB)
159
- text = normalize_with_synonyms(text, synonym_dict_norm)
160
- return text
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # app/utils/synonym_dict.py
2
+ import re
3
+ import unicodedata
4
+ from difflib import get_close_matches
5
+ from spellchecker import SpellChecker
6
+
7
+ # Configurar el corrector ortográfico en español
8
+ spell = SpellChecker(language='es')
9
+
10
+ # Términos médicos personalizados para el diccionario
11
+ medical_terms = [
12
+ # Síntomas comunes
13
+ 'rinorrea', 'fiebre', 'tos', 'expectoración', 'alzas', 'térmicas',
14
+ 'pálida', 'mucosas', 'disnea', 'somnolienta', 'cefalea', 'hiporexia',
15
+ 'disfonía', 'astenia', 'sibilancias', 'eructos', 'fétidos', 'febril',
16
+ # Sistemas corporales
17
+ 'respiratorio', 'digestivo', 'cardíaco', 'gastrointestinal', 'urinario',
18
+ # Medicamentos comunes
19
+ 'paracetamol', 'ibuprofeno', 'amoxicilina', 'omeprazol', 'loratadina',
20
+ # Exámenes y procedimientos
21
+ 'radiografía', 'análisis', 'hemograma', 'cultivo', 'ecografía'
22
+ ]
23
+
24
+ # Añadir términos médicos al diccionario del corrector
25
+ spell.word_frequency.load_words(medical_terms)
26
+
27
+ # Términos adicionales del diccionario de sinónimos
28
+ additional_terms = [
29
+ 'mocos', 'agua', 'nariz', 'temperatura', 'calor', 'tos', 'secreciones',
30
+ 'alimentos', 'apetito', 'habla', 'respiración', 'dolor', 'cabeza'
31
+ ]
32
+ spell.word_frequency.load_words(additional_terms)
33
+
34
+ # === Diccionario original de sinónimos (tal como lo definiste) ===
35
+ synonym_dict = {
36
+ "rinorrea": ["mocos como agua", "agua en la nariz", "nariz mocosa", "goteo de mocos como agua"],
37
+ "fiebre": ["temperatura alta", "calor", "alta temperatura", "calor intenso"],
38
+ "tos seca esporadica": ["tos espontanea", "a veces tos"],
39
+ "tos con expectoración": ["tos con flema", "tos con moco", "tos con expectoración"],
40
+ "alzas térmicas": ["temperaturas altas", "calor intenso"],
41
+ "piel pálida": ["piel pálida"],
42
+ "piel y mucosas pálidas": ["mucosas pálidas"],
43
+ "disnea": ["dificultad para respirar", "respiración rápida", "respiración difícil", "respiración dificultada"],
44
+ "somnolienta": ["cansancio", "sueño", "agotado"],
45
+ "cefalea": ["dolor de cabeza", "dolor de cabeza intenso", "dolor de cabeza severo", "dolor de cabeza fuerte"],
46
+ "tos seca sin secreciones": ["tos sin flema", "tos irritativa", "toz seca", "tis sica"],
47
+ "tos seca": ["tos seca sin secreciones"],
48
+ "hiporexia": ["rechaza alimentos", "no quiere comer", "no quiere lactar", "no tiene apetito"],
49
+ "disfonía": ["dificultad para hablar", "habla con dificultad", "ronco", "voz ronca"],
50
+ "malestar general": ["malestar", "no se siente bien", "malestar generalizado"],
51
+ "aumento de frecuencia respiratoria": ["frecuencia respiratoria aumentada", "respiración rápida", "respiración difícil"],
52
+ "sibilancias": ["silbido al respirar", "sonido al respirar", "respiración con silbido", "resoplido", "silbido"],
53
+ "astenica": ["sensación de debilidad", "falta de energía", "cansancio"],
54
+ "eructos fétidos": ["eructos de mal olor", "eructos fuertes", "eructos intensos"],
55
+ "febril": ["temperatura alta", "calor corporal"],
56
+ }
57
+
58
+
59
+ # === Normalización básica ===
60
+
61
+ def remove_accents(text: str) -> str:
62
+ """Elimina tildes/acentos del texto."""
63
+ return ''.join(
64
+ c for c in unicodedata.normalize('NFD', text)
65
+ if unicodedata.category(c) != 'Mn'
66
+ )
67
+
68
+
69
+ def basic_cleanup(text: str) -> str:
70
+ """
71
+ Limpieza básica:
72
+ - minúsculas
73
+ - sin tildes
74
+ - quitar signos raros
75
+ - colapsar letras repetidas (fieeebre -> fiebre)
76
+ """
77
+ if not isinstance(text, str):
78
+ text = str(text)
79
+
80
+ text = text.lower()
81
+ text = remove_accents(text)
82
+ # dejar solo letras, números, ñ y espacios
83
+ text = re.sub(r'[^a-z0-9ñ\s]', ' ', text)
84
+ # colapsar letras repetidas de 3+ a 2
85
+ text = re.sub(r'(.)\1{2,}', r'\1\1', text)
86
+ # espacios múltiples
87
+ text = re.sub(r'\s+', ' ', text).strip()
88
+ return text
89
+
90
+
91
+ # === Normalizar diccionario y construir vocabulario ===
92
+
93
+ def normalize_synonym_dict(sd: dict) -> dict:
94
+ """
95
+ Devuelve una versión normalizada (sin tildes, minúsculas) del diccionario.
96
+ """
97
+ new_sd = {}
98
+ for term, synonyms in sd.items():
99
+ norm_term = basic_cleanup(term)
100
+ norm_syns = [basic_cleanup(s) for s in synonyms]
101
+ # quitar duplicados y el propio término
102
+ norm_syns = sorted({s for s in norm_syns if s and s != norm_term})
103
+ new_sd[norm_term] = norm_syns
104
+ return new_sd
105
+
106
+
107
+ synonym_dict_norm = normalize_synonym_dict(synonym_dict)
108
+
109
+
110
+ def build_vocab(sd: dict) -> set:
111
+ """
112
+ Construye un vocabulario de palabras a partir de términos y sinónimos.
113
+ """
114
+ vocab = set()
115
+ for term, synonyms in sd.items():
116
+ frases = [term] + synonyms
117
+ for frase in frases:
118
+ for palabra in frase.split():
119
+ vocab.add(palabra)
120
+ return vocab
121
+
122
+
123
+ VOCAB = build_vocab(synonym_dict_norm)
124
+
125
+
126
+ # === Corrección ortográfica fuzzy ===
127
+
128
+ def correct_spelling(text: str, vocab: set = None, cutoff: float = 0.6) -> str:
129
+ """
130
+ Corrige la ortografía del texto usando pyspellchecker con soporte para términos médicos.
131
+
132
+ Args:
133
+ text: Texto a corregir
134
+ vocab: Conjunto de palabras del vocabulario conocido
135
+ cutoff: Umbral de confianza para la corrección (0-1)
136
+
137
+ Returns:
138
+ Texto con las correcciones ortográficas aplicadas
139
+ """
140
+ def calculate_similarity(w1: str, w2: str) -> float:
141
+ """Calcula la similitud entre dos palabras."""
142
+ if not w1 or not w2:
143
+ return 0.0
144
+
145
+ # Peso más alto para las primeras letras
146
+ min_len = min(len(w1), len(w2))
147
+ if min_len == 0:
148
+ return 0.0
149
+
150
+ # Verificar si las primeras letras coinciden
151
+ first_letter_match = 1.0 if w1[0] == w2[0] else 0.0
152
+
153
+ # Calcular similitud de conjuntos de caracteres
154
+ set1, set2 = set(w1), set(w2)
155
+ intersection = len(set1 & set2)
156
+ union = len(set1 | set2)
157
+ jaccard = intersection / union if union > 0 else 0
158
+
159
+ # Ponderar la similitud (50% primera letra, 50% similitud general)
160
+ return (first_letter_match * 0.5) + (jaccard * 0.5)
161
+
162
+ tokens = text.split()
163
+ corrected = []
164
+
165
+ for word in tokens:
166
+ # Si la palabra está en el vocabulario médico, no la corregimos
167
+ if word in spell or (vocab and word in vocab):
168
+ corrected.append(word)
169
+ continue
170
+
171
+ # Obtener la mejor corrección
172
+ best_correction = spell.correction(word)
173
+
174
+ # Si no hay corrección o es la misma palabra, mantener la original
175
+ if not best_correction or best_correction == word:
176
+ corrected.append(word)
177
+ continue
178
+
179
+ # Calcular similitud
180
+ similarity = calculate_similarity(word, best_correction)
181
+
182
+ # Aplicar corrección si la similitud es suficiente
183
+ if similarity >= cutoff:
184
+ # Priorizar términos médicos
185
+ if best_correction in medical_terms:
186
+ corrected.append(best_correction)
187
+ # Para palabras no médicas, ser más estricto
188
+ elif similarity >= 0.8:
189
+ corrected.append(best_correction)
190
+ else:
191
+ corrected.append(word)
192
+ else:
193
+ corrected.append(word)
194
+
195
+ return " ".join(corrected)
196
+
197
+
198
+ # === Aplicar sinónimos -> término médico canónico ===
199
+
200
+ def normalize_with_synonyms(text: str, sd_norm: dict) -> str:
201
+
202
+ replacements = []
203
+
204
+ for medical_term, synonyms in sd_norm.items():
205
+ # si ya está el término médico, no tocamos sus sinónimos
206
+ if re.search(r'\b' + re.escape(medical_term) + r'\b', text):
207
+ continue
208
+ for synonym in synonyms:
209
+ if synonym:
210
+ replacements.append((synonym, medical_term))
211
+
212
+ # primero las frases más largas
213
+ replacements.sort(key=lambda x: len(x[0]), reverse=True)
214
+
215
+ for synonym, medical_term in replacements:
216
+ pattern = r'\b' + re.escape(synonym) + r'\b'
217
+ text = re.sub(pattern, medical_term, text)
218
+
219
+ return text
220
+
221
+
222
+ # === Función principal utilizada por la API y el entrenamiento ===
223
+
224
+ def normalize_text(user_text: str, _unused_dict=None) -> str:
225
+ """
226
+ Pipeline robusto:
227
+ 1) limpieza básica (acentos, ruido, letras repetidas)
228
+ 2) corrección ortográfica aproximada (fuzzy)
229
+ 3) mapeo de sinónimos a términos médicos canónicos
230
+ """
231
+ # 1. Limpieza básica
232
+ text = basic_cleanup(user_text)
233
+
234
+ # 2. Corrección ortográfica
235
+ # Primero intentamos con el vocabulario médico
236
+ corrected = correct_spelling(text, VOCAB)
237
+
238
+ # Si no hubo cambios, intentamos con el diccionario general
239
+ if corrected == text:
240
+ corrected = ' '.join([spell.correction(word) or word for word in text.split()])
241
+
242
+ # 3. Normalización de sinónimos
243
+ normalized = normalize_with_synonyms(corrected, synonym_dict_norm)
244
+
245
+ # Si después de todo el proceso no hay cambios, devolvemos el texto original
246
+ return normalized if normalized.strip() else text