SantosPatazca commited on
Commit
f57c249
·
1 Parent(s): 06e70bd

feat(feedback): integrar Gemini flash-8b con parser robusto y fallback

Browse files
src/expon/feedback/application/internal/generate_feedback_service.py CHANGED
@@ -17,22 +17,29 @@ class GenerateFeedbackService:
17
  def generate_feedback(self, presentation_id: str) -> dict[str, Any]:
18
  # 1. Buscar presentación
19
  presentation: PresentationORM = self.presentation_repo.get_by_id(presentation_id)
20
-
21
  if presentation is None:
22
  raise ValueError("Presentación no encontrada")
23
 
24
  user_id = presentation.user_id
25
  emotion = presentation.dominant_emotion
26
  transcription = presentation.transcript or ""
27
- confidence = presentation.confidence or 0.0
28
- anxiety = 0.3 # Puedes cambiarlo si luego deseas calcularlo
 
 
 
 
 
 
 
29
 
30
- # 2. Generar contenido dinámico con IA
31
  general, language, confidence_fb, anxiety_fb, suggestions = self.text_gen_service.generate_structured_feedback(
32
  transcription=transcription,
33
  emotion=emotion,
34
  confidence=confidence,
35
- anxiety=anxiety
 
36
  )
37
 
38
  feedback = Feedback(
@@ -59,6 +66,8 @@ class GenerateFeedbackService:
59
  "anxiety_feedback": feedback.anxiety_feedback,
60
  "suggestions": feedback.suggestions,
61
  "created_at": feedback.created_at,
 
62
  "dominant_emotion": emotion,
63
- "confidence": round(confidence, 2)
 
64
  }
 
17
  def generate_feedback(self, presentation_id: str) -> dict[str, Any]:
18
  # 1. Buscar presentación
19
  presentation: PresentationORM = self.presentation_repo.get_by_id(presentation_id)
 
20
  if presentation is None:
21
  raise ValueError("Presentación no encontrada")
22
 
23
  user_id = presentation.user_id
24
  emotion = presentation.dominant_emotion
25
  transcription = presentation.transcript or ""
26
+ confidence = float(presentation.confidence or 0.0)
27
+
28
+ # 2. Calcular ansiedad desde la distribución (0..1)
29
+ probs: dict[str, float] = (presentation.emotion_probabilities or {}) # 0..1
30
+ anxiety = float(probs.get("ansiosa", 0.0) + probs.get("nerviosa", 0.0))
31
+ if anxiety > 1.0:
32
+ anxiety = 1.0
33
+ if anxiety < 0.0:
34
+ anxiety = 0.0
35
 
36
+ # 3. Generar contenido con IA (usa distribución completa)
37
  general, language, confidence_fb, anxiety_fb, suggestions = self.text_gen_service.generate_structured_feedback(
38
  transcription=transcription,
39
  emotion=emotion,
40
  confidence=confidence,
41
+ anxiety=anxiety,
42
+ emotion_probabilities=probs
43
  )
44
 
45
  feedback = Feedback(
 
66
  "anxiety_feedback": feedback.anxiety_feedback,
67
  "suggestions": feedback.suggestions,
68
  "created_at": feedback.created_at,
69
+ # extras para el front:
70
  "dominant_emotion": emotion,
71
+ "confidence": round(confidence, 2),
72
+ "emotion_probabilities": probs,
73
  }
src/expon/feedback/infrastructure/persistence/jpa/feedback_repository.py CHANGED
@@ -1,8 +1,6 @@
1
  from sqlalchemy.orm import Session
2
  from src.expon.feedback.infrastructure.persistence.jpa.feedback_orm import FeedbackORM
3
  from src.expon.feedback.domain.model.feedback import Feedback
4
- from datetime import datetime
5
- import uuid
6
 
7
  class FeedbackRepository:
8
  def __init__(self, db: Session):
@@ -10,7 +8,7 @@ class FeedbackRepository:
10
 
11
  def save(self, feedback: Feedback):
