Malaji71 Claude Fable 5 commited on
Commit
35caeb4
·
1 Parent(s): f467051

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>

Files changed (7) hide show
  1. .gitignore +2 -0
  2. README.md +55 -171
  3. agent.py +62 -8
  4. app.py +98 -45
  5. requirements.txt +12 -13
  6. semantic_database.py +0 -541
  7. y_refiner.py +353 -0
.gitignore ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ __pycache__/
2
+ *.pyc
README.md CHANGED
@@ -13,187 +13,71 @@ short_description: Enriquecimiento de prompts visuales
13
 
14
  # PromptCraft: Refinamiento Estructural de Prompts
15
 
16
- **PromptCraft** es un sistema de ingeniería de prompts asistida por IA que transforma ideas en lenguaje natural en prompts optimizados para modelos de difusión (Midjourney, FLUX, SDXL).
17
- 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.
 
 
18
 
19
  > **No impone plantillas. No inventa. Solo estructura.**
20
 
21
- ---
22
-
23
- ## 🌐 Flujo del sistema
24
- Usuario (castellano)
25
- ↓
26
- Traducción local (es → en)
27
- ↓
28
- Recuperación semántica (FAISS + categoría)
29
- ↓
30
- Re-ranking con Cross-Encoder (top 6)
31
- ↓
32
- Reconstrucción con LLaMA-3.2-3B (gramática del dataset)
33
- ↓
34
- Generación con SDXL
35
-
36
-
37
- ---
38
-
39
- ## 🔑 Características clave
40
-
41
- - **Refinamiento estructural**: el LLM imita la gramática de prompts reales, no sigue una plantilla abstracta.
42
- - **Categorización contextual**: filtra ejemplos por tipo (entidad, estilo, composición, etc.).
43
- - **Transparencia total**: muestra los 6 ejemplos del dataset usados para el refinamiento.
44
- - **Iteración libre**: refina el prompt tantas veces como necesites antes de generar.
45
- - **100% funcional en CPU**: traducción y análisis locales; solo la generación usa la nube.
46
-
47
- ---
 
 
 
 
 
 
48
 
49
  ## 🛠️ Cómo usarlo
50
 
51
- 1. **Escribe tu idea en castellano** (ej: *"un mercado en Antigua Guatemala"*).
52
- 2. **(Opcional) Selecciona una categoría**:
53
- - **Automática**: el sistema decide.
54
- - **Entidad**: personas, objetos, criaturas.
55
- - **Composición**: escenas, entornos, narrativas.
56
- - **Estilo artístico**: pintura, ilustración, fotografía.
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
- ## 📁 Requisitos del dataset
66
-
67
- Tu `metadata.json` debe tener este formato:
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
- **PromptCraft** es un sistema de ingeniería de prompts asistida por IA que transforma ideas en lenguaje natural en prompts optimizados para modelos de difusión (Midjourney, FLUX, SDXL).
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
- > **No impone plantillas. No inventa. Solo estructura.**
119
 
120
- ---
121
 
122
- ## 🌐 Flujo del sistema
123
-
124
- Usuario (castellano)
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
- ## 📁 Requisitos del dataset
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 🇦🇷
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
agent.py CHANGED
@@ -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([user_en], normalize_embeddings=True)[0].astype('float32').reshape(1, -1)
 
 
63
  _, idxs = self.index.search(query_emb, 50)
64
-
65
- examples = []
 
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
- examples.append(cap)
72
- if len(examples) >= k: break
73
- if not examples: return self._default_response()
 
 
 
 
 
 
 
 
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()) < 15]
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)
app.py CHANGED
@@ -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 exactly to the template. Maintain natural, comma-separated clause flow.\n"
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. Output ONLY the final prompt.\n"
75
- "4. Keep the prompt between 15-35 words. Ensure professional diffusion-ready syntax."
 
 
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 manejo de errores y timeout."""
83
  try:
84
- logger.info(f"🔄 Llamando a {self.model_name}...")
85
- response = self.openai_client.chat.completions.create(
86
- model=self.model_name,
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
- if response and response.choices and response.choices[0].message.content:
97
- raw_output = response.choices[0].message.content.strip()
98
- logger.info(f"✅ LLM respondió ({len(raw_output)} chars)")
99
- return raw_output
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 > 40:
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
- return cleaned, "✅ Refinado exitosamente", agent_data.get("examples_ui", [])
 
 
188
  else:
189
  logger.warning(f"⚠️ Output rechazado: {reason} → '{cleaned}'")
190
-
191
- # 5. Fallback semántico
 
 
 
 
192
  logger.info("🔧 Aplicando fallback semántico...")
 
193
  fallback, _ = self.agent.enhance_prompt(user_en, category)
194
- return self._clean_output(fallback), "⚠️ Fallback: enriquecimiento semántico", agent_data.get("examples_ui", [])
 
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
- progress(0.7, desc="✨ Composición estructural...")
251
- refined_prompt, status_msg, examples = refiner.refine(prompt, category_en)
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=2)
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
- demo.launch(server_name="0.0.0.0", server_port=7860)
 
 
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)
requirements.txt CHANGED
@@ -1,15 +1,14 @@
 
1
  gradio>=6.11.0,<7.0.0
2
- Pillow>=10.0.0
3
- huggingface-hub>=0.24.0
4
- sentence-transformers>=2.3.0
5
- faiss-cpu>=1.9.0.post1
6
- datasets>=2.10.0
7
- torch>=2.0.0
8
- transformers>=4.34.0
9
- sentencepiece>=0.1.99
10
- spacy>=3.7.0,<4.0.0
11
- uvicorn>=0.14.0
12
- websockets>=10.4
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
 
 
semantic_database.py DELETED
@@ -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")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
y_refiner.py ADDED
@@ -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}"