Spaces:
Running
Arquitectura Y (tri-LLM) + mejoras de recuperación y robustez
Browse files- y_refiner.py: YRefiner con α (GLM-5.2) + γ (Kimi-K2.6) en paralelo y
N (DeepSeek-V4-Flash) bajo contrato (1 línea, ≤60 palabras). Cadenas
de fallback por rol, circuit breaker de sesión, reasoning_effort=none
para modelos híbridos, degradación 4 escalones, telemetría con coste.
- agent.py: prefijo BGE en queries, re-ranking CrossEncoder (top 30→15),
MMR λ=0.7 para léxico diverso, filtro de fragmentos 5-48 chars,
warmup de modelos en arranque.
- app.py: toggle ⚡Rápido/🔱Y, modo rápido sobre registry con fallback
(R6), strip de bloques <think>, verificación de catálogo al arrancar.
- README reescrito (transcript pegado eliminado, modelos reales).
- semantic_database.py eliminado (código muerto).
Verificado: contrato N 5/5 al primer intento, latencia media 8.3s,
degradación parcial y total OK, modo rápido sin regresión.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- .gitignore +2 -0
- README.md +55 -171
- agent.py +62 -8
- app.py +98 -45
- requirements.txt +12 -13
- semantic_database.py +0 -541
- y_refiner.py +353 -0
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__/
|
| 2 |
+
*.pyc
|
|
@@ -13,187 +13,71 @@ short_description: Enriquecimiento de prompts visuales
|
|
| 13 |
|
| 14 |
# PromptCraft: Refinamiento Estructural de Prompts
|
| 15 |
|
| 16 |
-
**PromptCraft**
|
| 17 |
-
|
|
|
|
|
|
|
| 18 |
|
| 19 |
> **No impone plantillas. No inventa. Solo estructura.**
|
| 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 |
## 🛠️ Cómo usarlo
|
| 50 |
|
| 51 |
-
1.
|
| 52 |
-
2.
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
- **Imaginativo**: fantasía, ciencia ficción.
|
| 58 |
-
- **Texto**: tipografía, logotipos.
|
| 59 |
-
3. Haz clic en **"🔄 Refinar prompt"**.
|
| 60 |
-
4. Si no te gusta el resultado, **refina de nuevo**.
|
| 61 |
-
5. Cuando estés satisfecho, haz clic en **"🎨 Generar imagen"**.
|
| 62 |
|
| 63 |
-
|
| 64 |
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
```json
|
| 69 |
-
[
|
| 70 |
-
{
|
| 71 |
-
"caption": "a cyberpunk street at night, neon signs...",
|
| 72 |
-
"category": "composition"
|
| 73 |
-
},
|
| 74 |
-
{
|
| 75 |
-
"caption": "oil painting of a forest, style of Van Gogh",
|
| 76 |
-
"category": "style"
|
| 77 |
-
}
|
| 78 |
-
]
|
| 79 |
-
|
| 80 |
-
Sin el campo "category", el sistema funciona en modo "Automática" (sin filtrar).
|
| 81 |
-
|
| 82 |
-
📦 Dependencias
|
| 83 |
-
gradio==4.24.0
|
| 84 |
-
Pillow
|
| 85 |
-
huggingface-hub>=0.24.0
|
| 86 |
-
sentence-transformers>=2.3.0
|
| 87 |
-
faiss-cpu==1.7.4
|
| 88 |
-
datasets>=2.10.0
|
| 89 |
-
torch
|
| 90 |
-
transformers
|
| 91 |
-
sentencepiece
|
| 92 |
-
profile
|
| 93 |
-
Qwen3-Max
|
| 94 |
-
7:48 pm
|
| 95 |
-
¡Claro, socio! Aquí tienes el README.md completo en un solo bloque de código, listo para copiar y pegar, con los créditos finales como pediste.
|
| 96 |
-
|
| 97 |
-
markdown
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
1
|
| 101 |
-
2
|
| 102 |
-
3
|
| 103 |
-
4
|
| 104 |
-
5
|
| 105 |
-
6
|
| 106 |
-
7
|
| 107 |
-
8
|
| 108 |
-
9
|
| 109 |
-
10
|
| 110 |
-
11
|
| 111 |
-
⌄
|
| 112 |
-
⌄
|
| 113 |
-
# PromptCraft: Refinamiento Estructural de Prompts
|
| 114 |
|
| 115 |
-
|
| 116 |
-
A diferencia de los refinadores basados en parafraseo, **PromptCraft analiza la gramática descriptiva de 100.000 prompts profesionales** y reconstruye el concepto del usuario imitando esa estructura real.
|
| 117 |
|
| 118 |
-
|
| 119 |
|
| 120 |
-
|
| 121 |
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
Traducción local (es → en)
|
| 127 |
-
↓
|
| 128 |
-
Recuperación semántica (FAISS + categoría)
|
| 129 |
-
↓
|
| 130 |
-
Re-ranking con Cross-Encoder (top 6)
|
| 131 |
-
↓
|
| 132 |
-
Reconstrucción con LLaMA-3.2-3B (gramática del dataset)
|
| 133 |
-
↓
|
| 134 |
-
Generación con SDXL
|
| 135 |
-
|
| 136 |
-
---
|
| 137 |
-
|
| 138 |
-
## 🔑 Características clave
|
| 139 |
-
|
| 140 |
-
- **Refinamiento estructural**: el LLM imita la gramática de prompts reales, no sigue una plantilla abstracta.
|
| 141 |
-
- **Categorización contextual**: filtra ejemplos por tipo (entidad, estilo, composición, etc.).
|
| 142 |
-
- **Transparencia total**: muestra los 6 ejemplos del dataset usados para el refinamiento.
|
| 143 |
-
- **Iteración libre**: refina el prompt tantas veces como necesites antes de generar.
|
| 144 |
-
- **100% funcional en CPU**: traducción y análisis locales; solo la generación usa la nube.
|
| 145 |
-
|
| 146 |
-
---
|
| 147 |
-
|
| 148 |
-
## 🛠️ Cómo usarlo
|
| 149 |
-
|
| 150 |
-
1. **Escribe tu idea en castellano** (ej: *"un mercado en Antigua Guatemala"*).
|
| 151 |
-
2. **(Opcional) Selecciona una categoría**:
|
| 152 |
-
- **Automática**: el sistema decide.
|
| 153 |
-
- **Entidad**: personas, objetos, criaturas.
|
| 154 |
-
- **Composición**: escenas, entornos, narrativas.
|
| 155 |
-
- **Estilo artístico**: pintura, ilustración, fotografía.
|
| 156 |
-
- **Imaginativo**: fantasía, ciencia ficción.
|
| 157 |
-
- **Texto**: tipografía, logotipos.
|
| 158 |
-
3. Haz clic en **"🔄 Refinar prompt"**.
|
| 159 |
-
4. Si no te gusta el resultado, **refina de nuevo**.
|
| 160 |
-
5. Cuando estés satisfecho, haz clic en **"🎨 Generar imagen"**.
|
| 161 |
-
|
| 162 |
-
---
|
| 163 |
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
Tu `metadata.json` debe tener este formato:
|
| 167 |
-
```json
|
| 168 |
-
[
|
| 169 |
-
{
|
| 170 |
-
"caption": "a cyberpunk street at night, neon signs...",
|
| 171 |
-
"category": "composition"
|
| 172 |
-
},
|
| 173 |
-
{
|
| 174 |
-
"caption": "oil painting of a forest, style of Van Gogh",
|
| 175 |
-
"category": "style"
|
| 176 |
-
}
|
| 177 |
-
]
|
| 178 |
-
Sin el campo "category", el sistema funciona en modo "Automática" (sin filtrar).
|
| 179 |
-
|
| 180 |
-
📦 Dependencias
|
| 181 |
-
|
| 182 |
-
gradio==4.24.0
|
| 183 |
-
Pillow
|
| 184 |
-
huggingface-hub>=0.24.0
|
| 185 |
-
sentence-transformers>=2.3.0
|
| 186 |
-
faiss-cpu==1.7.4
|
| 187 |
-
datasets>=2.10.0
|
| 188 |
-
torch
|
| 189 |
-
transformers
|
| 190 |
-
sentencepiece
|
| 191 |
-
|
| 192 |
-
🚀 Créditos
|
| 193 |
-
Motor semántico: FAISS + bge-small-en-v1.5 + Cross-Encoder.
|
| 194 |
-
Traducción: Helsinki-NLP/opus-mt-es-en (local, CPU).
|
| 195 |
-
Refinamiento: meta-llama/Llama-3.2-3B-Instruct.
|
| 196 |
-
Generación: stabilityai/stable-diffusion-xl-base-1.0.
|
| 197 |
-
Este proyecto nace de la necesidad de coherencia narrativa y control estructural en la generación de imágenes por IA, especialmente para cineastas, escritores y artistas visuales.
|
| 198 |
-
|
| 199 |
-
Creado por Angel E. Pariente 🇬🇹 según la idea de Nacho Ravinovich 🇦🇷
|
|
|
|
| 13 |
|
| 14 |
# PromptCraft: Refinamiento Estructural de Prompts
|
| 15 |
|
| 16 |
+
**PromptCraft** transforma ideas en castellano en prompts optimizados para modelos de
|
| 17 |
+
difusión (SDXL, FLUX, Midjourney). Analiza la gramática descriptiva de ~100.000 prompts
|
| 18 |
+
profesionales (índice FAISS local) y reconstruye el concepto del usuario imitando esa
|
| 19 |
+
estructura real.
|
| 20 |
|
| 21 |
> **No impone plantillas. No inventa. Solo estructura.**
|
| 22 |
|
| 23 |
+
## ⚙️ Dos motores de refinamiento
|
| 24 |
+
|
| 25 |
+
### ⚡ Modo Rápido (1 LLM)
|
| 26 |
+
Pipeline clásico de una sola llamada:
|
| 27 |
+
|
| 28 |
+
```
|
| 29 |
+
castellano → traducción (Helsinki-NLP opus-mt-es-en, Inference API)
|
| 30 |
+
→ recuperación semántica (FAISS + bge-small-en-v1.5, prefijo BGE en query)
|
| 31 |
+
→ re-ranking (CrossEncoder ms-marco-MiniLM-L6-v2) + MMR (λ=0.7)
|
| 32 |
+
→ composición estructural (Qwen2.5-7B-Instruct)
|
| 33 |
+
→ validación (≤60 palabras, concepto presente) → SDXL
|
| 34 |
+
```
|
| 35 |
+
|
| 36 |
+
### 🔱 Modo Y (3 LLMs)
|
| 37 |
+
Dos generadores en paralelo bajo restricciones heterogéneas, un normalizador bajo
|
| 38 |
+
contrato estricto:
|
| 39 |
+
|
| 40 |
+
```
|
| 41 |
+
┌─ α zai-org/GLM-5.2 ── vocabulario canónico SDXL + léxico FAISS
|
| 42 |
+
concepto traducido ─┤ (en paralelo)
|
| 43 |
+
└─ γ moonshotai/Kimi-K2.6 ── solo lenguaje visual concreto,
|
| 44 |
+
quality-tags prohibidos
|
| 45 |
+
↓
|
| 46 |
+
N deepseek-ai/DeepSeek-V4-Flash ── integra α+γ: 1 línea, ≤60 palabras
|
| 47 |
+
↓
|
| 48 |
+
contrato incumplido → 1 reintento (temp 0) → modo rápido → fallback semántico
|
| 49 |
+
```
|
| 50 |
+
|
| 51 |
+
Todos los modelos se sirven vía el router de Hugging Face
|
| 52 |
+
(`https://router.huggingface.co/v1`, secreto `HF_TOKEN`), con cadena de fallback de
|
| 53 |
+
provider/modelo por rol y `reasoning_effort="none"` para desactivar el modo thinking
|
| 54 |
+
de los modelos híbridos. Generación de imagen:
|
| 55 |
+
`stabilityai/stable-diffusion-xl-base-1.0`.
|
| 56 |
|
| 57 |
## 🛠️ Cómo usarlo
|
| 58 |
|
| 59 |
+
1. Escribe tu idea en castellano.
|
| 60 |
+
2. (Opcional) Selecciona categoría (entidad, composición, estilo, imaginativo, texto).
|
| 61 |
+
3. Elige motor: **⚡ Rápido** (por defecto) o **🔱 Modo Y** (más lento, mayor calidad).
|
| 62 |
+
4. **🔄 Refinar prompt** — repite hasta que te guste. El panel de estado muestra qué
|
| 63 |
+
motor/escalón produjo el resultado, latencias por nodo y coste estimado de sesión.
|
| 64 |
+
5. **🎨 Generar imagen**.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
|
| 66 |
+
## 📁 Dataset
|
| 67 |
|
| 68 |
+
`metadata.json`: lista de `{"caption": "...", "category": "..."}`. Sin `category`,
|
| 69 |
+
todo funciona en modo "Automática". `faiss_index.bin` contiene los embeddings
|
| 70 |
+
(bge-small-en-v1.5, sin prefijo — el prefijo BGE se aplica solo a las queries).
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
|
| 72 |
+
## 📦 Dependencias
|
|
|
|
| 73 |
|
| 74 |
+
Ver `requirements.txt` (versiones con wheels verificados para Python 3.13).
|
| 75 |
|
| 76 |
+
## 🚀 Créditos
|
| 77 |
|
| 78 |
+
- Motor semántico: FAISS + bge-small-en-v1.5 + CrossEncoder + MMR.
|
| 79 |
+
- Traducción: Helsinki-NLP/opus-mt-es-en.
|
| 80 |
+
- Refinamiento: Qwen2.5-7B (rápido) · GLM-5.2 + Kimi-K2.6 + DeepSeek-V4-Flash (Y).
|
| 81 |
+
- Generación: stabilityai/stable-diffusion-xl-base-1.0.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
|
| 83 |
+
Creado por Angel E. Pariente 🇬🇹 · Sobre una idea de Nacho Ravinovich 🇦🇷
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -22,6 +22,10 @@ CONTENT_BLACKLIST = {"tokyo","antigua","guatemala","maya","indigenous","renaissa
|
|
| 22 |
"crimson","cerulean","velvet","silk","golden retriever","yorkshire terrier","atitlán",
|
| 23 |
"lucy nicholson","henrietta harris","blade runner","van gogh","jakub rozalski","carole at camelotcostumes"}
|
| 24 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
try:
|
| 26 |
NLP = spacy.load("en_core_web_sm")
|
| 27 |
except:
|
|
@@ -49,6 +53,13 @@ class ImprovedSemanticAgent:
|
|
| 49 |
self.indexed_examples = json.load(f)
|
| 50 |
self.total_indexed = len(self.indexed_examples)
|
| 51 |
self.is_ready = True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
return f"✅ {self.total_indexed:,} ejemplos cargados"
|
| 53 |
except Exception as e:
|
| 54 |
return f"❌ Error: {str(e)}"
|
|
@@ -59,18 +70,29 @@ class ImprovedSemanticAgent:
|
|
| 59 |
if not self.is_ready: return self._default_response()
|
| 60 |
|
| 61 |
try:
|
| 62 |
-
query_emb = self.embedding_model.encode(
|
|
|
|
|
|
|
| 63 |
_, idxs = self.index.search(query_emb, 50)
|
| 64 |
-
|
| 65 |
-
|
|
|
|
| 66 |
for idx in idxs[0]:
|
| 67 |
if 0 <= idx < len(self.indexed_examples):
|
| 68 |
ex = self.indexed_examples[idx]
|
| 69 |
cap = ex.get('caption', '')
|
| 70 |
if isinstance(cap, str) and len(cap) > 15 and (category == "auto" or ex.get('category') == category):
|
| 71 |
-
|
| 72 |
-
if len(
|
| 73 |
-
if not
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
|
| 75 |
# 1. Detectar patrón estructural real
|
| 76 |
comma_counts = [len([p.strip() for p in ex.split(',') if len(p.strip()) > 4]) for ex in examples]
|
|
@@ -83,10 +105,12 @@ class ImprovedSemanticAgent:
|
|
| 83 |
}
|
| 84 |
pattern = patterns.get(avg, patterns[6])
|
| 85 |
|
| 86 |
-
# 2. Pool léxico contextual (últimas 2 cláusulas, filtradas)
|
|
|
|
|
|
|
| 87 |
raw_fragments = []
|
| 88 |
for ex in examples:
|
| 89 |
-
parts = [p.strip() for p in ex.split(',') if 5 < len(p.strip()) <
|
| 90 |
raw_fragments.extend(parts[-2:])
|
| 91 |
|
| 92 |
lexicon = self._filter_and_score(raw_fragments, user_en)
|
|
@@ -100,11 +124,41 @@ class ImprovedSemanticAgent:
|
|
| 100 |
logger.error(f"❌ Error extracción: {e}")
|
| 101 |
return self._default_response()
|
| 102 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
def _filter_and_score(self, fragments: List[str], user_en: str) -> List[str]:
|
| 104 |
user_lower = user_en.lower()
|
| 105 |
valid = []
|
| 106 |
for frag in fragments:
|
| 107 |
fl = frag.lower()
|
|
|
|
|
|
|
|
|
|
| 108 |
if any(b in fl for b in CONTENT_BLACKLIST): continue
|
| 109 |
if any(w in user_lower for w in re.findall(r'\b[a-z]{4,}\b', fl)): continue
|
| 110 |
doc = NLP(frag)
|
|
|
|
| 22 |
"crimson","cerulean","velvet","silk","golden retriever","yorkshire terrier","atitlán",
|
| 23 |
"lucy nicholson","henrietta harris","blade runner","van gogh","jakub rozalski","carole at camelotcostumes"}
|
| 24 |
|
| 25 |
+
# BGE requiere este prefijo SOLO en las queries; los documentos del índice
|
| 26 |
+
# se embebieron sin prefijo (build estándar), así que el índice no se toca.
|
| 27 |
+
BGE_QUERY_PREFIX = "Represent this sentence for searching relevant passages: "
|
| 28 |
+
|
| 29 |
try:
|
| 30 |
NLP = spacy.load("en_core_web_sm")
|
| 31 |
except:
|
|
|
|
| 53 |
self.indexed_examples = json.load(f)
|
| 54 |
self.total_indexed = len(self.indexed_examples)
|
| 55 |
self.is_ready = True
|
| 56 |
+
# Warmup: la primera inferencia de cada modelo paga carga/compilación
|
| 57 |
+
# (~10s); hacerlo en el arranque y no en el primer usuario.
|
| 58 |
+
try:
|
| 59 |
+
self.embedding_model.encode(["warmup"], normalize_embeddings=True)
|
| 60 |
+
self.reranker.predict([("warmup", "warmup")])
|
| 61 |
+
except Exception as e:
|
| 62 |
+
logger.warning(f"⚠️ Warmup fallido (no crítico): {e}")
|
| 63 |
return f"✅ {self.total_indexed:,} ejemplos cargados"
|
| 64 |
except Exception as e:
|
| 65 |
return f"❌ Error: {str(e)}"
|
|
|
|
| 70 |
if not self.is_ready: return self._default_response()
|
| 71 |
|
| 72 |
try:
|
| 73 |
+
query_emb = self.embedding_model.encode(
|
| 74 |
+
[BGE_QUERY_PREFIX + user_en], normalize_embeddings=True
|
| 75 |
+
)[0].astype('float32').reshape(1, -1)
|
| 76 |
_, idxs = self.index.search(query_emb, 50)
|
| 77 |
+
|
| 78 |
+
# Candidatos válidos (hasta 30) conservando su idx para recuperar embeddings
|
| 79 |
+
candidates = []
|
| 80 |
for idx in idxs[0]:
|
| 81 |
if 0 <= idx < len(self.indexed_examples):
|
| 82 |
ex = self.indexed_examples[idx]
|
| 83 |
cap = ex.get('caption', '')
|
| 84 |
if isinstance(cap, str) and len(cap) > 15 and (category == "auto" or ex.get('category') == category):
|
| 85 |
+
candidates.append((int(idx), cap))
|
| 86 |
+
if len(candidates) >= 30: break
|
| 87 |
+
if not candidates: return self._default_response()
|
| 88 |
+
|
| 89 |
+
# Re-ranking con CrossEncoder: (concepto, caption) → top 15
|
| 90 |
+
pairs = [(user_en, cap) for _, cap in candidates]
|
| 91 |
+
ce_scores = self.reranker.predict(pairs)
|
| 92 |
+
reranked = [c for _, c in sorted(zip(ce_scores, candidates), key=lambda t: -t[0])][:15]
|
| 93 |
+
|
| 94 |
+
# MMR (λ=0.7) sobre los 15 para elegir k ejemplos diversos
|
| 95 |
+
examples = [cap for _, cap in self._mmr_select(query_emb[0], reranked, k=k, lam=0.7)]
|
| 96 |
|
| 97 |
# 1. Detectar patrón estructural real
|
| 98 |
comma_counts = [len([p.strip() for p in ex.split(',') if len(p.strip()) > 4]) for ex in examples]
|
|
|
|
| 105 |
}
|
| 106 |
pattern = patterns.get(avg, patterns[6])
|
| 107 |
|
| 108 |
+
# 2. Pool léxico contextual (últimas 2 cláusulas, filtradas).
|
| 109 |
+
# Hasta 48 chars: las SAFE_ATMOSPHERIC_FRAGMENTS miden 25-44 chars y el
|
| 110 |
+
# tope anterior de 15 las hacía inalcanzables.
|
| 111 |
raw_fragments = []
|
| 112 |
for ex in examples:
|
| 113 |
+
parts = [p.strip() for p in ex.split(',') if 5 < len(p.strip()) <= 48]
|
| 114 |
raw_fragments.extend(parts[-2:])
|
| 115 |
|
| 116 |
lexicon = self._filter_and_score(raw_fragments, user_en)
|
|
|
|
| 124 |
logger.error(f"❌ Error extracción: {e}")
|
| 125 |
return self._default_response()
|
| 126 |
|
| 127 |
+
def _mmr_select(self, query_emb: np.ndarray, candidates: List[Tuple[int, str]],
|
| 128 |
+
k: int = 6, lam: float = 0.7) -> List[Tuple[int, str]]:
|
| 129 |
+
"""Maximal Marginal Relevance: relevancia a la query − redundancia con lo ya elegido.
|
| 130 |
+
Usa los embeddings ya almacenados en el índice FAISS (reconstruct); si el tipo de
|
| 131 |
+
índice no lo permite, re-encodea los pocos candidatos (≤15, coste despreciable)."""
|
| 132 |
+
if len(candidates) <= k:
|
| 133 |
+
return candidates
|
| 134 |
+
try:
|
| 135 |
+
doc_embs = np.stack([self.index.reconstruct(idx) for idx, _ in candidates])
|
| 136 |
+
except Exception:
|
| 137 |
+
doc_embs = self.embedding_model.encode(
|
| 138 |
+
[cap for _, cap in candidates], normalize_embeddings=True
|
| 139 |
+
).astype('float32')
|
| 140 |
+
# Embeddings normalizados → producto punto = similitud coseno
|
| 141 |
+
query_sims = doc_embs @ query_emb
|
| 142 |
+
selected, remaining = [], list(range(len(candidates)))
|
| 143 |
+
while remaining and len(selected) < k:
|
| 144 |
+
if not selected:
|
| 145 |
+
best = max(remaining, key=lambda i: query_sims[i])
|
| 146 |
+
else:
|
| 147 |
+
sel_embs = doc_embs[selected]
|
| 148 |
+
best = max(remaining,
|
| 149 |
+
key=lambda i: lam * query_sims[i] - (1 - lam) * float(np.max(sel_embs @ doc_embs[i])))
|
| 150 |
+
selected.append(best)
|
| 151 |
+
remaining.remove(best)
|
| 152 |
+
return [candidates[i] for i in selected]
|
| 153 |
+
|
| 154 |
def _filter_and_score(self, fragments: List[str], user_en: str) -> List[str]:
|
| 155 |
user_lower = user_en.lower()
|
| 156 |
valid = []
|
| 157 |
for frag in fragments:
|
| 158 |
fl = frag.lower()
|
| 159 |
+
# Fragmentos rotos por el split de comas (comillas sueltas, cláusulas
|
| 160 |
+
# que empiezan a mitad de frase): deben empezar por letra
|
| 161 |
+
if not frag or not frag[0].isalpha(): continue
|
| 162 |
if any(b in fl for b in CONTENT_BLACKLIST): continue
|
| 163 |
if any(w in user_lower for w in re.findall(r'\b[a-z]{4,}\b', fl)): continue
|
| 164 |
doc = NLP(frag)
|
|
@@ -8,6 +8,7 @@ from typing import Optional, Tuple, List, Dict
|
|
| 8 |
from huggingface_hub import InferenceClient
|
| 9 |
from openai import OpenAI
|
| 10 |
from agent import ImprovedSemanticAgent
|
|
|
|
| 11 |
|
| 12 |
# ✅ Configuración de logging profesional
|
| 13 |
logging.basicConfig(
|
|
@@ -36,9 +37,23 @@ class LlamaRefiner:
|
|
| 36 |
|
| 37 |
# Modelo principal confirmado
|
| 38 |
self.model_name = "Qwen/Qwen2.5-7B-Instruct:together"
|
| 39 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
logger.info("✅ LlamaRefiner v3.1 inicializado correctamente")
|
| 41 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
def _translate_to_english(self, text_es: str) -> str:
|
| 43 |
"""Traduce ES → EN con fallback seguro."""
|
| 44 |
if not text_es.strip():
|
|
@@ -66,46 +81,36 @@ class LlamaRefiner:
|
|
| 66 |
system_prompt = (
|
| 67 |
"You are an expert diffusion prompt composer. Your task is to transcribe the user's concept into a "
|
| 68 |
"professional, narrative-style image prompt that strictly follows the structural cadence of high-end datasets.\n\n"
|
| 69 |
-
f"STRUCTURAL TEMPLATE: {pattern_str}\n"
|
| 70 |
f"AVAILABLE ATMOSPHERIC/STYLISTIC FRAGMENTS: {lexicon_str}\n\n"
|
| 71 |
"RULES:\n"
|
| 72 |
-
"1. Map the user concept
|
| 73 |
"2. Use ONLY the provided fragments to enrich atmospheric, lighting, or quality slots. DO NOT invent objects, colors, or eras.\n"
|
| 74 |
-
"3. DO NOT copy full reference phrases. DO NOT output explanations.
|
| 75 |
-
"4.
|
|
|
|
|
|
|
| 76 |
)
|
| 77 |
|
| 78 |
user_prompt = f"User concept to compose: {user_en}"
|
| 79 |
return system_prompt, user_prompt
|
| 80 |
|
| 81 |
def _call_llm(self, system_prompt: str, user_prompt: str) -> Optional[str]:
|
| 82 |
-
"""Llamada al LLM con
|
| 83 |
try:
|
| 84 |
-
logger.info(f"🔄 Llamando
|
| 85 |
-
|
| 86 |
-
|
| 87 |
messages=[
|
| 88 |
{"role": "system", "content": system_prompt},
|
| 89 |
{"role": "user", "content": user_prompt}
|
| 90 |
],
|
| 91 |
-
max_tokens=160,
|
| 92 |
-
temperature=0.1,
|
| 93 |
-
top_p=0.9,
|
| 94 |
-
timeout=50
|
| 95 |
)
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
else:
|
| 101 |
-
logger.warning("⚠️ LLM devolvió respuesta vacía.")
|
| 102 |
-
return None
|
| 103 |
-
except Exception as e:
|
| 104 |
-
err_msg = str(e)
|
| 105 |
-
if "400" in err_msg or "401" in err_msg or "403" in err_msg or "not supported" in err_msg.lower():
|
| 106 |
-
logger.error(f"🚫 Error de modelo/proveedor: {err_msg[:100]}")
|
| 107 |
-
else:
|
| 108 |
-
logger.warning(f"⚠️ Error de conexión/timeout: {err_msg[:100]}")
|
| 109 |
return None
|
| 110 |
|
| 111 |
def _validate_output(self, user_en: str, output: str) -> Tuple[bool, str]:
|
|
@@ -123,9 +128,12 @@ class LlamaRefiner:
|
|
| 123 |
if not any(w in output_lower for w in user_tokens):
|
| 124 |
return False, "missing_user_concept"
|
| 125 |
|
| 126 |
-
# 2. Longitud razonable
|
|
|
|
|
|
|
|
|
|
| 127 |
word_count = len(output.split())
|
| 128 |
-
if word_count >
|
| 129 |
return False, f"too_long ({word_count} words)"
|
| 130 |
|
| 131 |
# 3. Seguridad (términos prohibidos)
|
|
@@ -136,15 +144,37 @@ class LlamaRefiner:
|
|
| 136 |
|
| 137 |
return True, "valid"
|
| 138 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 139 |
def _clean_output(self, text: str) -> str:
|
| 140 |
-
"""Limpieza final del prompt generado."""
|
|
|
|
|
|
|
|
|
|
|
|
|
| 141 |
text = text.strip().strip("\"'")
|
| 142 |
# Eliminar prefijos comunes de LLM
|
| 143 |
for prefix in ["Here is", "Final:", "Output:", "Prompt:", "Result:", "Generated:"]:
|
| 144 |
if text.startswith(prefix):
|
| 145 |
text = text.split(":", 1)[-1].strip()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 146 |
# Eliminar markdown/etiquetas residuales
|
| 147 |
text = re.sub(r'\*+|_+|</?[^>]+>', '', text).strip()
|
|
|
|
|
|
|
|
|
|
| 148 |
# Eliminar punto final si parece frase suelta
|
| 149 |
if text.endswith('.') and text.count(',') < 3:
|
| 150 |
text = text.rstrip('.')
|
|
@@ -176,22 +206,32 @@ class LlamaRefiner:
|
|
| 176 |
sys_msg, usr_msg = self._build_compositional_prompt(user_en, agent_data)
|
| 177 |
|
| 178 |
# 4. Llamada al LLM
|
|
|
|
| 179 |
raw_output = self._call_llm(sys_msg, usr_msg)
|
| 180 |
-
|
|
|
|
| 181 |
if raw_output:
|
| 182 |
cleaned = self._clean_output(raw_output)
|
| 183 |
is_valid, reason = self._validate_output(user_en, cleaned)
|
| 184 |
-
|
| 185 |
if is_valid:
|
| 186 |
logger.info(f"✅ Prompt validado: {reason}")
|
| 187 |
-
|
|
|
|
|
|
|
| 188 |
else:
|
| 189 |
logger.warning(f"⚠️ Output rechazado: {reason} → '{cleaned}'")
|
| 190 |
-
|
| 191 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 192 |
logger.info("🔧 Aplicando fallback semántico...")
|
|
|
|
| 193 |
fallback, _ = self.agent.enhance_prompt(user_en, category)
|
| 194 |
-
|
|
|
|
| 195 |
|
| 196 |
|
| 197 |
class SDXLGenerator:
|
|
@@ -225,18 +265,19 @@ def create_interface():
|
|
| 225 |
try:
|
| 226 |
refiner = LlamaRefiner()
|
| 227 |
generator = SDXLGenerator()
|
|
|
|
|
|
|
| 228 |
logger.info("✅ Todos los servicios inicializados correctamente")
|
| 229 |
except Exception as e:
|
| 230 |
logger.error(f"❌ Error crítico de inicialización: {e}", exc_info=True)
|
| 231 |
raise
|
| 232 |
|
| 233 |
-
def do_refine(prompt: str, category_es: str, progress=gr.Progress()):
|
| 234 |
if not prompt.strip():
|
| 235 |
return "", "", "Prompt vacío."
|
| 236 |
if refiner is None:
|
| 237 |
return "", "", "❌ Servicio no disponible (revisa los logs)."
|
| 238 |
-
|
| 239 |
-
progress(0.3, desc="🔄 Traduciendo y analizando estructura...")
|
| 240 |
category_map = {
|
| 241 |
"Automática": "auto",
|
| 242 |
"Entidad": "entity",
|
|
@@ -246,10 +287,15 @@ def create_interface():
|
|
| 246 |
"Texto": "text"
|
| 247 |
}
|
| 248 |
category_en = category_map.get(category_es, "auto")
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 253 |
examples_text = "\n".join(f"{i+1}. {ex}" for i, ex in enumerate(examples)) if examples else "Sin ejemplos recuperados."
|
| 254 |
return refined_prompt, examples_text, status_msg
|
| 255 |
|
|
@@ -291,6 +337,12 @@ def create_interface():
|
|
| 291 |
placeholder="Ej: retrato de una mujer en la edad media con un elaborado vestido"
|
| 292 |
)
|
| 293 |
category_input = gr.Dropdown(label="📂 Categoría", choices=CATEGORY_CHOICES, value="Automática")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 294 |
aspect_input = gr.Dropdown(label="📐 Proporción", choices=ASPECT_CHOICES, value="1:1")
|
| 295 |
|
| 296 |
refine_btn = gr.Button("🔄 Refinar Prompt", variant="secondary", size="lg")
|
|
@@ -300,7 +352,7 @@ def create_interface():
|
|
| 300 |
refined_output = gr.Textbox(label="🇬🇧 Prompt refinado (inglés)", interactive=False, lines=2)
|
| 301 |
image_output = gr.Image(label="🖼️ Imagen generada", type="filepath", height=420)
|
| 302 |
examples_output = gr.Textbox(label="📚 Referencia estructural (dataset)", interactive=False, lines=4)
|
| 303 |
-
status_output = gr.Textbox(label="📊 Estado / Métricas", interactive=False, lines=
|
| 304 |
|
| 305 |
gr.HTML("""
|
| 306 |
<div style="text-align: center; padding: 15px; margin-top: 20px; border-top: 1px solid #333; color: #666; font-size: 0.9em;">
|
|
@@ -310,7 +362,7 @@ def create_interface():
|
|
| 310 |
|
| 311 |
refine_btn.click(
|
| 312 |
fn=do_refine,
|
| 313 |
-
inputs=[prompt_input, category_input],
|
| 314 |
outputs=[refined_output, examples_output, status_output],
|
| 315 |
show_progress=True
|
| 316 |
)
|
|
@@ -324,4 +376,5 @@ def create_interface():
|
|
| 324 |
|
| 325 |
if __name__ == "__main__":
|
| 326 |
demo = create_interface()
|
| 327 |
-
|
|
|
|
|
|
| 8 |
from huggingface_hub import InferenceClient
|
| 9 |
from openai import OpenAI
|
| 10 |
from agent import ImprovedSemanticAgent
|
| 11 |
+
from y_refiner import YRefiner, call_with_fallback, check_catalog, NodeError
|
| 12 |
|
| 13 |
# ✅ Configuración de logging profesional
|
| 14 |
logging.basicConfig(
|
|
|
|
| 37 |
|
| 38 |
# Modelo principal confirmado
|
| 39 |
self.model_name = "Qwen/Qwen2.5-7B-Instruct:together"
|
| 40 |
+
|
| 41 |
+
# Métricas de sesión: cuántas veces el LLM respondió bien, fue rechazado
|
| 42 |
+
# por el validador, falló la llamada, o se usó el fallback semántico
|
| 43 |
+
self.metrics = {"total": 0, "llm_ok": 0, "llm_rejected": 0, "llm_error": 0, "fallback": 0}
|
| 44 |
+
|
| 45 |
logger.info("✅ LlamaRefiner v3.1 inicializado correctamente")
|
| 46 |
|
| 47 |
+
def _metrics_summary(self) -> str:
|
| 48 |
+
"""Resumen corto de métricas para mostrar en la UI."""
|
| 49 |
+
m = self.metrics
|
| 50 |
+
if m["total"] == 0:
|
| 51 |
+
return ""
|
| 52 |
+
reject_rate = 100.0 * (m["llm_rejected"] + m["llm_error"]) / m["total"]
|
| 53 |
+
return (f"Sesión: {m['total']} refinados | LLM OK: {m['llm_ok']} | "
|
| 54 |
+
f"rechazados: {m['llm_rejected']} | errores: {m['llm_error']} | "
|
| 55 |
+
f"fallback: {m['fallback']} | tasa de rechazo LLM: {reject_rate:.0f}%")
|
| 56 |
+
|
| 57 |
def _translate_to_english(self, text_es: str) -> str:
|
| 58 |
"""Traduce ES → EN con fallback seguro."""
|
| 59 |
if not text_es.strip():
|
|
|
|
| 81 |
system_prompt = (
|
| 82 |
"You are an expert diffusion prompt composer. Your task is to transcribe the user's concept into a "
|
| 83 |
"professional, narrative-style image prompt that strictly follows the structural cadence of high-end datasets.\n\n"
|
| 84 |
+
f"STRUCTURAL TEMPLATE (internal guide only — the labels must NEVER appear in your output): {pattern_str}\n"
|
| 85 |
f"AVAILABLE ATMOSPHERIC/STYLISTIC FRAGMENTS: {lexicon_str}\n\n"
|
| 86 |
"RULES:\n"
|
| 87 |
+
"1. Map the user concept to the template slots. Maintain natural, comma-separated clause flow.\n"
|
| 88 |
"2. Use ONLY the provided fragments to enrich atmospheric, lighting, or quality slots. DO NOT invent objects, colors, or eras.\n"
|
| 89 |
+
"3. DO NOT copy full reference phrases. DO NOT output explanations.\n"
|
| 90 |
+
"4. Output EXACTLY ONE line of plain text: no line breaks, no section labels (e.g. 'Opening/Pose:'), "
|
| 91 |
+
"no arrows (→), no numbering, no bullet points, no quotes.\n"
|
| 92 |
+
"5. HARD LIMIT: the prompt must contain between 20 and 40 words. Output ONLY the prompt text."
|
| 93 |
)
|
| 94 |
|
| 95 |
user_prompt = f"User concept to compose: {user_en}"
|
| 96 |
return system_prompt, user_prompt
|
| 97 |
|
| 98 |
def _call_llm(self, system_prompt: str, user_prompt: str) -> Optional[str]:
|
| 99 |
+
"""Llamada al LLM del modo rápido, con cadena de fallback de provider (fix R6)."""
|
| 100 |
try:
|
| 101 |
+
logger.info(f"🔄 Llamando al modo rápido (cadena 'fast' del registry)...")
|
| 102 |
+
result = call_with_fallback(
|
| 103 |
+
self.openai_client, "fast",
|
| 104 |
messages=[
|
| 105 |
{"role": "system", "content": system_prompt},
|
| 106 |
{"role": "user", "content": user_prompt}
|
| 107 |
],
|
| 108 |
+
max_tokens=160, temperature=0.1, timeout=50
|
|
|
|
|
|
|
|
|
|
| 109 |
)
|
| 110 |
+
logger.info(f"✅ LLM respondió ({len(result['text'])} chars, {result['model']})")
|
| 111 |
+
return result["text"]
|
| 112 |
+
except NodeError as e:
|
| 113 |
+
logger.error(f"🚫 Cadena del modo rápido agotada: {e}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 114 |
return None
|
| 115 |
|
| 116 |
def _validate_output(self, user_en: str, output: str) -> Tuple[bool, str]:
|
|
|
|
| 128 |
if not any(w in output_lower for w in user_tokens):
|
| 129 |
return False, "missing_user_concept"
|
| 130 |
|
| 131 |
+
# 2. Longitud razonable. El encoder CLIP de SDXL trunca a 77 tokens (~55-60
|
| 132 |
+
# palabras en inglés): hasta ahí el prompt es útil, no "demasiado largo".
|
| 133 |
+
# El system prompt pide 20-40 palabras; 60 da margen para no rechazar
|
| 134 |
+
# outputs válidos que se pasan un poco.
|
| 135 |
word_count = len(output.split())
|
| 136 |
+
if word_count > 60:
|
| 137 |
return False, f"too_long ({word_count} words)"
|
| 138 |
|
| 139 |
# 3. Seguridad (términos prohibidos)
|
|
|
|
| 144 |
|
| 145 |
return True, "valid"
|
| 146 |
|
| 147 |
+
# Etiquetas de sección del template que Qwen a veces copia en el output
|
| 148 |
+
# (p. ej. "Opening/Pose: ..." o "Subject/Core → ...")
|
| 149 |
+
_SECTION_LABEL_RE = re.compile(
|
| 150 |
+
r'\b(?:Opening|Subject|Core|Secondary|Descriptive|Atmospheric|Environment(?:al)?|Context|'
|
| 151 |
+
r'Lighting|Mood|Technical|Quality|Stylistic|Style|Medium|Frame|Pose|Detail|Clause)'
|
| 152 |
+
r'(?:\s*/\s*[A-Za-z]+)?\s*[::]\s*',
|
| 153 |
+
re.IGNORECASE
|
| 154 |
+
)
|
| 155 |
+
|
| 156 |
def _clean_output(self, text: str) -> str:
|
| 157 |
+
"""Limpieza final del prompt generado. Debe ejecutarse ANTES de validar longitud."""
|
| 158 |
+
# Cinturón extra: bloques de razonamiento de modelos híbridos (thinking)
|
| 159 |
+
text = re.sub(r'<think>.*?</think>', '', text, flags=re.DOTALL)
|
| 160 |
+
if '</think>' in text: # apertura truncada: quedarse con lo posterior al cierre
|
| 161 |
+
text = text.split('</think>')[-1]
|
| 162 |
text = text.strip().strip("\"'")
|
| 163 |
# Eliminar prefijos comunes de LLM
|
| 164 |
for prefix in ["Here is", "Final:", "Output:", "Prompt:", "Result:", "Generated:"]:
|
| 165 |
if text.startswith(prefix):
|
| 166 |
text = text.split(":", 1)[-1].strip()
|
| 167 |
+
# Colapsar saltos de línea y bullets/numeración en flujo de cláusulas
|
| 168 |
+
lines = [re.sub(r'^\s*(?:[-•*]|\d+[.)])\s*', '', ln).strip() for ln in text.splitlines()]
|
| 169 |
+
text = ', '.join(ln for ln in lines if ln)
|
| 170 |
+
# Eliminar etiquetas de sección del template y flechas
|
| 171 |
+
text = self._SECTION_LABEL_RE.sub('', text)
|
| 172 |
+
text = re.sub(r'\s*(?:→|->)\s*', ', ', text)
|
| 173 |
# Eliminar markdown/etiquetas residuales
|
| 174 |
text = re.sub(r'\*+|_+|</?[^>]+>', '', text).strip()
|
| 175 |
+
# Normalizar comas: dobles, colgantes al inicio/final, espacios
|
| 176 |
+
text = re.sub(r'\s*,\s*(?:,\s*)+', ', ', text)
|
| 177 |
+
text = re.sub(r'\s{2,}', ' ', text).strip().strip(',').strip()
|
| 178 |
# Eliminar punto final si parece frase suelta
|
| 179 |
if text.endswith('.') and text.count(',') < 3:
|
| 180 |
text = text.rstrip('.')
|
|
|
|
| 206 |
sys_msg, usr_msg = self._build_compositional_prompt(user_en, agent_data)
|
| 207 |
|
| 208 |
# 4. Llamada al LLM
|
| 209 |
+
self.metrics["total"] += 1
|
| 210 |
raw_output = self._call_llm(sys_msg, usr_msg)
|
| 211 |
+
|
| 212 |
+
fallback_reason = "sin respuesta del LLM"
|
| 213 |
if raw_output:
|
| 214 |
cleaned = self._clean_output(raw_output)
|
| 215 |
is_valid, reason = self._validate_output(user_en, cleaned)
|
| 216 |
+
|
| 217 |
if is_valid:
|
| 218 |
logger.info(f"✅ Prompt validado: {reason}")
|
| 219 |
+
self.metrics["llm_ok"] += 1
|
| 220 |
+
status = f"✅ Refinado con LLM ({self.model_name.split('/')[-1].split(':')[0]})\n{self._metrics_summary()}"
|
| 221 |
+
return cleaned, status, agent_data.get("examples_ui", [])
|
| 222 |
else:
|
| 223 |
logger.warning(f"⚠️ Output rechazado: {reason} → '{cleaned}'")
|
| 224 |
+
self.metrics["llm_rejected"] += 1
|
| 225 |
+
fallback_reason = f"LLM rechazado: {reason}"
|
| 226 |
+
else:
|
| 227 |
+
self.metrics["llm_error"] += 1
|
| 228 |
+
|
| 229 |
+
# 5. Fallback semántico (visible para el usuario, con motivo)
|
| 230 |
logger.info("🔧 Aplicando fallback semántico...")
|
| 231 |
+
self.metrics["fallback"] += 1
|
| 232 |
fallback, _ = self.agent.enhance_prompt(user_en, category)
|
| 233 |
+
status = f"⚠️ Fallback semántico ({fallback_reason})\n{self._metrics_summary()}"
|
| 234 |
+
return self._clean_output(fallback), status, agent_data.get("examples_ui", [])
|
| 235 |
|
| 236 |
|
| 237 |
class SDXLGenerator:
|
|
|
|
| 265 |
try:
|
| 266 |
refiner = LlamaRefiner()
|
| 267 |
generator = SDXLGenerator()
|
| 268 |
+
y_refiner = YRefiner(refiner)
|
| 269 |
+
check_catalog()
|
| 270 |
logger.info("✅ Todos los servicios inicializados correctamente")
|
| 271 |
except Exception as e:
|
| 272 |
logger.error(f"❌ Error crítico de inicialización: {e}", exc_info=True)
|
| 273 |
raise
|
| 274 |
|
| 275 |
+
def do_refine(prompt: str, category_es: str, mode: str, progress=gr.Progress()):
|
| 276 |
if not prompt.strip():
|
| 277 |
return "", "", "Prompt vacío."
|
| 278 |
if refiner is None:
|
| 279 |
return "", "", "❌ Servicio no disponible (revisa los logs)."
|
| 280 |
+
|
|
|
|
| 281 |
category_map = {
|
| 282 |
"Automática": "auto",
|
| 283 |
"Entidad": "entity",
|
|
|
|
| 287 |
"Texto": "text"
|
| 288 |
}
|
| 289 |
category_en = category_map.get(category_es, "auto")
|
| 290 |
+
|
| 291 |
+
if mode.startswith("🔱"):
|
| 292 |
+
refined_prompt, status_msg, examples = y_refiner.refine_y(
|
| 293 |
+
prompt, category_en, progress_cb=progress)
|
| 294 |
+
else:
|
| 295 |
+
progress(0.3, desc="🔄 Traduciendo y analizando estructura...")
|
| 296 |
+
progress(0.7, desc="✨ Composición estructural...")
|
| 297 |
+
refined_prompt, status_msg, examples = refiner.refine(prompt, category_en)
|
| 298 |
+
|
| 299 |
examples_text = "\n".join(f"{i+1}. {ex}" for i, ex in enumerate(examples)) if examples else "Sin ejemplos recuperados."
|
| 300 |
return refined_prompt, examples_text, status_msg
|
| 301 |
|
|
|
|
| 337 |
placeholder="Ej: retrato de una mujer en la edad media con un elaborado vestido"
|
| 338 |
)
|
| 339 |
category_input = gr.Dropdown(label="📂 Categoría", choices=CATEGORY_CHOICES, value="Automática")
|
| 340 |
+
mode_input = gr.Radio(
|
| 341 |
+
label="⚙️ Motor de refinamiento",
|
| 342 |
+
choices=["⚡ Rápido (1 LLM)", "🔱 Modo Y (3 LLMs)"],
|
| 343 |
+
value="⚡ Rápido (1 LLM)",
|
| 344 |
+
info="Y: dos generadores en paralelo + normalizador. Más lento y ~14× más caro, mayor calidad."
|
| 345 |
+
)
|
| 346 |
aspect_input = gr.Dropdown(label="📐 Proporción", choices=ASPECT_CHOICES, value="1:1")
|
| 347 |
|
| 348 |
refine_btn = gr.Button("🔄 Refinar Prompt", variant="secondary", size="lg")
|
|
|
|
| 352 |
refined_output = gr.Textbox(label="🇬🇧 Prompt refinado (inglés)", interactive=False, lines=2)
|
| 353 |
image_output = gr.Image(label="🖼️ Imagen generada", type="filepath", height=420)
|
| 354 |
examples_output = gr.Textbox(label="📚 Referencia estructural (dataset)", interactive=False, lines=4)
|
| 355 |
+
status_output = gr.Textbox(label="📊 Estado / Métricas", interactive=False, lines=3)
|
| 356 |
|
| 357 |
gr.HTML("""
|
| 358 |
<div style="text-align: center; padding: 15px; margin-top: 20px; border-top: 1px solid #333; color: #666; font-size: 0.9em;">
|
|
|
|
| 362 |
|
| 363 |
refine_btn.click(
|
| 364 |
fn=do_refine,
|
| 365 |
+
inputs=[prompt_input, category_input, mode_input],
|
| 366 |
outputs=[refined_output, examples_output, status_output],
|
| 367 |
show_progress=True
|
| 368 |
)
|
|
|
|
| 376 |
|
| 377 |
if __name__ == "__main__":
|
| 378 |
demo = create_interface()
|
| 379 |
+
# ssr_mode=False: evita el error "Could not get API info" del SSR de Gradio en Spaces
|
| 380 |
+
demo.launch(server_name="0.0.0.0", server_port=7860, ssr_mode=False)
|
|
@@ -1,15 +1,14 @@
|
|
|
|
|
| 1 |
gradio>=6.11.0,<7.0.0
|
| 2 |
-
Pillow>=10.
|
| 3 |
-
huggingface-hub>=0.
|
| 4 |
-
sentence-transformers>=
|
| 5 |
-
faiss-cpu>=1.9.0.post1
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
sentencepiece>=0.1.
|
| 10 |
-
spacy>=3.
|
| 11 |
-
|
| 12 |
-
|
| 13 |
spaces
|
| 14 |
-
sacremoses>=0.0.53
|
| 15 |
-
openai>=1.0.0
|
|
|
|
| 1 |
+
# Versiones verificadas con wheels para Python 3.13 (cp313/abi3 en PyPI)
|
| 2 |
gradio>=6.11.0,<7.0.0
|
| 3 |
+
Pillow>=10.4.0
|
| 4 |
+
huggingface-hub>=0.27.0
|
| 5 |
+
sentence-transformers>=3.3.0
|
| 6 |
+
faiss-cpu>=1.9.0.post1 # primer release con wheel cp313
|
| 7 |
+
torch>=2.6.0 # cp313 desde 2.5.0; 2.6+ recomendado (fix CVE torch.load)
|
| 8 |
+
transformers>=4.48.0
|
| 9 |
+
tokenizers>=0.21.0 # wheels abi3 compatibles con 3.13
|
| 10 |
+
sentencepiece>=0.2.1 # ÚNICA versión con wheel cp313 (0.2.0 compila desde source y falla)
|
| 11 |
+
spacy>=3.8.2,<4.0.0 # cp313 desde 3.8.2; 3.7.x NO soporta 3.13
|
| 12 |
+
sacremoses>=0.1.1
|
| 13 |
+
openai>=1.55.0
|
| 14 |
spaces
|
|
|
|
|
|
|
@@ -1,541 +0,0 @@
|
|
| 1 |
-
# semantic_database.py
|
| 2 |
-
import sqlite3
|
| 3 |
-
import hashlib
|
| 4 |
-
import json
|
| 5 |
-
import logging
|
| 6 |
-
import time
|
| 7 |
-
from datetime import datetime, timedelta
|
| 8 |
-
from typing import Optional, Dict, List, Any, Tuple
|
| 9 |
-
import threading
|
| 10 |
-
|
| 11 |
-
# Configurar logging
|
| 12 |
-
logging.basicConfig(level=logging.INFO)
|
| 13 |
-
logger = logging.getLogger(__name__)
|
| 14 |
-
|
| 15 |
-
class SemanticDatabase:
|
| 16 |
-
"""
|
| 17 |
-
💾 SISTEMA DE BASE DE DATOS SEMÁNTICA — ¡AHORA EN USO ACTIVO!
|
| 18 |
-
|
| 19 |
-
Gestiona:
|
| 20 |
-
- Caché de prompts de alta calidad (¡priorizados por el agente!)
|
| 21 |
-
- Memoria episódica de sesiones
|
| 22 |
-
- Estadísticas del sistema
|
| 23 |
-
- Optimización y limpieza automática
|
| 24 |
-
"""
|
| 25 |
-
|
| 26 |
-
def __init__(self, db_path: str = "semantic_cache.db"):
|
| 27 |
-
self.db_path = db_path
|
| 28 |
-
self.lock = threading.Lock()
|
| 29 |
-
self._initialize_database()
|
| 30 |
-
logger.info(f"📊 Base de datos semántica inicializada: {db_path}")
|
| 31 |
-
|
| 32 |
-
def _initialize_database(self):
|
| 33 |
-
"""🏗️ Crea las tablas necesarias si no existen"""
|
| 34 |
-
try:
|
| 35 |
-
with sqlite3.connect(self.db_path) as conn:
|
| 36 |
-
cursor = conn.cursor()
|
| 37 |
-
|
| 38 |
-
# Tabla de caché de prompts — ¡AHORA USADA ACTIVAMENTE!
|
| 39 |
-
cursor.execute("""
|
| 40 |
-
CREATE TABLE IF NOT EXISTS prompt_cache (
|
| 41 |
-
prompt_hash TEXT PRIMARY KEY,
|
| 42 |
-
original_prompt TEXT NOT NULL,
|
| 43 |
-
enhanced_prompt TEXT NOT NULL,
|
| 44 |
-
category TEXT NOT NULL,
|
| 45 |
-
similarity_score REAL NOT NULL,
|
| 46 |
-
source_field TEXT NOT NULL,
|
| 47 |
-
hit_count INTEGER DEFAULT 1,
|
| 48 |
-
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
| 49 |
-
last_accessed TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
| 50 |
-
)
|
| 51 |
-
""")
|
| 52 |
-
|
| 53 |
-
# Tabla de memoria episódica
|
| 54 |
-
cursor.execute("""
|
| 55 |
-
CREATE TABLE IF NOT EXISTS episodic_memory (
|
| 56 |
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 57 |
-
session_id TEXT NOT NULL,
|
| 58 |
-
prompt TEXT NOT NULL,
|
| 59 |
-
category TEXT NOT NULL,
|
| 60 |
-
strategy TEXT NOT NULL,
|
| 61 |
-
similarity_score REAL NOT NULL,
|
| 62 |
-
processing_time REAL NOT NULL,
|
| 63 |
-
models_used TEXT NOT NULL,
|
| 64 |
-
success BOOLEAN NOT NULL,
|
| 65 |
-
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
| 66 |
-
)
|
| 67 |
-
""")
|
| 68 |
-
|
| 69 |
-
# Tabla de estadísticas diarias
|
| 70 |
-
cursor.execute("""
|
| 71 |
-
CREATE TABLE IF NOT EXISTS daily_stats (
|
| 72 |
-
date TEXT PRIMARY KEY,
|
| 73 |
-
total_searches INTEGER DEFAULT 0,
|
| 74 |
-
cache_hits INTEGER DEFAULT 0,
|
| 75 |
-
unique_prompts INTEGER DEFAULT 0,
|
| 76 |
-
avg_processing_time REAL DEFAULT 0.0,
|
| 77 |
-
successful_enhancements INTEGER DEFAULT 0,
|
| 78 |
-
failed_enhancements INTEGER DEFAULT 0
|
| 79 |
-
)
|
| 80 |
-
""")
|
| 81 |
-
|
| 82 |
-
# Tabla de configuración del sistema
|
| 83 |
-
cursor.execute("""
|
| 84 |
-
CREATE TABLE IF NOT EXISTS system_config (
|
| 85 |
-
key TEXT PRIMARY KEY,
|
| 86 |
-
value TEXT NOT NULL,
|
| 87 |
-
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
| 88 |
-
)
|
| 89 |
-
""")
|
| 90 |
-
|
| 91 |
-
# Índices para optimización — ¡CRÍTICOS PARA PERFORMANCE!
|
| 92 |
-
cursor.execute("CREATE INDEX IF NOT EXISTS idx_prompt_hash ON prompt_cache(prompt_hash)")
|
| 93 |
-
cursor.execute("CREATE INDEX IF NOT EXISTS idx_category_score ON prompt_cache(category, similarity_score)")
|
| 94 |
-
cursor.execute("CREATE INDEX IF NOT EXISTS idx_hit_count ON prompt_cache(hit_count)")
|
| 95 |
-
cursor.execute("CREATE INDEX IF NOT EXISTS idx_session_id ON episodic_memory(session_id)")
|
| 96 |
-
cursor.execute("CREATE INDEX IF NOT EXISTS idx_timestamp ON episodic_memory(timestamp)")
|
| 97 |
-
cursor.execute("CREATE INDEX IF NOT EXISTS idx_last_accessed ON prompt_cache(last_accessed)")
|
| 98 |
-
|
| 99 |
-
conn.commit()
|
| 100 |
-
logger.info("✅ Tablas de base de datos inicializadas correctamente")
|
| 101 |
-
|
| 102 |
-
except Exception as e:
|
| 103 |
-
logger.error(f"❌ Error inicializando base de datos: {e}")
|
| 104 |
-
raise
|
| 105 |
-
|
| 106 |
-
def _get_prompt_hash(self, prompt: str) -> str:
|
| 107 |
-
"""🔐 Genera hash único para un prompt"""
|
| 108 |
-
return hashlib.md5(prompt.strip().lower().encode()).hexdigest()
|
| 109 |
-
|
| 110 |
-
def store_cache_result(self, original_prompt: str, enhanced_prompt: str,
|
| 111 |
-
category: str, similarity_score: float, source_field: str) -> bool:
|
| 112 |
-
"""💾 Almacena resultado en caché — ¡USADO POR EL AGENTE!"""
|
| 113 |
-
try:
|
| 114 |
-
prompt_hash = self._get_prompt_hash(original_prompt)
|
| 115 |
-
|
| 116 |
-
with self.lock:
|
| 117 |
-
with sqlite3.connect(self.db_path) as conn:
|
| 118 |
-
cursor = conn.cursor()
|
| 119 |
-
|
| 120 |
-
# Verificar si ya existe
|
| 121 |
-
cursor.execute(
|
| 122 |
-
"SELECT hit_count FROM prompt_cache WHERE prompt_hash = ?",
|
| 123 |
-
(prompt_hash,)
|
| 124 |
-
)
|
| 125 |
-
|
| 126 |
-
existing = cursor.fetchone()
|
| 127 |
-
|
| 128 |
-
if existing:
|
| 129 |
-
# Actualizar hit count y última vez accedido
|
| 130 |
-
cursor.execute("""
|
| 131 |
-
UPDATE prompt_cache
|
| 132 |
-
SET hit_count = hit_count + 1,
|
| 133 |
-
last_accessed = CURRENT_TIMESTAMP,
|
| 134 |
-
enhanced_prompt = ?,
|
| 135 |
-
similarity_score = ?
|
| 136 |
-
WHERE prompt_hash = ?
|
| 137 |
-
""", (enhanced_prompt, similarity_score, prompt_hash))
|
| 138 |
-
else:
|
| 139 |
-
# Insertar nuevo registro
|
| 140 |
-
cursor.execute("""
|
| 141 |
-
INSERT INTO prompt_cache
|
| 142 |
-
(prompt_hash, original_prompt, enhanced_prompt, category,
|
| 143 |
-
similarity_score, source_field)
|
| 144 |
-
VALUES (?, ?, ?, ?, ?, ?)
|
| 145 |
-
""", (prompt_hash, original_prompt, enhanced_prompt, category,
|
| 146 |
-
similarity_score, source_field))
|
| 147 |
-
|
| 148 |
-
conn.commit()
|
| 149 |
-
return True
|
| 150 |
-
|
| 151 |
-
except Exception as e:
|
| 152 |
-
logger.error(f"❌ Error almacenando en caché: {e}")
|
| 153 |
-
return False
|
| 154 |
-
|
| 155 |
-
def get_cached_result(self, prompt: str) -> Optional[Dict]:
|
| 156 |
-
"""🔍 Busca resultado en caché"""
|
| 157 |
-
try:
|
| 158 |
-
prompt_hash = self._get_prompt_hash(prompt)
|
| 159 |
-
|
| 160 |
-
with self.lock:
|
| 161 |
-
with sqlite3.connect(self.db_path) as conn:
|
| 162 |
-
cursor = conn.cursor()
|
| 163 |
-
|
| 164 |
-
cursor.execute("""
|
| 165 |
-
SELECT enhanced_prompt, category, similarity_score, source_field,
|
| 166 |
-
hit_count, created_at, last_accessed
|
| 167 |
-
FROM prompt_cache
|
| 168 |
-
WHERE prompt_hash = ?
|
| 169 |
-
""", (prompt_hash,))
|
| 170 |
-
|
| 171 |
-
result = cursor.fetchone()
|
| 172 |
-
|
| 173 |
-
if result:
|
| 174 |
-
# Actualizar hit_count y last_accessed
|
| 175 |
-
cursor.execute("""
|
| 176 |
-
UPDATE prompt_cache
|
| 177 |
-
SET hit_count = hit_count + 1,
|
| 178 |
-
last_accessed = CURRENT_TIMESTAMP
|
| 179 |
-
WHERE prompt_hash = ?
|
| 180 |
-
""", (prompt_hash,))
|
| 181 |
-
|
| 182 |
-
# Volver a leer para obtener valores actualizados
|
| 183 |
-
cursor.execute("""
|
| 184 |
-
SELECT enhanced_prompt, category, similarity_score, source_field,
|
| 185 |
-
hit_count, created_at, last_accessed
|
| 186 |
-
FROM prompt_cache
|
| 187 |
-
WHERE prompt_hash = ?
|
| 188 |
-
""", (prompt_hash,))
|
| 189 |
-
|
| 190 |
-
updated_result = cursor.fetchone()
|
| 191 |
-
conn.commit()
|
| 192 |
-
|
| 193 |
-
if updated_result:
|
| 194 |
-
return {
|
| 195 |
-
'enhanced_prompt': updated_result[0],
|
| 196 |
-
'category': updated_result[1],
|
| 197 |
-
'similarity_score': updated_result[2],
|
| 198 |
-
'source_field': updated_result[3],
|
| 199 |
-
'hit_count': updated_result[4],
|
| 200 |
-
'created_at': updated_result[5],
|
| 201 |
-
'last_accessed': updated_result[6]
|
| 202 |
-
}
|
| 203 |
-
|
| 204 |
-
return None
|
| 205 |
-
|
| 206 |
-
except Exception as e:
|
| 207 |
-
logger.error(f"❌ Error buscando en caché: {e}")
|
| 208 |
-
return None
|
| 209 |
-
|
| 210 |
-
def get_top_cached_prompts(self, prompt: str, category: str, top_k: int = 5) -> List[Dict]:
|
| 211 |
-
"""🌟 OBTIENE LOS MEJORES PROMPTS CURADOS — ¡NUEVO MÉTODO CLAVE!
|
| 212 |
-
Busca prompts de alta calidad (score > 0.8) en la misma categoría, ordenados por relevancia y uso."""
|
| 213 |
-
try:
|
| 214 |
-
with self.lock:
|
| 215 |
-
with sqlite3.connect(self.db_path) as conn:
|
| 216 |
-
cursor = conn.cursor()
|
| 217 |
-
|
| 218 |
-
cursor.execute("""
|
| 219 |
-
SELECT enhanced_prompt, category, similarity_score, source_field, hit_count
|
| 220 |
-
FROM prompt_cache
|
| 221 |
-
WHERE category = ? AND similarity_score > 0.8
|
| 222 |
-
ORDER BY hit_count DESC, similarity_score DESC
|
| 223 |
-
LIMIT ?
|
| 224 |
-
""", (category, top_k))
|
| 225 |
-
|
| 226 |
-
results = cursor.fetchall()
|
| 227 |
-
|
| 228 |
-
cached_prompts = []
|
| 229 |
-
for row in results:
|
| 230 |
-
cached_prompts.append({
|
| 231 |
-
'enhanced_prompt': row[0],
|
| 232 |
-
'category': row[1],
|
| 233 |
-
'similarity_score': row[2],
|
| 234 |
-
'source_field': row[3],
|
| 235 |
-
'hit_count': row[4]
|
| 236 |
-
})
|
| 237 |
-
|
| 238 |
-
if cached_prompts:
|
| 239 |
-
logger.info(f"🧠 Encontrados {len(cached_prompts)} prompts curados de alta calidad para categoría '{category}'")
|
| 240 |
-
return cached_prompts
|
| 241 |
-
|
| 242 |
-
except Exception as e:
|
| 243 |
-
logger.error(f"❌ Error obteniendo prompts curados: {e}")
|
| 244 |
-
return []
|
| 245 |
-
|
| 246 |
-
def store_episodic_memory(self, session_id: str, prompt: str, category: str,
|
| 247 |
-
strategy: str, similarity_score: float, processing_time: float,
|
| 248 |
-
models_used: List[str], success: bool) -> bool:
|
| 249 |
-
"""🧠 Almacena memoria episódica"""
|
| 250 |
-
try:
|
| 251 |
-
models_json = json.dumps(models_used)
|
| 252 |
-
|
| 253 |
-
with self.lock:
|
| 254 |
-
with sqlite3.connect(self.db_path) as conn:
|
| 255 |
-
cursor = conn.cursor()
|
| 256 |
-
|
| 257 |
-
cursor.execute("""
|
| 258 |
-
INSERT INTO episodic_memory
|
| 259 |
-
(session_id, prompt, category, strategy, similarity_score,
|
| 260 |
-
processing_time, models_used, success)
|
| 261 |
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
| 262 |
-
""", (session_id, prompt, category, strategy, similarity_score,
|
| 263 |
-
processing_time, models_json, success))
|
| 264 |
-
|
| 265 |
-
conn.commit()
|
| 266 |
-
return True
|
| 267 |
-
|
| 268 |
-
except Exception as e:
|
| 269 |
-
logger.error(f"❌ Error almacenando memoria episódica: {e}")
|
| 270 |
-
return False
|
| 271 |
-
|
| 272 |
-
def get_session_history(self, session_id: str, limit: int = 50) -> List[Dict]:
|
| 273 |
-
"""📚 Obtiene historial de una sesión"""
|
| 274 |
-
try:
|
| 275 |
-
with self.lock:
|
| 276 |
-
with sqlite3.connect(self.db_path) as conn:
|
| 277 |
-
cursor = conn.cursor()
|
| 278 |
-
|
| 279 |
-
cursor.execute("""
|
| 280 |
-
SELECT prompt, category, strategy, similarity_score,
|
| 281 |
-
processing_time, models_used, success, timestamp
|
| 282 |
-
FROM episodic_memory
|
| 283 |
-
WHERE session_id = ?
|
| 284 |
-
ORDER BY timestamp DESC
|
| 285 |
-
LIMIT ?
|
| 286 |
-
""", (session_id, limit))
|
| 287 |
-
|
| 288 |
-
results = cursor.fetchall()
|
| 289 |
-
|
| 290 |
-
history = []
|
| 291 |
-
for row in results:
|
| 292 |
-
history.append({
|
| 293 |
-
'prompt': row[0],
|
| 294 |
-
'category': row[1],
|
| 295 |
-
'strategy': row[2],
|
| 296 |
-
'similarity_score': row[3],
|
| 297 |
-
'processing_time': row[4],
|
| 298 |
-
'models_used': json.loads(row[5]),
|
| 299 |
-
'success': bool(row[6]),
|
| 300 |
-
'timestamp': row[7]
|
| 301 |
-
})
|
| 302 |
-
|
| 303 |
-
return history
|
| 304 |
-
|
| 305 |
-
except Exception as e:
|
| 306 |
-
logger.error(f"❌ Error obteniendo historial: {e}")
|
| 307 |
-
return []
|
| 308 |
-
|
| 309 |
-
def update_daily_stats(self, searches: int = 0, cache_hits: int = 0,
|
| 310 |
-
processing_time: float = 0.0, success: bool = True) -> bool:
|
| 311 |
-
"""📊 Actualiza estadísticas diarias"""
|
| 312 |
-
try:
|
| 313 |
-
today = datetime.now().strftime('%Y-%m-%d')
|
| 314 |
-
|
| 315 |
-
with self.lock:
|
| 316 |
-
with sqlite3.connect(self.db_path) as conn:
|
| 317 |
-
cursor = conn.cursor()
|
| 318 |
-
|
| 319 |
-
cursor.execute(
|
| 320 |
-
"SELECT total_searches, cache_hits, avg_processing_time, successful_enhancements, failed_enhancements FROM daily_stats WHERE date = ?",
|
| 321 |
-
(today,)
|
| 322 |
-
)
|
| 323 |
-
|
| 324 |
-
existing = cursor.fetchone()
|
| 325 |
-
|
| 326 |
-
if existing:
|
| 327 |
-
new_total = existing[0] + searches
|
| 328 |
-
new_cache_hits = existing[1] + cache_hits
|
| 329 |
-
new_successful = existing[3] + (1 if success else 0)
|
| 330 |
-
new_failed = existing[4] + (0 if success else 1)
|
| 331 |
-
|
| 332 |
-
if new_total > 0:
|
| 333 |
-
new_avg_time = ((existing[2] * existing[0]) + processing_time) / new_total
|
| 334 |
-
else:
|
| 335 |
-
new_avg_time = 0.0
|
| 336 |
-
|
| 337 |
-
cursor.execute("""
|
| 338 |
-
UPDATE daily_stats
|
| 339 |
-
SET total_searches = ?, cache_hits = ?, avg_processing_time = ?,
|
| 340 |
-
successful_enhancements = ?, failed_enhancements = ?
|
| 341 |
-
WHERE date = ?
|
| 342 |
-
""", (new_total, new_cache_hits, new_avg_time, new_successful, new_failed, today))
|
| 343 |
-
else:
|
| 344 |
-
cursor.execute("""
|
| 345 |
-
INSERT INTO daily_stats
|
| 346 |
-
(date, total_searches, cache_hits, avg_processing_time,
|
| 347 |
-
successful_enhancements, failed_enhancements)
|
| 348 |
-
VALUES (?, ?, ?, ?, ?, ?)
|
| 349 |
-
""", (today, searches, cache_hits, processing_time,
|
| 350 |
-
1 if success else 0, 0 if success else 1))
|
| 351 |
-
|
| 352 |
-
conn.commit()
|
| 353 |
-
return True
|
| 354 |
-
|
| 355 |
-
except Exception as e:
|
| 356 |
-
logger.error(f"❌ Error actualizando estadísticas: {e}")
|
| 357 |
-
return False
|
| 358 |
-
|
| 359 |
-
def get_system_stats(self) -> Dict[str, Any]:
|
| 360 |
-
"""📈 Obtiene estadísticas del sistema"""
|
| 361 |
-
try:
|
| 362 |
-
with self.lock:
|
| 363 |
-
with sqlite3.connect(self.db_path) as conn:
|
| 364 |
-
cursor = conn.cursor()
|
| 365 |
-
|
| 366 |
-
stats = {}
|
| 367 |
-
|
| 368 |
-
# Estadísticas de caché
|
| 369 |
-
cursor.execute("SELECT COUNT(*), AVG(hit_count), AVG(similarity_score) FROM prompt_cache")
|
| 370 |
-
cache_stats = cursor.fetchone()
|
| 371 |
-
stats['caché'] = {
|
| 372 |
-
'total_entradas': cache_stats[0] or 0,
|
| 373 |
-
'promedio_usos': round(cache_stats[1] or 0, 2),
|
| 374 |
-
'promedio_similitud': round(cache_stats[2] or 0, 3)
|
| 375 |
-
}
|
| 376 |
-
|
| 377 |
-
# Estadísticas de memoria episódica
|
| 378 |
-
cursor.execute("""
|
| 379 |
-
SELECT COUNT(*), COUNT(DISTINCT session_id),
|
| 380 |
-
AVG(similarity_score), AVG(processing_time),
|
| 381 |
-
SUM(CASE WHEN success = 1 THEN 1 ELSE 0 END) * 100.0 / COUNT(*)
|
| 382 |
-
FROM episodic_memory
|
| 383 |
-
""")
|
| 384 |
-
memory_stats = cursor.fetchone()
|
| 385 |
-
stats['episódica'] = {
|
| 386 |
-
'búsquedas_totales': memory_stats[0] or 0,
|
| 387 |
-
'sesiones_únicas': memory_stats[1] or 0,
|
| 388 |
-
'promedio_similitud': round(memory_stats[2] or 0, 3),
|
| 389 |
-
'tiempo_promedio': round(memory_stats[3] or 0, 2),
|
| 390 |
-
'tasa_éxito': round(memory_stats[4] or 0, 1)
|
| 391 |
-
}
|
| 392 |
-
|
| 393 |
-
# Estadísticas de últimos 7 días
|
| 394 |
-
cursor.execute("""
|
| 395 |
-
SELECT SUM(total_searches), SUM(cache_hits), AVG(avg_processing_time)
|
| 396 |
-
FROM daily_stats
|
| 397 |
-
WHERE date >= date('now', '-7 days')
|
| 398 |
-
""")
|
| 399 |
-
weekly_stats = cursor.fetchone()
|
| 400 |
-
stats['semanal'] = {
|
| 401 |
-
'búsquedas_totales': weekly_stats[0] or 0,
|
| 402 |
-
'aciertos_caché': weekly_stats[1] or 0,
|
| 403 |
-
'tiempo_promedio': round(weekly_stats[2] or 0, 2)
|
| 404 |
-
}
|
| 405 |
-
|
| 406 |
-
# Información de la base de datos
|
| 407 |
-
cursor.execute("SELECT COUNT(*) FROM sqlite_master WHERE type='table'")
|
| 408 |
-
table_count = cursor.fetchone()[0]
|
| 409 |
-
|
| 410 |
-
stats['base_de_datos'] = {
|
| 411 |
-
'tablas': table_count,
|
| 412 |
-
'tamaño_mb': self._get_db_size_mb(),
|
| 413 |
-
'última_limpieza': self._get_config_value('last_cleanup'),
|
| 414 |
-
'versión': '2.0'
|
| 415 |
-
}
|
| 416 |
-
|
| 417 |
-
return stats
|
| 418 |
-
|
| 419 |
-
except Exception as e:
|
| 420 |
-
logger.error(f"❌ Error obteniendo estadísticas: {e}")
|
| 421 |
-
return {}
|
| 422 |
-
|
| 423 |
-
def cleanup_cache(self, max_age_days: int = 30, max_entries: int = 1000) -> Tuple[int, int]:
|
| 424 |
-
"""🧹 Limpieza de caché antiguo"""
|
| 425 |
-
try:
|
| 426 |
-
with self.lock:
|
| 427 |
-
with sqlite3.connect(self.db_path) as conn:
|
| 428 |
-
cursor = conn.cursor()
|
| 429 |
-
|
| 430 |
-
# Eliminar entradas muy antiguas
|
| 431 |
-
cutoff_date = (datetime.now() - timedelta(days=max_age_days)).isoformat()
|
| 432 |
-
cursor.execute(
|
| 433 |
-
"DELETE FROM prompt_cache WHERE last_accessed < ?",
|
| 434 |
-
(cutoff_date,)
|
| 435 |
-
)
|
| 436 |
-
old_deleted = cursor.rowcount
|
| 437 |
-
|
| 438 |
-
# Eliminar entradas menos usadas si hay demasiadas
|
| 439 |
-
cursor.execute("SELECT COUNT(*) FROM prompt_cache")
|
| 440 |
-
total_count = cursor.fetchone()[0]
|
| 441 |
-
|
| 442 |
-
excess_deleted = 0
|
| 443 |
-
if total_count > max_entries:
|
| 444 |
-
excess_count = total_count - max_entries
|
| 445 |
-
cursor.execute("""
|
| 446 |
-
DELETE FROM prompt_cache
|
| 447 |
-
WHERE prompt_hash IN (
|
| 448 |
-
SELECT prompt_hash FROM prompt_cache
|
| 449 |
-
ORDER BY hit_count ASC, last_accessed ASC
|
| 450 |
-
LIMIT ?
|
| 451 |
-
)
|
| 452 |
-
""", (excess_count,))
|
| 453 |
-
excess_deleted = cursor.rowcount
|
| 454 |
-
|
| 455 |
-
# Limpiar memoria episódica antigua (más de 90 días)
|
| 456 |
-
old_memory_cutoff = (datetime.now() - timedelta(days=90)).isoformat()
|
| 457 |
-
cursor.execute(
|
| 458 |
-
"DELETE FROM episodic_memory WHERE timestamp < ?",
|
| 459 |
-
(old_memory_cutoff,)
|
| 460 |
-
)
|
| 461 |
-
|
| 462 |
-
# Actualizar configuración
|
| 463 |
-
self._set_config_value('last_cleanup', datetime.now().isoformat())
|
| 464 |
-
|
| 465 |
-
conn.commit()
|
| 466 |
-
|
| 467 |
-
total_deleted = old_deleted + excess_deleted
|
| 468 |
-
logger.info(f"🧹 Limpieza completada: {total_deleted} entradas eliminadas")
|
| 469 |
-
|
| 470 |
-
return old_deleted, excess_deleted
|
| 471 |
-
|
| 472 |
-
except Exception as e:
|
| 473 |
-
logger.error(f"❌ Error en limpieza: {e}")
|
| 474 |
-
return 0, 0
|
| 475 |
-
|
| 476 |
-
def optimize_databases(self) -> bool:
|
| 477 |
-
"""⚡ Optimiza la base de datos"""
|
| 478 |
-
try:
|
| 479 |
-
with self.lock:
|
| 480 |
-
with sqlite3.connect(self.db_path) as conn:
|
| 481 |
-
cursor = conn.cursor()
|
| 482 |
-
|
| 483 |
-
cursor.execute("VACUUM")
|
| 484 |
-
cursor.execute("ANALYZE")
|
| 485 |
-
|
| 486 |
-
conn.commit()
|
| 487 |
-
|
| 488 |
-
logger.info("⚡ Base de datos optimizada")
|
| 489 |
-
return True
|
| 490 |
-
|
| 491 |
-
except Exception as e:
|
| 492 |
-
logger.error(f"❌ Error optimizando base de datos: {e}")
|
| 493 |
-
return False
|
| 494 |
-
|
| 495 |
-
def _get_db_size_mb(self) -> float:
|
| 496 |
-
"""📏 Obtiene tamaño de la base de datos en MB"""
|
| 497 |
-
try:
|
| 498 |
-
import os
|
| 499 |
-
size_bytes = os.path.getsize(self.db_path)
|
| 500 |
-
return round(size_bytes / (1024 * 1024), 2)
|
| 501 |
-
except:
|
| 502 |
-
return 0.0
|
| 503 |
-
|
| 504 |
-
def _get_config_value(self, key: str) -> Optional[str]:
|
| 505 |
-
"""⚙️ Obtiene valor de configuración"""
|
| 506 |
-
try:
|
| 507 |
-
with sqlite3.connect(self.db_path) as conn:
|
| 508 |
-
cursor = conn.cursor()
|
| 509 |
-
cursor.execute("SELECT value FROM system_config WHERE key = ?", (key,))
|
| 510 |
-
result = cursor.fetchone()
|
| 511 |
-
return result[0] if result else None
|
| 512 |
-
except:
|
| 513 |
-
return None
|
| 514 |
-
|
| 515 |
-
def _set_config_value(self, key: str, value: str) -> bool:
|
| 516 |
-
"""⚙️ Establece valor de configuración"""
|
| 517 |
-
try:
|
| 518 |
-
with sqlite3.connect(self.db_path) as conn:
|
| 519 |
-
cursor = conn.cursor()
|
| 520 |
-
cursor.execute("""
|
| 521 |
-
INSERT OR REPLACE INTO system_config (key, value)
|
| 522 |
-
VALUES (?, ?)
|
| 523 |
-
""", (key, value))
|
| 524 |
-
conn.commit()
|
| 525 |
-
return True
|
| 526 |
-
except:
|
| 527 |
-
return False
|
| 528 |
-
|
| 529 |
-
def get_cache_size(self) -> int:
|
| 530 |
-
"""📊 Obtiene número de entradas en caché"""
|
| 531 |
-
try:
|
| 532 |
-
with sqlite3.connect(self.db_path) as conn:
|
| 533 |
-
cursor = conn.cursor()
|
| 534 |
-
cursor.execute("SELECT COUNT(*) FROM prompt_cache")
|
| 535 |
-
return cursor.fetchone()[0]
|
| 536 |
-
except:
|
| 537 |
-
return 0
|
| 538 |
-
|
| 539 |
-
def close(self):
|
| 540 |
-
"""🔒 Cierra conexiones (cleanup)"""
|
| 541 |
-
logger.info("📊 Base de datos cerrada correctamente")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -0,0 +1,353 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# y_refiner.py — PromptCraft: Arquitectura Y (tri-LLM)
|
| 2 |
+
# α (canónico) y γ (concreto) generan en paralelo bajo restricciones heterogéneas;
|
| 3 |
+
# N (normalizador) integra ambos bajo contrato estricto (1 línea, ≤60 palabras).
|
| 4 |
+
import logging
|
| 5 |
+
import re
|
| 6 |
+
import time
|
| 7 |
+
from collections import deque
|
| 8 |
+
from concurrent.futures import ThreadPoolExecutor
|
| 9 |
+
from typing import Callable, Dict, List, Optional, Tuple
|
| 10 |
+
|
| 11 |
+
logger = logging.getLogger(__name__)
|
| 12 |
+
|
| 13 |
+
# ── Registry de modelos ─────────────────────────────────────────────────────
|
| 14 |
+
# Por rol: cadena [primario, fallback1, ...]. pricing = USD por 1M tokens (in, out).
|
| 15 |
+
# extra: parámetros extra del body; reasoning_effort="none" desactiva el modo
|
| 16 |
+
# thinking de los híbridos 2026 (GLM-5.2, Kimi-K2.6, DeepSeek-V4, Qwen3.5) — sin
|
| 17 |
+
# él, el razonamiento inline consume todo el max_tokens (verificado empíricamente).
|
| 18 |
+
MODEL_REGISTRY: Dict[str, List[Dict]] = {
|
| 19 |
+
"alpha": [
|
| 20 |
+
{"model": "zai-org/GLM-5.2:fireworks-ai", "pricing": (1.40, 4.40), "extra": {"reasoning_effort": "none"}},
|
| 21 |
+
{"model": "zai-org/GLM-5.1:together", "pricing": (1.40, 4.40), "extra": {"reasoning_effort": "none"}},
|
| 22 |
+
{"model": "zai-org/GLM-4.7:deepinfra", "pricing": (0.40, 1.75), "extra": {"reasoning_effort": "none"}},
|
| 23 |
+
],
|
| 24 |
+
"gamma": [
|
| 25 |
+
{"model": "moonshotai/Kimi-K2.6:together", "pricing": (1.20, 4.50), "extra": {"reasoning_effort": "none"}},
|
| 26 |
+
{"model": "moonshotai/Kimi-K2.6:fireworks-ai", "pricing": (0.95, 4.00), "extra": {"reasoning_effort": "none"}},
|
| 27 |
+
{"model": "meta-llama/Llama-3.3-70B-Instruct:groq", "pricing": (0.59, 0.79), "extra": {}},
|
| 28 |
+
{"model": "moonshotai/Kimi-K2.5:fireworks-ai", "pricing": (0.60, 3.00), "extra": {"reasoning_effort": "none"}},
|
| 29 |
+
],
|
| 30 |
+
"normalizer": [
|
| 31 |
+
{"model": "deepseek-ai/DeepSeek-V4-Flash:fireworks-ai", "pricing": (0.14, 0.28), "extra": {"reasoning_effort": "none"}},
|
| 32 |
+
{"model": "Qwen/Qwen3.5-9B:deepinfra", "pricing": (0.10, 0.15), "extra": {"reasoning_effort": "none"}},
|
| 33 |
+
{"model": "Qwen/Qwen2.5-7B-Instruct:together", "pricing": (0.30, 0.30), "extra": {}},
|
| 34 |
+
],
|
| 35 |
+
# Cadena del modo rápido (usada por LlamaRefiner vía este registry — fix R6)
|
| 36 |
+
"fast": [
|
| 37 |
+
{"model": "Qwen/Qwen2.5-7B-Instruct:together", "pricing": (0.30, 0.30), "extra": {}},
|
| 38 |
+
{"model": "Qwen/Qwen3.5-9B:deepinfra", "pricing": (0.10, 0.15), "extra": {"reasoning_effort": "none"}},
|
| 39 |
+
],
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
TIMEOUTS = {"alpha": 30, "gamma": 30, "normalizer": 20}
|
| 43 |
+
GLOBAL_BUDGET_S = 75
|
| 44 |
+
MAX_TOKENS = {"alpha": 200, "gamma": 200, "normalizer": 120}
|
| 45 |
+
|
| 46 |
+
# ── Prompts de nodo (definidos en el brief de implementación — no editar) ───
|
| 47 |
+
NODE_PROMPTS = {
|
| 48 |
+
"alpha": """You write prompts for the SDXL image generation model.
|
| 49 |
+
|
| 50 |
+
You will receive: a visual CONCEPT, a structural PATTERN, and LEXICON fragments taken from a professional prompt dataset.
|
| 51 |
+
|
| 52 |
+
Write ONE prompt for the concept. Pursue the canonical cluster: use the most familiar, most professional SDXL vocabulary for this kind of image. The PATTERN tells you which aspects to cover (subject, detail, environment, lighting, technical quality, medium) — it is an internal guide only, its labels must NEVER appear in your output. Weave in LEXICON fragments where they genuinely fit; ignore the ones that don't.
|
| 53 |
+
|
| 54 |
+
Output: the prompt text only. One single line. No labels, no arrows, no numbering, no quotes, no explanations. 40-70 words.""",
|
| 55 |
+
|
| 56 |
+
"gamma": """You write prompts for the SDXL image generation model.
|
| 57 |
+
|
| 58 |
+
You will receive a visual CONCEPT. Write ONE prompt for it with one absolute rule: NO quality tags of any kind. Forbidden: 4k, 8k, hdr, masterpiece, best quality, highly detailed, ultra-realistic, award-winning, trending on artstation, professional, stunning, breathtaking, and every synonym of these.
|
| 59 |
+
|
| 60 |
+
Build the image with concrete matter only: physical objects and their materials, textures, specific colors, the exact quality and direction of light, spatial composition, atmosphere as physical fact (dust, haze, wet asphalt), camera position if it matters. Every word must describe something visible.
|
| 61 |
+
|
| 62 |
+
Output: the prompt text only. One single line. No labels, no arrows, no numbering, no quotes, no explanations. 40-70 words.""",
|
| 63 |
+
|
| 64 |
+
"normalizer": """You will receive a visual CONCEPT and two prompts below, A and B. They are two different attempts at an SDXL prompt for that concept. You did not see how they were made.
|
| 65 |
+
|
| 66 |
+
Your task: write the final SDXL prompt. You have full creative liberty — merge them, cut them, rewrite from scratch if you believe the result is better. Your only obligation: the final prompt depicts the CONCEPT, unmistakably.
|
| 67 |
+
|
| 68 |
+
ABSOLUTE PROHIBITIONS:
|
| 69 |
+
1. One single line. No line breaks anywhere in your output.
|
| 70 |
+
2. No labels, no section names, no arrows, no numbering, no bullet points, no quotation marks around the prompt.
|
| 71 |
+
3. No preamble and no commentary. Do not write "Here is", do not explain choices. Your first word is the first word of the prompt.
|
| 72 |
+
4. Maximum 60 words. This is a hard ceiling — the image model truncates beyond it, so every word past 60 is discarded.
|
| 73 |
+
5. At most TWO quality tags total (words like "detailed", "8k", "masterpiece"). Concrete visual matter always wins over quality tags. If in doubt, use zero.
|
| 74 |
+
6. Do not copy either source verbatim. The final prompt is yours.""",
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
class NodeError(Exception):
|
| 79 |
+
"""Fallo de un nodo tras agotar su cadena de fallback."""
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def check_catalog() -> None:
|
| 83 |
+
"""Mitigación R4: al arrancar, 1 GET al catálogo del router y log de qué
|
| 84 |
+
primarios del registry siguen servidos (no fatal si el GET falla)."""
|
| 85 |
+
import json
|
| 86 |
+
import urllib.request
|
| 87 |
+
try:
|
| 88 |
+
with urllib.request.urlopen("https://router.huggingface.co/v1/models", timeout=10) as r:
|
| 89 |
+
catalog = json.loads(r.read())
|
| 90 |
+
served = {}
|
| 91 |
+
for m in catalog.get("data", []):
|
| 92 |
+
served[m["id"]] = {p["provider"] for p in m.get("providers", [])
|
| 93 |
+
if p.get("status") == "live"}
|
| 94 |
+
for role, chain in MODEL_REGISTRY.items():
|
| 95 |
+
primary = chain[0]["model"]
|
| 96 |
+
model_id, _, provider = primary.partition(":")
|
| 97 |
+
live = provider in served.get(model_id, set()) if provider else model_id in served
|
| 98 |
+
icon = "✅" if live else "🚨"
|
| 99 |
+
logger.info(f"{icon} Catálogo router — {role}: {primary} "
|
| 100 |
+
f"{'live' if live else 'NO SERVIDO (usará fallback)'}")
|
| 101 |
+
except Exception as e:
|
| 102 |
+
logger.warning(f"⚠️ No se pudo verificar el catálogo del router: {e}")
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
# Circuit breaker de sesión: entradas con ≥3 fallos consecutivos se saltan
|
| 106 |
+
# (p.ej. un provider que devuelve siempre content vacío por no honrar
|
| 107 |
+
# reasoning_effort). Se rearma si el modelo vuelve a responder.
|
| 108 |
+
_BREAKER_THRESHOLD = 3
|
| 109 |
+
_consecutive_failures: Dict[str, int] = {}
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def call_with_fallback(client, role: str, messages: List[Dict], max_tokens: int,
|
| 113 |
+
temperature: float, timeout: int) -> Dict:
|
| 114 |
+
"""Recorre la cadena de fallback del rol. 1 retry con backoff solo en
|
| 115 |
+
transitorios (429/5xx/timeout); los fallos deterministas (4xx, respuesta
|
| 116 |
+
vacía) saltan directo al siguiente modelo. Devuelve dict con
|
| 117 |
+
text/model/latency_s/cost_usd. Lanza NodeError si toda la cadena falla."""
|
| 118 |
+
chain = MODEL_REGISTRY[role]
|
| 119 |
+
last_err = None
|
| 120 |
+
for entry in chain:
|
| 121 |
+
model = entry["model"]
|
| 122 |
+
if _consecutive_failures.get(model, 0) >= _BREAKER_THRESHOLD:
|
| 123 |
+
logger.info(f"⏭️ [{role}] {model} saltado (circuit breaker abierto)")
|
| 124 |
+
continue
|
| 125 |
+
extra = dict(entry.get("extra", {}))
|
| 126 |
+
for attempt in (1, 2):
|
| 127 |
+
t0 = time.time()
|
| 128 |
+
try:
|
| 129 |
+
resp = client.chat.completions.create(
|
| 130 |
+
model=model, messages=messages, max_tokens=max_tokens,
|
| 131 |
+
temperature=temperature, timeout=timeout,
|
| 132 |
+
extra_body=extra if extra else None,
|
| 133 |
+
)
|
| 134 |
+
text = (resp.choices[0].message.content or "").strip()
|
| 135 |
+
if not text:
|
| 136 |
+
# Determinista (p.ej. thinking no desactivable se come los
|
| 137 |
+
# tokens): no reintentar este modelo
|
| 138 |
+
raise _EmptyResponse("respuesta vacía")
|
| 139 |
+
_consecutive_failures[model] = 0
|
| 140 |
+
usage = getattr(resp, "usage", None)
|
| 141 |
+
p_in, p_out = entry["pricing"]
|
| 142 |
+
cost = 0.0
|
| 143 |
+
if usage:
|
| 144 |
+
cost = (usage.prompt_tokens * p_in + usage.completion_tokens * p_out) / 1e6
|
| 145 |
+
return {"text": text, "model": model,
|
| 146 |
+
"latency_s": round(time.time() - t0, 2), "cost_usd": cost}
|
| 147 |
+
except Exception as e:
|
| 148 |
+
last_err = e
|
| 149 |
+
status = getattr(e, "status_code", None)
|
| 150 |
+
err_str = str(e)
|
| 151 |
+
logger.warning(f"⚠️ [{role}] {model} intento {attempt}: "
|
| 152 |
+
f"HTTP {status} {err_str[:120]}")
|
| 153 |
+
_consecutive_failures[model] = _consecutive_failures.get(model, 0) + 1
|
| 154 |
+
# El provider rechaza los parámetros extra → reintentar sin ellos
|
| 155 |
+
if status == 400 and extra and "extra inputs" in err_str.lower():
|
| 156 |
+
extra = {}
|
| 157 |
+
continue
|
| 158 |
+
if isinstance(e, _EmptyResponse):
|
| 159 |
+
break # determinista → siguiente modelo
|
| 160 |
+
# Transitorios: 1 retry con backoff sobre el mismo modelo
|
| 161 |
+
if attempt == 1 and (status in (429, 500, 502, 503, 504) or status is None):
|
| 162 |
+
time.sleep(1.5)
|
| 163 |
+
continue
|
| 164 |
+
break # 4xx u otro persistente → siguiente modelo de la cadena
|
| 165 |
+
raise NodeError(f"{role}: cadena agotada ({type(last_err).__name__}: {str(last_err)[:100]})")
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
class _EmptyResponse(RuntimeError):
|
| 169 |
+
pass
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
class YRefiner:
|
| 173 |
+
"""Orquestador de la arquitectura Y. Compone un LlamaRefiner existente y
|
| 174 |
+
reutiliza su traducción, agente FAISS, limpieza y validación de contrato."""
|
| 175 |
+
|
| 176 |
+
def __init__(self, base):
|
| 177 |
+
self.base = base # LlamaRefiner
|
| 178 |
+
self.client = base.openai_client
|
| 179 |
+
self.records = deque(maxlen=200) # telemetría por refinamiento
|
| 180 |
+
self.session = {"y_total": 0, "n_ok_first": 0, "n_ok_retry": 0,
|
| 181 |
+
"alpha_fail": 0, "gamma_fail": 0, "degraded_fast": 0,
|
| 182 |
+
"fallback_sem": 0, "cost_total_usd": 0.0}
|
| 183 |
+
logger.info("🔱 YRefiner inicializado (α+γ paralelos → N)")
|
| 184 |
+
|
| 185 |
+
# ── helpers ──
|
| 186 |
+
def _alpha_messages(self, user_en: str, agent_data: Dict) -> List[Dict]:
|
| 187 |
+
pattern = " → ".join(agent_data["pattern"])
|
| 188 |
+
lexicon = " | ".join(agent_data["lexicon"][:6])
|
| 189 |
+
return [{"role": "system", "content": NODE_PROMPTS["alpha"]},
|
| 190 |
+
{"role": "user", "content": f"CONCEPT: {user_en}\nPATTERN: {pattern}\nLEXICON: {lexicon}"}]
|
| 191 |
+
|
| 192 |
+
def _gamma_messages(self, user_en: str) -> List[Dict]:
|
| 193 |
+
# γ trabaja a ciegas del dataset: solo el concepto
|
| 194 |
+
return [{"role": "system", "content": NODE_PROMPTS["gamma"]},
|
| 195 |
+
{"role": "user", "content": f"CONCEPT: {user_en}"}]
|
| 196 |
+
|
| 197 |
+
def _normalizer_messages(self, user_en: str, out_a: str, out_b: str) -> List[Dict]:
|
| 198 |
+
return [{"role": "system", "content": NODE_PROMPTS["normalizer"]},
|
| 199 |
+
{"role": "user", "content": f"CONCEPT: {user_en}\n\nSOURCE A:\n{out_a}\n\nSOURCE B:\n{out_b}"}]
|
| 200 |
+
|
| 201 |
+
def _check_contract(self, user_en: str, raw: str) -> Tuple[str, bool, str]:
|
| 202 |
+
"""Limpia y verifica el contrato de N: 1 línea, ≤60 palabras, concepto presente."""
|
| 203 |
+
cleaned = self.base._clean_output(raw)
|
| 204 |
+
ok, reason = self.base._validate_output(user_en, cleaned)
|
| 205 |
+
return cleaned, ok, reason
|
| 206 |
+
|
| 207 |
+
def _session_summary(self) -> str:
|
| 208 |
+
s = self.session
|
| 209 |
+
if s["y_total"] == 0:
|
| 210 |
+
return ""
|
| 211 |
+
contract_ok = s["n_ok_first"] + s["n_ok_retry"]
|
| 212 |
+
rate = 100.0 * contract_ok / s["y_total"]
|
| 213 |
+
return (f"Y: {s['y_total']} | contrato N: {rate:.0f}% "
|
| 214 |
+
f"(1er intento: {s['n_ok_first']}, reintento: {s['n_ok_retry']}) | "
|
| 215 |
+
f"degradados: {s['degraded_fast']} | fallback: {s['fallback_sem']} | "
|
| 216 |
+
f"coste sesión: ${s['cost_total_usd']:.4f}")
|
| 217 |
+
|
| 218 |
+
# ── pipeline principal ──
|
| 219 |
+
def refine_y(self, prompt_es: str, category: str = "auto",
|
| 220 |
+
progress_cb: Optional[Callable] = None) -> Tuple[str, str, List[str]]:
|
| 221 |
+
t_start = time.time()
|
| 222 |
+
self.session["y_total"] += 1
|
| 223 |
+
rec = {"mode": "y", "lat": {}, "failed_node": None, "final_step": None, "cost_usd": 0.0}
|
| 224 |
+
|
| 225 |
+
def report(frac, desc):
|
| 226 |
+
if progress_cb:
|
| 227 |
+
progress_cb(frac, desc=desc)
|
| 228 |
+
|
| 229 |
+
def elapsed():
|
| 230 |
+
return time.time() - t_start
|
| 231 |
+
|
| 232 |
+
# 1. Traducción + recuperación (reutilizadas del pipeline base)
|
| 233 |
+
report(0.10, "🌍 Traduciendo...")
|
| 234 |
+
t0 = time.time()
|
| 235 |
+
user_en = self.base._translate_to_english(prompt_es)
|
| 236 |
+
rec["lat"]["traduccion"] = round(time.time() - t0, 2)
|
| 237 |
+
if not user_en:
|
| 238 |
+
return "", "❌ Entrada vacía o traducción fallida.", []
|
| 239 |
+
|
| 240 |
+
report(0.20, "📐 Recuperación semántica (FAISS + rerank + MMR)...")
|
| 241 |
+
t0 = time.time()
|
| 242 |
+
try:
|
| 243 |
+
agent_data = self.base.agent.extract_pattern_and_lexicon(user_en, category=category, k=6)
|
| 244 |
+
except Exception as e:
|
| 245 |
+
logger.error(f"❌ Agente: {e}")
|
| 246 |
+
agent_data = self.base.agent._default_response()
|
| 247 |
+
rec["lat"]["agente"] = round(time.time() - t0, 2)
|
| 248 |
+
examples_ui = agent_data.get("examples_ui", [])
|
| 249 |
+
|
| 250 |
+
# 2. α + γ en paralelo
|
| 251 |
+
report(0.35, "🔱 α (GLM) + γ (Kimi) generando en paralelo...")
|
| 252 |
+
out_a = out_g = None
|
| 253 |
+
with ThreadPoolExecutor(max_workers=2) as pool:
|
| 254 |
+
fut_a = pool.submit(call_with_fallback, self.client, "alpha",
|
| 255 |
+
self._alpha_messages(user_en, agent_data),
|
| 256 |
+
MAX_TOKENS["alpha"], 0.7, TIMEOUTS["alpha"])
|
| 257 |
+
fut_g = pool.submit(call_with_fallback, self.client, "gamma",
|
| 258 |
+
self._gamma_messages(user_en),
|
| 259 |
+
MAX_TOKENS["gamma"], 0.7, TIMEOUTS["gamma"])
|
| 260 |
+
try:
|
| 261 |
+
out_a = fut_a.result()
|
| 262 |
+
rec["lat"]["alpha"] = out_a["latency_s"]
|
| 263 |
+
rec["cost_usd"] += out_a["cost_usd"]
|
| 264 |
+
except Exception as e:
|
| 265 |
+
logger.warning(f"⚠️ α caído: {e}")
|
| 266 |
+
self.session["alpha_fail"] += 1
|
| 267 |
+
rec["failed_node"] = "alpha"
|
| 268 |
+
try:
|
| 269 |
+
out_g = fut_g.result()
|
| 270 |
+
rec["lat"]["gamma"] = out_g["latency_s"]
|
| 271 |
+
rec["cost_usd"] += out_g["cost_usd"]
|
| 272 |
+
except Exception as e:
|
| 273 |
+
logger.warning(f"⚠️ γ caído: {e}")
|
| 274 |
+
self.session["gamma_fail"] += 1
|
| 275 |
+
rec["failed_node"] = "gamma" if not rec["failed_node"] else "alpha+gamma"
|
| 276 |
+
|
| 277 |
+
# Ambos paralelos caídos → escalón 3 directamente
|
| 278 |
+
if not out_a and not out_g:
|
| 279 |
+
return self._degrade_fast(prompt_es, category, rec, examples_ui, elapsed)
|
| 280 |
+
|
| 281 |
+
# Degradación parcial: el superviviente ocupa ambos slots de N
|
| 282 |
+
# (la prohibición 6 de N fuerza reescritura, no copia)
|
| 283 |
+
src_a = out_a["text"] if out_a else out_g["text"]
|
| 284 |
+
src_b = out_g["text"] if out_g else out_a["text"]
|
| 285 |
+
|
| 286 |
+
# 3. N bajo contrato (1 intento + 1 reintento a temp 0.0)
|
| 287 |
+
for n_attempt, temp in ((1, 0.3), (2, 0.0)):
|
| 288 |
+
if elapsed() > GLOBAL_BUDGET_S:
|
| 289 |
+
logger.warning("⏱️ Presupuesto global agotado → fallback semántico")
|
| 290 |
+
return self._fallback_sem(user_en, category, rec, examples_ui)
|
| 291 |
+
report(0.75, f"⚖️ N (DeepSeek) normalizando (intento {n_attempt})...")
|
| 292 |
+
try:
|
| 293 |
+
out_n = call_with_fallback(self.client, "normalizer",
|
| 294 |
+
self._normalizer_messages(user_en, src_a, src_b),
|
| 295 |
+
MAX_TOKENS["normalizer"], temp, TIMEOUTS["normalizer"])
|
| 296 |
+
except NodeError as e:
|
| 297 |
+
logger.warning(f"⚠️ N caído: {e}")
|
| 298 |
+
rec["failed_node"] = (rec["failed_node"] or "") + "+N" if rec["failed_node"] else "N"
|
| 299 |
+
break
|
| 300 |
+
rec["lat"][f"N{n_attempt}"] = out_n["latency_s"]
|
| 301 |
+
rec["cost_usd"] += out_n["cost_usd"]
|
| 302 |
+
cleaned, ok, reason = self._check_contract(user_en, out_n["text"])
|
| 303 |
+
if ok:
|
| 304 |
+
key = "n_ok_first" if n_attempt == 1 else "n_ok_retry"
|
| 305 |
+
self.session[key] += 1
|
| 306 |
+
rec["final_step"] = "y" if n_attempt == 1 else "y_retry"
|
| 307 |
+
self._commit(rec)
|
| 308 |
+
lat_str = self._lat_str(rec)
|
| 309 |
+
partial = "" if (out_a and out_g) else f" (parcial: {'α' if not out_a else 'γ'} caído)"
|
| 310 |
+
status = (f"🔱 Y completo{partial} — {out_n['model'].split('/')[-1].split(':')[0]}"
|
| 311 |
+
f"{' · reintento N' if n_attempt == 2 else ''}\n"
|
| 312 |
+
f"{lat_str}\n{self._session_summary()}")
|
| 313 |
+
return cleaned, status, examples_ui
|
| 314 |
+
logger.warning(f"⚠️ N intento {n_attempt} incumple contrato: {reason} → '{cleaned[:80]}'")
|
| 315 |
+
|
| 316 |
+
# 4. N incumplió dos veces (o cayó) → escalón 3: modo rápido
|
| 317 |
+
return self._degrade_fast(prompt_es, category, rec, examples_ui, elapsed)
|
| 318 |
+
|
| 319 |
+
# ── escalones de degradación ──
|
| 320 |
+
def _degrade_fast(self, prompt_es, category, rec, examples_ui, elapsed):
|
| 321 |
+
if elapsed() > GLOBAL_BUDGET_S:
|
| 322 |
+
user_en = self.base._translate_to_english(prompt_es)
|
| 323 |
+
return self._fallback_sem(user_en, category, rec, examples_ui)
|
| 324 |
+
logger.info("⬇️ Degradando a modo rápido...")
|
| 325 |
+
self.session["degraded_fast"] += 1
|
| 326 |
+
rec["final_step"] = "degraded_fast"
|
| 327 |
+
t0 = time.time()
|
| 328 |
+
result, base_status, examples = self.base.refine(prompt_es, category)
|
| 329 |
+
rec["lat"]["fast"] = round(time.time() - t0, 2)
|
| 330 |
+
self._commit(rec)
|
| 331 |
+
status = (f"⚡ Y degradado a modo rápido (nodo caído: {rec['failed_node'] or 'contrato N'})\n"
|
| 332 |
+
f"{base_status.splitlines()[0]}\n{self._session_summary()}")
|
| 333 |
+
return result, status, examples or examples_ui
|
| 334 |
+
|
| 335 |
+
def _fallback_sem(self, user_en, category, rec, examples_ui):
|
| 336 |
+
self.session["fallback_sem"] += 1
|
| 337 |
+
rec["final_step"] = "fallback_sem"
|
| 338 |
+
self._commit(rec)
|
| 339 |
+
fallback, _ = self.base.agent.enhance_prompt(user_en, category)
|
| 340 |
+
status = (f"⚠️ Fallback semántico (presupuesto {GLOBAL_BUDGET_S}s agotado o cadena caída)\n"
|
| 341 |
+
f"{self._session_summary()}")
|
| 342 |
+
return self.base._clean_output(fallback), status, examples_ui
|
| 343 |
+
|
| 344 |
+
def _commit(self, rec):
|
| 345 |
+
rec["total_s"] = round(sum(v for v in rec["lat"].values()), 2)
|
| 346 |
+
self.session["cost_total_usd"] += rec["cost_usd"]
|
| 347 |
+
self.records.append(rec)
|
| 348 |
+
logger.info(f"📊 Telemetría Y: {rec}")
|
| 349 |
+
|
| 350 |
+
@staticmethod
|
| 351 |
+
def _lat_str(rec):
|
| 352 |
+
parts = [f"{k} {v}s" for k, v in rec["lat"].items()]
|
| 353 |
+
return "Latencias: " + " | ".join(parts) + f" | coste ~${rec['cost_usd']:.4f}"
|