12
  orm_obj = FeedbackORM(
13
- id=uuid.uuid4(),
14
  user_id=feedback.user_id,
15
  presentation_id=feedback.presentation_id,
16
  general_feedback=feedback.general_feedback,
@@ -18,7 +16,7 @@ class FeedbackRepository:
18
  confidence_feedback=feedback.confidence_feedback,
19
  anxiety_feedback=feedback.anxiety_feedback,
20
  suggestions=feedback.suggestions,
21
- created_at=datetime.utcnow()
22
  )
23
  self.db.add(orm_obj)
24
  self.db.commit()
@@ -30,7 +28,7 @@ class FeedbackRepository:
30
  return self.db.query(FeedbackORM).filter_by(user_id=user_id).all()
31
 
32
  def get_by_presentation(self, presentation_id):
33
- return self.db.query(FeedbackORM).filter_by(presentation_id=presentation_id).all() # ✅ CORREGIDO (antes era `.first()`)
34
 
35
  def delete(self, feedback: FeedbackORM):
36
  self.db.delete(feedback)
 
1
  from sqlalchemy.orm import Session
2
  from src.expon.feedback.infrastructure.persistence.jpa.feedback_orm import FeedbackORM
3
  from src.expon.feedback.domain.model.feedback import Feedback
 
 
4
 
5
  class FeedbackRepository:
6
  def __init__(self, db: Session):
 
8
 
9
  def save(self, feedback: Feedback):
10
  orm_obj = FeedbackORM(
11
+ id=feedback.id, # ✅ respeta el id generado en dominio
12
  user_id=feedback.user_id,
13
  presentation_id=feedback.presentation_id,
14
  general_feedback=feedback.general_feedback,
 
16
  confidence_feedback=feedback.confidence_feedback,
17
  anxiety_feedback=feedback.anxiety_feedback,
18
  suggestions=feedback.suggestions,
19
+ created_at=feedback.created_at # ✅ usa el created_at del dominio
20
  )
21
  self.db.add(orm_obj)
22
  self.db.commit()
 
28
  return self.db.query(FeedbackORM).filter_by(user_id=user_id).all()
29
 
30
  def get_by_presentation(self, presentation_id):
31
+ return self.db.query(FeedbackORM).filter_by(presentation_id=presentation_id).all()
32
 
33
  def delete(self, feedback: FeedbackORM):
34
  self.db.delete(feedback)
src/expon/feedback/infrastructure/services/text_generation_service.py CHANGED
@@ -1,69 +1,239 @@
1
- import os
 
2
  import google.generativeai as genai
3
  from dotenv import load_dotenv
4
 
5
- # Cargar variables desde .env
6
  load_dotenv()
7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
  class TextGenerationService:
9
- def __init__(self, model="gemini-1.5-flash"):
10
- self.model_name = model
 
 
 
11
  api_key = os.getenv("GEMINI_API_KEY")
12
  if not api_key:
13
  raise ValueError("GEMINI_API_KEY no encontrada en variables de entorno")
14
-
15
  genai.configure(api_key=api_key)
16
- # Usar modelo sin configuración fija para permitir ajustes dinámicos
17
- self.model = genai.GenerativeModel(model)
18
-
19
- def generate_structured_feedback(self, transcription: str, emotion: str, confidence: float, anxiety: float) -> tuple[str, str, str, str, str]:
20
- # Contexto base con información de la presentación
21
- context = (
22
- f"ANÁLISIS DE PRESENTACIÓN ACADÉMICA\n"
23
- f"====================================\n"
24
- f"Transcripción: \"{transcription}\"\n\n"
25
- f"Métricas detectadas:\n"
26
- f"- Emoción dominante: {emotion}\n"
27
- f"- Nivel de confianza: {int(confidence * 100)}%\n"
28
- f"- Nivel de ansiedad: {int(anxiety * 100)}%\n"
29
- )
30
 
31
- def ask(prompt: str) -> str:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32
  try:
