Spaces:
Running
Running
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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
29 |
|
30 |
-
#
|
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=
|
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=
|
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()
|
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 |
-
|
|
|
|
|
|
|
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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
32 |
try:
|
33 |
-
|
34 |
-
|
|
|
35 |
|
36 |
-
|
|
|
37 |
|
38 |
-
|
|
|
|
|
39 |
|
40 |
-
|
|
|
41 |
|
42 |
-
|
43 |
-
|
44 |
-
|
45 |
-
|
46 |
-
|
47 |
-
|
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 |
-
|
60 |
-
|
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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 |
+
)
|