33
- # Crear el prompt completo con contexto
34
- full_prompt = f"""Eres un experto en análisis de presentaciones académicas.
 
35
 
36
- {context}
 
37
 
38
- {prompt}
 
 
39
 
40
- IMPORTANTE: Responde en máximo 60 palabras, de forma directa y profesional, sin usar comillas dobles."""
 
41
 
42
- # Configuración dinámica como sugiere GPT
43
- response = self.model.generate_content(
44
- full_prompt,
45
- generation_config={
46
- "temperature": 0.7,
47
- "max_output_tokens": 100
48
- }
49
  )
50
-
51
- # Limpiar caracteres de escape y limitaciones
52
- clean_text = response.text.strip().replace('\\"', '"').replace('\\n', ' ')
53
- # Limitar palabras si es muy largo
54
- words = clean_text.split()
55
- if len(words) > 60:
56
- clean_text = ' '.join(words[:60]) + "..."
57
- return clean_text
58
  except Exception as e:
59
- print(f"Error al generar feedback con Gemini: {e}")
60
- return f"Error al generar análisis. Verifique la configuración de la API."
61
-
62
- # Pedir feedback por secciones con prompts más específicos
63
- general = ask("Analiza brevemente la presentación general: fortalezas principales y área de mejora más importante.")
64
- language = ask("Evalúa el lenguaje: ¿es académico o informal? Menciona 2 mejoras específicas para el vocabulario.")
65
- confidence_fb = ask("¿Cómo se percibe la confianza del orador? Analiza el tono y seguridad proyectada.")
66
- anxiety_fb = ask("¿Se detecta ansiedad? Proporciona 2 técnicas específicas para reducirla.")
67
- suggestions = ask("Lista exactamente 3 mejoras concretas y accionables para futuras presentaciones.")
68
 
69
- return general, language, confidence_fb, anxiety_fb, suggestions
 
 
 
 
 
 
 
 
1
+ import os, json, re, time
2
+ from typing import Dict, Tuple, Optional, Any
3
  import google.generativeai as genai
4
  from dotenv import load_dotenv
5
 
 
6
  load_dotenv()
7
 
8
+ def _fmt_dist(dist: Dict[str, float]) -> str:
9
+ order = ["confiada","entusiasta","motivada","neutra","ansiosa","nerviosa"]
10
+ parts = [f"{k}:{round(float(dist.get(k,0))*100,1)}%" for k in order]
11
+ for k,v in dist.items():
12
+ if k not in order:
13
+ parts.append(f"{k}:{round(float(v)*100,1)}%")
14
+ return ", ".join(parts)
15
+
16
+ def _extract_json_block(text: Any) -> str:
17
+ """
18
+ Acepta str | list | dict | cualquier cosa.
19
+ Si no es str, lo serializa a str antes de procesar.
20
+ """
21
+ if text is None:
22
+ return ""
23
+ if not isinstance(text, str):
24
+ try:
25
+ text = json.dumps(text, ensure_ascii=False)
26
+ except Exception:
27
+ text = str(text)
28
+
29
+ t = text.strip()
30
+ # limpia fences ```json ... ```
31
+ t = re.sub(r"^```(?:json)?\s*|\s*```$", "", t, flags=re.IGNORECASE)
32
+
33
+ # Si ya parece JSON directo
34
+ try:
35
+ if t.startswith("{"):
36
+ json.loads(t)
37
+ return t
38
+ except Exception:
39
+ pass
40
+
41
+ # Busca bloque { ... } más grande
42
+ s, e = t.find("{"), t.rfind("}")
43
+ if s != -1 and e != -1 and e > s:
44
+ candidate = t[s:e+1]
45
+ return candidate
46
+
47
+ return t # último recurso
48
+
49
+ def _resp_to_str(resp: Any) -> str:
50
+ """
51
+ Convierte la respuesta de google-generativeai a string de forma robusta.
52
+ En 0.8.x, resp.text puede ser str | list | dict dependiendo de response_mime_type.
53
+ """
54
+ # 1) Si hay .text y es str, úsalo
55
+ txt = getattr(resp, "text", None)
56
+ if isinstance(txt, str):
57
+ return txt.strip()
58
+
59
+ # 2) Si .text es list/dict (caso JSON mode), serializa
60
+ if isinstance(txt, (list, dict)):
61
+ try:
62
+ return json.dumps(txt, ensure_ascii=False)
63
+ except Exception:
64
+ pass
65
+
66
+ # 3) Intenta candidates -> parts
67
+ try:
68
+ cands = getattr(resp, "candidates", None) or []
69
+ if cands:
70
+ parts = getattr(cands[0], "content", None)
71
+ parts = getattr(parts, "parts", None) if parts else None
72
+ if parts:
73
+ chunks = []
74
+ for p in parts:
75
+ val = getattr(p, "text", None)
76
+ if isinstance(val, str):
77
+ chunks.append(val)
78
+ else:
79
+ try:
80
+ chunks.append(json.dumps(p, default=str, ensure_ascii=False))
81
+ except Exception:
82
+ continue
83
+ if chunks:
84
+ return "\n".join(chunks).strip()
85
+ except Exception:
86
+ pass
87
+
88
+ # 4) Último recurso: serializa todo el objeto
89
+ try:
90
+ return json.dumps(resp, default=str, ensure_ascii=False)
91
+ except Exception:
92
+ return ""
93
+
94
+ def _to_text(value: Any) -> str:
95
+ """
96
+ Normaliza cualquier valor del JSON a string “seguro” para .strip().
97
+ - Si es lista de strings, las une con ' · '.
98
+ - Si es lista/dict, lo serializa a JSON legible.
99
+ - Si es None, devuelve "".
100
+ """
101
+ if value is None:
102
+ return ""
103
+ if isinstance(value, str):
104
+ return value
105
+ if isinstance(value, list):
106
+ # Une strings; si hay dicts u otros tipos, serializa cada item
107
+ parts = []
108
+ for it in value:
109
+ if isinstance(it, str):
110
+ parts.append(it)
111
+ else:
112
+ try:
113
+ parts.append(json.dumps(it, ensure_ascii=False))
114
+ except Exception:
115
+ parts.append(str(it))
116
+ return " · ".join(parts)
117
+ if isinstance(value, dict):
118
+ try:
119
+ return json.dumps(value, ensure_ascii=False)
120
+ except Exception:
121
+ return str(value)
122
+ return str(value)
123
+
124
  class TextGenerationService:
125
+ def __init__(self, model: str = "gemini-1.5-flash-8b"):
126
+ # Mantén solo el 8b para evitar aliases que resuelven a -002/-latest
127
+ self.primary_model = model
128
+ self.backup_model = model # reintento con la misma versión, sin json_mode
129
+
130
  api_key = os.getenv("GEMINI_API_KEY")
131
  if not api_key:
132
  raise ValueError("GEMINI_API_KEY no encontrada en variables de entorno")
 
133
  genai.configure(api_key=api_key)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
134
 
135
+ def _gen(self, model_name: str, prompt: str, json_mode: bool) -> str:
136
+ model = genai.GenerativeModel(model_name)
137
+ gen_cfg = {"temperature": 0.6, "max_output_tokens": 512}
138
+ if json_mode:
139
+ # En 0.8.5 resp.text puede venir como list/dict: lo manejamos en _resp_to_str
140
+ try:
141
+ gen_cfg["response_mime_type"] = "application/json"
142
+ except Exception:
143
+ pass
144
+
145
+ resp = model.generate_content(prompt, generation_config=gen_cfg)
146
+
147
+ if hasattr(resp, "prompt_feedback") and resp.prompt_feedback:
148
+ print("[Gemini] prompt_feedback:", resp.prompt_feedback)
149
+
150
+ raw = _resp_to_str(resp) # ⬅️ SIEMPRE usar el conversor robusto
151
+ print(f"[Gemini] _gen returned type={type(raw).__name__}")
152
+ return raw
153
+
154
+ def generate_structured_feedback(
155
+ self,
156
+ transcription: str,
157
+ emotion: str,
158
+ confidence: float,
159
+ anxiety: float,
160
+ emotion_probabilities: Optional[Dict[str, float]] = None,
161
+ ) -> Tuple[str, str, str, str, str]:
162
+
163
+ dist = emotion_probabilities or {}
164
+ dist_txt = _fmt_dist(dist)
165
+ conf_pct = int((confidence or 0.0) * 100)
166
+ anx_pct = int((anxiety or 0.0) * 100)
167
+ transcript = (transcription or "").strip()
168
+
169
+ prompt = f"""
170
+ Eres un coach de oratoria académica. Genera feedback breve, accionable y específico
171
+ para un estudiante usando la TRANSCRIPCIÓN y la DISTRIBUCIÓN DE EMOCIONES del modelo.
172
+
173
+ ### Datos del modelo:
174
+ - Emoción dominante: {emotion or "desconocida"}
175
+ - Confianza del modelo: {conf_pct}%
176
+ - Ansiedad estimada (desde probs): {anx_pct}%
177
+ - Distribución de emociones: {dist_txt}
178
+
179
+ ### Transcripción:
180
+ \"\"\"{transcript[:12000]}\"\"\"
181
+
182
+ ### Reglas de salida (JSON estricto):
183
+ Devuelve SOLO un objeto con estas claves:
184
+ {{
185
+ "general_feedback": "Resumen (3-5 líneas) con 1 fortaleza y 1 foco principal de mejora.",
186
+ "confidence_feedback": "Consejo corto para reforzar seguridad; si ya es alta, cómo mantenerla.",
187
+ "anxiety_feedback": "1-2 técnicas concretas si hay ansiedad; si es baja, cómo prevenir.",
188
+ "language_feedback": "Observaciones sobre claridad y muletillas (máx. 4 ideas).",
189
+ "suggestions": "3 recomendaciones accionables numeradas (1 línea cada una)."
190
+ }}
191
+
192
+ - Prioriza acciones (“pausas de 1s”, “ensayo cronometrado 90s”, “sustituir muletillas”).
193
+ - No inventes hechos fuera de la transcripción.
194
+ - No devuelvas texto fuera del JSON.
195
+ """.strip()
196
+
197
+ # Retries: mismo modelo (8b), con y sin json_mode
198
+ attempts = [
199
+ (self.primary_model, True),
200
+ (self.backup_model, False),
201
+ ]
202
+
203
+ last_err = None
204
+ for i, (model_name, json_mode) in enumerate(attempts, start=1):
205
  try:
206
+ raw = self._gen(model_name, prompt, json_mode=json_mode)
207
+ if not raw:
208
+ raise RuntimeError("Respuesta vacía de Gemini")
209
 
210
+ cleaned = _extract_json_block(raw)
211
+ data = json.loads(cleaned)
212
 
213
+ # Si la raíz es lista y el primer elemento es dict, úsalo
214
+ if isinstance(data, list) and data and isinstance(data[0], dict):
215
+ data = data[0]
216
 
217
+ def take(key: str) -> str:
218
+ return _to_text(data.get(key)).strip()[:800]
219
 
220
+ return (
221
+ take("general_feedback"),
222
+ take("language_feedback"),
223
+ take("confidence_feedback"),
224
+ take("anxiety_feedback"),
225
+ take("suggestions"),
 
226
  )
 
 
 
 
 
 
 
 
227
  except Exception as e:
228
+ last_err = e
229
+ print(f"[Gemini] intento {i} falló ({model_name}, json_mode={json_mode}): {e}")
230
+ time.sleep(0.6 * i)
 
 
 
 
 
 
231
 
232
+ print(f"[Gemini] Fallback activado por error: {last_err}")
233
+ return (
234
+ "No se pudo generar el resumen en este intento.",
235
+ "Revisa claridad y evita muletillas frecuentes.",
236
+ "Ensaya con voz firme y ritmo estable.",
237
+ "Prueba respiración 4-7-8 y pausas de 1s.",
238
+ "1) Ensayo cronometrado 2) Grábate y revisa 3) Mejora transiciones.",
239
+ )