iagofp commited on
Commit
0f3d57b
·
1 Parent(s): bfb1036

BD modificada, renombre, DAO, DTO, VO, Singleton e Factory

Browse files
.claude/settings.local.json CHANGED
@@ -7,7 +7,8 @@
7
  "Bash(Get-ChildItem -Path \"c:\\\\Users\\\\usuario\\\\Desktop\\\\ValorSentimental\" -Recurse -Force)",
8
  "Bash(Select-Object -Property FullName, Name)",
9
  "Bash(ConvertTo-Json)",
10
- "Bash(Out-String)"
 
11
  ]
12
  }
13
  }
 
7
  "Bash(Get-ChildItem -Path \"c:\\\\Users\\\\usuario\\\\Desktop\\\\ValorSentimental\" -Recurse -Force)",
8
  "Bash(Select-Object -Property FullName, Name)",
9
  "Bash(ConvertTo-Json)",
10
+ "Bash(Out-String)",
11
+ "Bash(Select-Object FullName)"
12
  ]
13
  }
14
  }
.codex DELETED
File without changes
backend/aplicacion.py ADDED
@@ -0,0 +1,392 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Configura y expone la app Flask como singleton.
3
+ Las rutas son delegadores delgados: validan la entrada, llaman al servicio correspondiente y serializan la respuesta.
4
+ """
5
+
6
+ import dataclasses
7
+ import re
8
+ from datetime import datetime, timezone
9
+
10
+ import requests as http_requests
11
+ from flask import Flask, jsonify, request
12
+ from flask_cors import CORS
13
+
14
+ from config import OMDB_API_KEY
15
+ from dao.ciclo_dao import CicloDAO
16
+ from dao.emocion_dao import EmocionDAO
17
+ from dao.historial_dao import HistorialDAO
18
+ from dao.pelicula_dao import PeliculaDAO
19
+ from dao.usuario_dao import UsuarioDao
20
+ from base_datos import iniciar_historial_usuario
21
+ from modelos import PeliculaVistaVO
22
+ from services.pipeline import AnalysisService
23
+ from services.analisis_sentimientos import analizar_texto, crear_clasificador_emociones
24
+ from services.recomendacion import cargar_dataset_movies
25
+
26
+
27
+ _poster_cache: dict[str, str | None] = {}
28
+ _movies_index: dict[str, dict] = {} # movieId -> row, built after dataset loads
29
+
30
+
31
+ def _year_from_title(title: str) -> str | None:
32
+ m = re.search(r"\((\d{4})\)\s*$", title or "")
33
+ return m.group(1) if m else None
34
+
35
+
36
+ def _meta_pelicula(movie_id: str) -> tuple[str | None, str | None]:
37
+ """Returns (anio, genero) from the in-memory dataset index."""
38
+ row = _movies_index.get(str(movie_id))
39
+ if not row:
40
+ return None, None
41
+ genres_raw = str(row.get("genres", "") or "").strip()
42
+ genero = genres_raw if genres_raw and genres_raw != "(no genres listed)" else None
43
+ anio = _year_from_title(str(row.get("title", "") or ""))
44
+ return anio, genero
45
+
46
+ app = Flask(__name__)
47
+ CORS(app)
48
+
49
+ _modelo = crear_clasificador_emociones() # Carga localmente pysentimiento/robertuito (descarga al primer arranque)
50
+ _movies_df, _media_rating_global = cargar_dataset_movies()
51
+ _movies_index = {str(r.get("movieId", "")).strip(): r for r in _movies_df}
52
+ iniciar_historial_usuario()
53
+ print(
54
+ f"Listo. Dataset de recomendaciones: {len(_movies_df)} peliculas "
55
+ f"(rating global medio={_media_rating_global:.3f})"
56
+ )
57
+
58
+ _analysis_service = AnalysisService(_modelo, _movies_df, _media_rating_global)
59
+
60
+ _usuario_dao = UsuarioDao()
61
+ _emocion_dao = EmocionDAO()
62
+ _ciclo_dao = CicloDAO()
63
+ _historial_dao = HistorialDAO()
64
+ _pelicula_dao = PeliculaDAO()
65
+
66
+
67
+ # ------------------------------------------------------------------
68
+ # Auth
69
+ # ------------------------------------------------------------------
70
+
71
+ @app.route("/auth/register", methods=["POST"])
72
+ def register():
73
+ payload = request.json or {}
74
+ username = str(payload.get("username", "")).strip()
75
+ password = str(payload.get("password", "")).strip()
76
+ if not username or not password:
77
+ return jsonify({"error": "username y password son obligatorios"}), 400
78
+ if len(username) < 3:
79
+ return jsonify({"error": "El usuario debe tener al menos 3 caracteres"}), 400
80
+ if len(password) < 6:
81
+ return jsonify({"error": "La contraseña debe tener al menos 6 caracteres"}), 400
82
+ usuario = _usuario_dao.registrar(username, password)
83
+ if not usuario:
84
+ return jsonify({"error": "El nombre de usuario ya existe"}), 409
85
+ return jsonify({"user_id": usuario.id, "username": usuario.username, "token": usuario.token}), 201
86
+
87
+ @app.route("/auth/login", methods=["POST"])
88
+ def login():
89
+ payload = request.json or {}
90
+ username = str(payload.get("username", "")).strip()
91
+ password = str(payload.get("password", "")).strip()
92
+ if not username or not password:
93
+ return jsonify({"error": "username y password son obligatorios"}), 400
94
+ usuario = _usuario_dao.login(username, password)
95
+ if not usuario:
96
+ return jsonify({"error": "Credenciales incorrectas"}), 401
97
+ return jsonify({"user_id": usuario.id, "username": usuario.username, "token": usuario.token})
98
+
99
+ @app.route("/auth/logout", methods=["POST"])
100
+ def logout():
101
+ payload = request.json or {}
102
+ token = str(payload.get("token", "")).strip()
103
+ _usuario_dao.cerrar_sesion(token)
104
+ return jsonify({"ok": True})
105
+
106
+ @app.route("/auth/password", methods=["POST"])
107
+ def change_password():
108
+ payload = request.json or {}
109
+ token = str(payload.get("token", "")).strip()
110
+ old_password = str(payload.get("old_password", "")).strip()
111
+ new_password = str(payload.get("new_password", "")).strip()
112
+ if not token or not old_password or not new_password:
113
+ return jsonify({"error": "token, old_password y new_password son obligatorios"}), 400
114
+ if len(new_password) < 6:
115
+ return jsonify({"error": "La nueva contraseña debe tener al menos 6 caracteres"}), 400
116
+ usuario = _usuario_dao.obtener_por_token(token)
117
+ if not usuario or not _usuario_dao.actualizar_contraseña(usuario.id, new_password):
118
+ return jsonify({"error": "Contraseña actual incorrecta o sesión inválida"}), 401
119
+ return jsonify({"ok": True})
120
+
121
+ @app.route("/auth/verify", methods=["POST"])
122
+ def verify_token():
123
+ payload = request.json or {}
124
+ token = str(payload.get("token", "")).strip()
125
+ if not token:
126
+ return jsonify({"valid": False}), 400
127
+ usuario = _usuario_dao.obtener_por_token(token)
128
+ if not usuario:
129
+ return jsonify({"valid": False}), 401
130
+ return jsonify({"valid": True, "user_id": usuario.id, "username": usuario.username})
131
+
132
+ @app.route("/auth/account", methods=["DELETE"])
133
+ def delete_account():
134
+ payload = request.json or {}
135
+ token = str(payload.get("token", "")).strip()
136
+ if not token:
137
+ return jsonify({"error": "token es obligatorio"}), 400
138
+ usuario = _usuario_dao.obtener_por_token(token)
139
+ if not usuario:
140
+ return jsonify({"error": "Token inválido o cuenta no encontrada"}), 401
141
+ _historial_dao.borrar_por_usuario(usuario.id)
142
+ _emocion_dao.borrar_por_usuario(usuario.id)
143
+ _ciclo_dao.borrar_por_usuario(usuario.id)
144
+ _usuario_dao.eliminar(usuario.id)
145
+ return jsonify({"ok": True, "deleted_user": usuario.username})
146
+
147
+
148
+ # ------------------------------------------------------------------
149
+ # Análisis
150
+ # ------------------------------------------------------------------
151
+
152
+ @app.route("/analizar", methods=["POST"])
153
+ def analizar():
154
+ payload = request.json or {}
155
+ texto = payload.get("texto", "")
156
+ user_id = str(payload.get("user_id", "")).strip()
157
+ estrategia = str(payload.get("estrategia") or "v1").strip().lower()
158
+ resultado = _analysis_service.analizar(texto, user_id, estrategia)
159
+ return jsonify(dataclasses.asdict(resultado))
160
+
161
+
162
+ # ------------------------------------------------------------------
163
+ # Seguimiento de recomendación
164
+ # ------------------------------------------------------------------
165
+
166
+ @app.route("/recomendacion/seguimiento", methods=["POST"])
167
+ def seguimiento_recomendacion():
168
+ payload = request.json or {}
169
+ user_id = str(payload.get("user_id", "")).strip()
170
+ texto_posterior = str(payload.get("texto_post", "")).strip()
171
+ id_pelicula = str(payload.get("id_pelicula") or payload.get("movie_id") or "").strip()
172
+ titulo_pelicula = str(payload.get("title", "")).strip()
173
+
174
+ try:
175
+ cycle_id = int(payload.get("ciclo_recomendacion_id", 0))
176
+ except (TypeError, ValueError):
177
+ cycle_id = 0
178
+
179
+ if not user_id or not cycle_id or not texto_posterior:
180
+ return jsonify({"error": "user_id, ciclo_recomendacion_id y texto_post son obligatorios"}), 400
181
+
182
+ ciclo = _ciclo_dao.obtener_por_id(cycle_id, user_id)
183
+ if not ciclo:
184
+ return jsonify({"error": "ciclo de recomendacion no encontrado"}), 404
185
+
186
+ emocion_pre = _emocion_dao.obtener_por_id(ciclo.emocion_pre_id)
187
+
188
+ momento_analisis = datetime.now(timezone.utc).isoformat()
189
+ result_post, emocion_posterior, valencia_posterior = analizar_texto(_modelo, texto_posterior)
190
+
191
+ emocion_post_obj = _emocion_dao.añadir(
192
+ user_id=user_id,
193
+ texto=texto_posterior,
194
+ emocion=emocion_posterior,
195
+ valencia=valencia_posterior,
196
+ tiempo=momento_analisis,
197
+ )
198
+
199
+ if id_pelicula:
200
+ _anio, _genero = _meta_pelicula(id_pelicula)
201
+ _pelicula_dao.guardar_si_no_existe(id_pelicula, titulo_pelicula, anio=_anio, genero=_genero)
202
+ if emocion_post_obj:
203
+ _ciclo_dao.cerrar_ciclo(
204
+ ciclo_id=cycle_id,
205
+ user_id=user_id,
206
+ pelicula_id=id_pelicula,
207
+ emocion_post_id=emocion_post_obj.id,
208
+ )
209
+
210
+ pre_emotion = emocion_pre.emocion if emocion_pre else None
211
+ pre_valence = emocion_pre.valencia if emocion_pre else None
212
+
213
+ return jsonify({
214
+ "ciclo_recomendacion_id": cycle_id,
215
+ "id_pelicula": id_pelicula,
216
+ "title": titulo_pelicula,
217
+ "pre_emotion": pre_emotion,
218
+ "pre_valence": pre_valence,
219
+ "post_emotion": emocion_posterior,
220
+ "post_valence": valencia_posterior,
221
+ "cambio_emocional": pre_emotion != emocion_posterior,
222
+ "cambio_valencia": pre_valence != valencia_posterior,
223
+ "emociones_post": result_post,
224
+ })
225
+
226
+
227
+ # ------------------------------------------------------------------
228
+ # Historial de visionado
229
+ # ------------------------------------------------------------------
230
+
231
+ @app.route("/historial/visto", methods=["POST"])
232
+ def guardar_visto():
233
+ payload = request.json or {}
234
+ user_id = str(payload.get("user_id", "")).strip()
235
+ id_pelicula = str(payload.get("id_pelicula") or payload.get("movie_id") or "").strip()
236
+
237
+ if not user_id or not id_pelicula:
238
+ return jsonify({"error": "user_id y id_pelicula son obligatorios"}), 400
239
+
240
+ momento_visionado = datetime.now(timezone.utc).isoformat()
241
+ titulo_pelicula = str(payload.get("title", "")).strip()
242
+ emocion_str = str(payload.get("emotion", "")).strip()
243
+ texto = str(payload.get("session_text", "")).strip()
244
+ rating_usuario_raw = payload.get("user_rating")
245
+ rating_usuario = None
246
+
247
+ if rating_usuario_raw is not None and str(rating_usuario_raw).strip() != "":
248
+ try:
249
+ rating_usuario = float(rating_usuario_raw)
250
+ except (TypeError, ValueError):
251
+ return jsonify({"error": "rating_usuario debe ser numerica entre 1 y 5"}), 400
252
+ if rating_usuario < 1 or rating_usuario > 5:
253
+ return jsonify({"error": "rating_usuario debe estar entre 1 y 5"}), 400
254
+
255
+ _anio, _genero = _meta_pelicula(id_pelicula)
256
+ _pelicula_dao.guardar_si_no_existe(id_pelicula, titulo_pelicula, anio=_anio, genero=_genero)
257
+
258
+ emocion_id = None
259
+ if emocion_str:
260
+ valencia = "positiva" if emocion_str in ("alegria", "sorpresa") else "negativa"
261
+ emocion_obj = _emocion_dao.añadir(
262
+ user_id=user_id,
263
+ texto=texto,
264
+ emocion=emocion_str,
265
+ valencia=valencia,
266
+ tiempo=momento_visionado,
267
+ )
268
+ emocion_id = emocion_obj.id if emocion_obj else None
269
+
270
+ entrada = _historial_dao.añadir_pelicula(
271
+ user_id=user_id,
272
+ pelicula_id=id_pelicula,
273
+ emocion_id=emocion_id,
274
+ valoracion=rating_usuario,
275
+ texto=texto,
276
+ tiempo=momento_visionado,
277
+ )
278
+
279
+ vo = PeliculaVistaVO(
280
+ id=entrada.id if entrada else 0,
281
+ user_id=user_id,
282
+ movie_id=id_pelicula,
283
+ titulo=titulo_pelicula,
284
+ emocion=emocion_str or None,
285
+ valoracion=rating_usuario,
286
+ texto_sesion=texto or None,
287
+ visto_en=momento_visionado,
288
+ )
289
+ return jsonify(dataclasses.asdict(vo)), 201
290
+
291
+ @app.route("/historial", methods=["GET", "DELETE"])
292
+ def obtener_historial():
293
+ if request.method == "DELETE":
294
+ payload = request.json or {}
295
+ user_id = str(payload.get("user_id", "") or request.args.get("user_id", "")).strip()
296
+ if not user_id:
297
+ return jsonify({"error": "user_id es obligatorio"}), 400
298
+ deleted = _historial_dao.borrar_por_usuario(user_id)
299
+ return jsonify({"ok": True, "user_id": user_id, "deleted": deleted})
300
+
301
+ user_id = str(request.args.get("user_id", "")).strip()
302
+ if not user_id:
303
+ return jsonify({"error": "user_id es obligatorio"}), 400
304
+
305
+ try:
306
+ limit = int(request.args.get("limit", 30))
307
+ except ValueError:
308
+ limit = 30
309
+ limit = max(1, min(limit, 200))
310
+
311
+ vistas = _historial_dao.obtener_vistas_por_usuario(user_id=user_id, limit=limit)
312
+ return jsonify({"items": [dataclasses.asdict(vo) for vo in vistas], "count": len(vistas)})
313
+
314
+
315
+ # ------------------------------------------------------------------
316
+ # Transiciones emocionales
317
+ # ------------------------------------------------------------------
318
+
319
+ @app.route("/historial/transiciones", methods=["GET"])
320
+ def obtener_transiciones():
321
+ user_id = str(request.args.get("user_id", "")).strip()
322
+ if not user_id:
323
+ return jsonify({"error": "user_id es obligatorio"}), 400
324
+
325
+ try:
326
+ limit = int(request.args.get("limit", 20))
327
+ except ValueError:
328
+ limit = 20
329
+ limit = max(1, min(limit, 100))
330
+
331
+ emociones = _emocion_dao.obtener_por_usuario(user_id, limit=500)
332
+ entradas_h = _historial_dao.obtener_por_usuario(user_id, limit=1000)
333
+
334
+ peliculas_map: dict[str, str] = {}
335
+ for h in entradas_h:
336
+ if h.pelicula_id not in peliculas_map:
337
+ peli = _pelicula_dao.obtener_por_id(h.pelicula_id)
338
+ peliculas_map[h.pelicula_id] = peli.titulo if peli else ""
339
+
340
+ emociones_asc = sorted(emociones, key=lambda e: e.analizado_en)
341
+ historial_asc = sorted(entradas_h, key=lambda h: h.visto_en)
342
+
343
+ transition_counter: dict[tuple[str, str, str, str], int] = {}
344
+ for idx in range(1, len(emociones_asc)):
345
+ prev = emociones_asc[idx - 1]
346
+ curr = emociones_asc[idx]
347
+ if prev.emocion == curr.emocion:
348
+ continue
349
+ matched = None
350
+ for h in reversed(historial_asc):
351
+ if prev.analizado_en < h.visto_en <= curr.analizado_en:
352
+ matched = h
353
+ break
354
+ if not matched:
355
+ continue
356
+ key = (matched.pelicula_id, peliculas_map.get(matched.pelicula_id, ""), prev.emocion, curr.emocion)
357
+ transition_counter[key] = transition_counter.get(key, 0) + 1
358
+
359
+ items = [
360
+ {"movie_id": mid, "title": title, "from_emotion": fe, "to_emotion": te, "count": cnt}
361
+ for (mid, title, fe, te), cnt in transition_counter.items()
362
+ ]
363
+ items.sort(key=lambda x: x["count"], reverse=True)
364
+ return jsonify({"items": items[:limit], "count": len(items[:limit])})
365
+
366
+
367
+ # ------------------------------------------------------------------
368
+ # Poster OMDB
369
+ # ------------------------------------------------------------------
370
+
371
+ @app.route("/poster/<imdb_id>", methods=["GET"])
372
+ def get_poster(imdb_id):
373
+ key = str(imdb_id).strip()
374
+ if not key or key == "0":
375
+ return jsonify({"poster_url": None})
376
+ if key in _poster_cache:
377
+ return jsonify({"poster_url": _poster_cache[key]})
378
+ if not OMDB_API_KEY:
379
+ _poster_cache[key] = None
380
+ return jsonify({"poster_url": None})
381
+ try:
382
+ resp = http_requests.get(
383
+ "https://www.omdbapi.com/",
384
+ params={"i": f"tt{key}", "apikey": OMDB_API_KEY},
385
+ timeout=5,
386
+ )
387
+ poster = resp.json().get("Poster") if resp.ok else None
388
+ url = poster if poster and poster != "N/A" else None
389
+ except Exception:
390
+ url = None
391
+ _poster_cache[key] = url
392
+ return jsonify({"poster_url": url})
backend/app_factory.py DELETED
@@ -1,293 +0,0 @@
1
- """
2
- Crea y configura la app Flask.
3
- Las rutas son delegadores delgados: validan la entrada, llaman al servicio correspondiente y serializan la respuesta.
4
- """
5
-
6
- import dataclasses
7
- from datetime import datetime, timezone
8
-
9
- import requests as http_requests
10
- from flask import Flask, jsonify, request
11
- from flask_cors import CORS
12
-
13
- from db import iniciar_historial_usuario
14
- from repositories.auth_repository import (
15
- cambiar_contraseña,
16
- cerrar_sesion,
17
- eliminar_cuenta,
18
- iniciar_sesion,
19
- registrar_usuario,
20
- )
21
- from repositories.history_repository import (
22
- añadir_evento_emocional,
23
- borrar_historial_usuario,
24
- guardar_estado_posterior,
25
- obtener_ciclo_recomendacion,
26
- obtener_peliculas_del_historial,
27
- obtener_relacion_pelicula_emocion,
28
- añadir_pelicula_a_historial,
29
- )
30
- from config import OMDB_API_KEY
31
- from services.analysis_service import AnalysisService
32
- from services.emotion_service import analizar_texto, crear_clasificador_emociones
33
- from services.recommender_service import cargar_dataset_movies
34
-
35
-
36
- _poster_cache: dict[str, str | None] = {}
37
-
38
-
39
- def create_app() -> Flask:
40
- app = Flask(__name__)
41
- CORS(app)
42
-
43
- print("Cargando modelo...")
44
- modelo = crear_clasificador_emociones()
45
- movies_df, media_rating_global = cargar_dataset_movies()
46
- iniciar_historial_usuario()
47
- print(
48
- f"Listo. Dataset de recomendaciones: {len(movies_df)} peliculas "
49
- f"(rating global medio={media_rating_global:.3f})"
50
- )
51
-
52
- analysis_service = AnalysisService(modelo, movies_df, media_rating_global)
53
-
54
- @app.route("/auth/register", methods=["POST"])
55
- def register():
56
- payload = request.json or {}
57
- username = str(payload.get("username", "")).strip()
58
- password = str(payload.get("password", "")).strip()
59
- email = str(payload.get("email", "")).strip()
60
- if not username or not password:
61
- return jsonify({"error": "username y password son obligatorios"}), 400
62
- if len(username) < 3:
63
- return jsonify({"error": "El usuario debe tener al menos 3 caracteres"}), 400
64
- if len(password) < 6:
65
- return jsonify({"error": "La contraseña debe tener al menos 6 caracteres"}), 400
66
- result = registrar_usuario(username, password, email)
67
- if not result:
68
- return jsonify({"error": "El nombre de usuario ya existe"}), 409
69
- return jsonify(result), 201
70
-
71
- @app.route("/auth/login", methods=["POST"])
72
- def login():
73
- payload = request.json or {}
74
- username = str(payload.get("username", "")).strip()
75
- password = str(payload.get("password", "")).strip()
76
- if not username or not password:
77
- return jsonify({"error": "username y password son obligatorios"}), 400
78
- result = iniciar_sesion(username, password)
79
- if not result:
80
- return jsonify({"error": "Credenciales incorrectas"}), 401
81
- return jsonify(result)
82
-
83
- @app.route("/auth/logout", methods=["POST"])
84
- def logout():
85
- payload = request.json or {}
86
- token = str(payload.get("token", "")).strip()
87
- cerrar_sesion(token)
88
- return jsonify({"ok": True})
89
-
90
- @app.route("/auth/password", methods=["POST"])
91
- def change_password():
92
- payload = request.json or {}
93
- token = str(payload.get("token", "")).strip()
94
- old_password = str(payload.get("old_password", "")).strip()
95
- new_password = str(payload.get("new_password", "")).strip()
96
- if not token or not old_password or not new_password:
97
- return jsonify({"error": "token, old_password y new_password son obligatorios"}), 400
98
- if len(new_password) < 6:
99
- return jsonify({"error": "La nueva contraseña debe tener al menos 6 caracteres"}), 400
100
- if not cambiar_contraseña(token, old_password, new_password):
101
- return jsonify({"error": "Contraseña actual incorrecta o sesión inválida"}), 401
102
- return jsonify({"ok": True})
103
-
104
- @app.route("/auth/account", methods=["DELETE"])
105
- def delete_account():
106
- payload = request.json or {}
107
- token = str(payload.get("token", "")).strip()
108
- if not token:
109
- return jsonify({"error": "token es obligatorio"}), 400
110
- result = eliminar_cuenta(token)
111
- if not result:
112
- return jsonify({"error": "Token inválido o cuenta no encontrada"}), 401
113
- return jsonify({"ok": True, "deleted_user": result["username"]})
114
-
115
- @app.route("/analizar", methods=["POST"])
116
- def analizar():
117
- payload = request.json or {}
118
- texto = payload.get("texto", "")
119
- user_id = str(payload.get("user_id", "")).strip()
120
- estrategia = str(payload.get("estrategia") or "v1").strip().lower()
121
-
122
- resultado = analysis_service.analizar(texto, user_id, estrategia)
123
- return jsonify(dataclasses.asdict(resultado))
124
-
125
- @app.route("/recomendacion/seguimiento", methods=["POST"])
126
- def seguimiento_recomendacion():
127
- payload = request.json or {}
128
- user_id = str(payload.get("user_id", "")).strip()
129
- texto_posterior = str(payload.get("texto_post", "")).strip()
130
- id_pelicula = str(payload.get("id_pelicula") or payload.get("movie_id") or "").strip()
131
- titulo_pelicula = str(payload.get("title", "")).strip()
132
-
133
- try:
134
- cycle_id = int(payload.get("ciclo_recomendacion_id", 0))
135
- except (TypeError, ValueError):
136
- cycle_id = 0
137
-
138
- if not user_id or not cycle_id or not texto_posterior:
139
- return jsonify({"error": "user_id, ciclo_recomendacion_id y texto_post son obligatorios"}), 400
140
-
141
- cycle = obtener_ciclo_recomendacion(cycle_id=cycle_id, user_id=user_id)
142
- if not cycle:
143
- return jsonify({"error": "ciclo de recomendacion no encontrado"}), 404
144
-
145
- momento_analisis = datetime.now(timezone.utc).isoformat()
146
- result_post, emocion_posterior, valencia_posterior = analizar_texto(modelo, texto_posterior)
147
-
148
- añadir_evento_emocional(
149
- user_id=user_id,
150
- text=texto_posterior,
151
- emotion=emocion_posterior,
152
- analyzed_at=momento_analisis,
153
- )
154
- guardar_estado_posterior(
155
- cycle_id=cycle_id,
156
- user_id=user_id,
157
- post_text=texto_posterior,
158
- post_emotion=emocion_posterior,
159
- post_valence=valencia_posterior,
160
- post_analyzed_at=momento_analisis,
161
- movie_id=id_pelicula,
162
- movie_title=titulo_pelicula,
163
- )
164
-
165
- return jsonify(
166
- {
167
- "ciclo_recomendacion_id": cycle_id,
168
- "id_pelicula": id_pelicula,
169
- "title": titulo_pelicula,
170
- "pre_emotion": cycle.get("pre_emotion"),
171
- "pre_valence": cycle.get("pre_valence"),
172
- "post_emotion": emocion_posterior,
173
- "post_valence": valencia_posterior,
174
- "cambio_emocional": cycle.get("pre_emotion") != emocion_posterior,
175
- "cambio_valencia": cycle.get("pre_valence") != valencia_posterior,
176
- "emociones_post": result_post,
177
- }
178
- )
179
-
180
- @app.route("/historial/visto", methods=["POST"])
181
- def guardar_visto():
182
- payload = request.json or {}
183
- user_id = str(payload.get("user_id", "")).strip()
184
- id_pelicula = str(payload.get("id_pelicula") or payload.get("movie_id") or "").strip()
185
-
186
- if not user_id or not id_pelicula:
187
- return jsonify({"error": "user_id y id_pelicula son obligatorios"}), 400
188
-
189
- momento_visionado = datetime.now(timezone.utc).isoformat()
190
- titulo_pelicula = str(payload.get("title", "")).strip()
191
- emocion = str(payload.get("emotion", "")).strip()
192
- texto = str(payload.get("session_text", "")).strip()
193
- rating_usuario_raw = payload.get("user_rating")
194
- rating_usuario = None
195
-
196
- if rating_usuario_raw is not None and str(rating_usuario_raw).strip() != "":
197
- try:
198
- rating_usuario = float(rating_usuario_raw)
199
- except (TypeError, ValueError):
200
- return jsonify({"error": "rating_usuario debe ser numerica entre 1 y 5"}), 400
201
- if rating_usuario < 1 or rating_usuario > 5:
202
- return jsonify({"error": "rating_usuario debe estar entre 1 y 5"}), 400
203
-
204
- id_anadida = añadir_pelicula_a_historial(
205
- user_id=user_id,
206
- movie_id=id_pelicula,
207
- title=titulo_pelicula,
208
- emotion=emocion,
209
- user_rating=rating_usuario,
210
- session_text=texto,
211
- viewed_at=momento_visionado,
212
- )
213
-
214
- return jsonify(
215
- {
216
- "id": id_anadida,
217
- "user_id": user_id,
218
- "movie_id": id_pelicula,
219
- "title": titulo_pelicula,
220
- "emotion": emocion,
221
- "user_rating": rating_usuario,
222
- "session_text": texto,
223
- "viewed_at": momento_visionado,
224
- "momento_visionado": momento_visionado,
225
- }
226
- ), 201
227
-
228
- @app.route("/historial", methods=["GET", "DELETE"])
229
- def obtener_historial():
230
- if request.method == "DELETE":
231
- payload = request.json or {}
232
- user_id = str(payload.get("user_id", "") or request.args.get("user_id", "")).strip()
233
- if not user_id:
234
- return jsonify({"error": "user_id es obligatorio"}), 400
235
- deleted = borrar_historial_usuario(user_id)
236
- return jsonify({"ok": True, "user_id": user_id, "deleted": deleted})
237
-
238
- user_id = str(request.args.get("user_id", "")).strip()
239
- if not user_id:
240
- return jsonify({"error": "user_id es obligatorio"}), 400
241
-
242
- try:
243
- limit = int(request.args.get("limit", 30))
244
- except ValueError:
245
- limit = 30
246
- limit = max(1, min(limit, 200))
247
-
248
- historial = obtener_peliculas_del_historial(user_id=user_id, limit=limit)
249
- return jsonify({"items": historial, "count": len(historial)})
250
-
251
- @app.route("/poster/<imdb_id>", methods=["GET"])
252
- def get_poster(imdb_id):
253
- key = str(imdb_id).strip()
254
- if not key or key == "0":
255
- return jsonify({"poster_url": None})
256
-
257
- if key in _poster_cache:
258
- return jsonify({"poster_url": _poster_cache[key]})
259
-
260
- if not OMDB_API_KEY:
261
- _poster_cache[key] = None
262
- return jsonify({"poster_url": None})
263
-
264
- try:
265
- resp = http_requests.get(
266
- "https://www.omdbapi.com/",
267
- params={"i": f"tt{key}", "apikey": OMDB_API_KEY},
268
- timeout=5,
269
- )
270
- poster = resp.json().get("Poster") if resp.ok else None
271
- url = poster if poster and poster != "N/A" else None
272
- except Exception:
273
- url = None
274
-
275
- _poster_cache[key] = url
276
- return jsonify({"poster_url": url})
277
-
278
- @app.route("/historial/transiciones", methods=["GET"])
279
- def obtener_transiciones():
280
- user_id = str(request.args.get("user_id", "")).strip()
281
- if not user_id:
282
- return jsonify({"error": "user_id es obligatorio"}), 400
283
-
284
- try:
285
- limit = int(request.args.get("limit", 20))
286
- except ValueError:
287
- limit = 20
288
- limit = max(1, min(limit, 100))
289
-
290
- items = obtener_relacion_pelicula_emocion(user_id=user_id, limit=limit)
291
- return jsonify({"items": items, "count": len(items)})
292
-
293
- return app
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
backend/base_datos.py ADDED
@@ -0,0 +1,148 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Gestión de creación de tablas de la BD SQLite.
3
+
4
+ Esquema normalizado en BCNF:
5
+ Usuarios — datos de autenticación
6
+ Peliculas — catálogo de películas vistas (sin duplicados por usuario)
7
+ Emociones — registro de eventos emocionales detectados
8
+ Historial_Peliculas — qué usuario vio qué película, cuándo y con qué emoción
9
+ Ciclo_Recomendacion — ciclo pre/post recomendación vinculado a una película
10
+ """
11
+
12
+ from conexion_bd import ConexionBD
13
+
14
+
15
+ def iniciar_historial_usuario() -> None:
16
+ with ConexionBD.instancia().obtener_conexion() as conn:
17
+ conn.execute("PRAGMA foreign_keys = ON")
18
+
19
+ # ------------------------------------------------------------------ #
20
+ # Usuarios #
21
+ # PK: id (UUID) #
22
+ # FDs: id → todos los atributos #
23
+ # ------------------------------------------------------------------ #
24
+ conn.execute(
25
+ """
26
+ CREATE TABLE IF NOT EXISTS Usuarios (
27
+ id TEXT PRIMARY KEY,
28
+ username TEXT UNIQUE NOT NULL,
29
+ email TEXT,
30
+ password_hash TEXT NOT NULL,
31
+ session_token TEXT,
32
+ created_at TEXT NOT NULL
33
+ )
34
+ """
35
+ )
36
+
37
+ # ------------------------------------------------------------------ #
38
+ # Peliculas #
39
+ # PK: id (IMDb/OMDB id) #
40
+ # FDs: id → titulo, anio, genero, poster_url #
41
+ # Entidad independiente — los datos de la película no dependen #
42
+ # del usuario ni de la sesión. #
43
+ # ------------------------------------------------------------------ #
44
+ conn.execute(
45
+ """
46
+ CREATE TABLE IF NOT EXISTS Peliculas (
47
+ id TEXT PRIMARY KEY,
48
+ titulo TEXT NOT NULL,
49
+ anio TEXT,
50
+ genero TEXT,
51
+ poster_url TEXT
52
+ )
53
+ """
54
+ )
55
+
56
+ # ------------------------------------------------------------------ #
57
+ # Emociones #
58
+ # PK: id (AUTOINCREMENT) #
59
+ # FDs: id → user_id, texto_analizado, emocion, valencia, analizado_en #
60
+ # user_id es FK → Usuarios.id #
61
+ # ------------------------------------------------------------------ #
62
+ conn.execute(
63
+ """
64
+ CREATE TABLE IF NOT EXISTS Emociones (
65
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
66
+ user_id TEXT NOT NULL,
67
+ texto_analizado TEXT,
68
+ emocion TEXT NOT NULL,
69
+ valencia TEXT NOT NULL,
70
+ analizado_en TEXT NOT NULL,
71
+ FOREIGN KEY (user_id) REFERENCES Usuarios(id) ON DELETE CASCADE
72
+ )
73
+ """
74
+ )
75
+ conn.execute(
76
+ """
77
+ CREATE INDEX IF NOT EXISTS idx_emociones_user_tiempo
78
+ ON Emociones (user_id, analizado_en DESC)
79
+ """
80
+ )
81
+
82
+ # ------------------------------------------------------------------ #
83
+ # Historial_Peliculas #
84
+ # PK: id (AUTOINCREMENT) #
85
+ # FDs: id → user_id, pelicula_id, emocion_id, valoracion, #
86
+ # texto_sesion, visto_en #
87
+ # FKs: user_id → Usuarios.id #
88
+ # pelicula_id → Peliculas.id #
89
+ # emocion_id → Emociones.id (emoción detectada al ver la peli) #
90
+ # ------------------------------------------------------------------ #
91
+ conn.execute(
92
+ """
93
+ CREATE TABLE IF NOT EXISTS Historial_Peliculas (
94
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
95
+ user_id TEXT NOT NULL,
96
+ pelicula_id TEXT NOT NULL,
97
+ emocion_id INTEGER,
98
+ valoracion REAL,
99
+ texto_sesion TEXT,
100
+ visto_en TEXT NOT NULL,
101
+ FOREIGN KEY (user_id) REFERENCES Usuarios(id) ON DELETE CASCADE,
102
+ FOREIGN KEY (pelicula_id) REFERENCES Peliculas(id) ON DELETE RESTRICT,
103
+ FOREIGN KEY (emocion_id) REFERENCES Emociones(id) ON DELETE SET NULL
104
+ )
105
+ """
106
+ )
107
+ conn.execute(
108
+ """
109
+ CREATE INDEX IF NOT EXISTS idx_historial_user_tiempo
110
+ ON Historial_Peliculas (user_id, visto_en DESC)
111
+ """
112
+ )
113
+
114
+ # ------------------------------------------------------------------ #
115
+ # Ciclo_Recomendacion #
116
+ # PK: id (AUTOINCREMENT) #
117
+ # FDs: id → user_id, emocion_pre_id, estrategia, creado_en, #
118
+ # pelicula_id, emocion_post_id #
119
+ # FKs: user_id → Usuarios.id #
120
+ # emocion_pre_id → Emociones.id #
121
+ # emocion_post_id → Emociones.id #
122
+ # pelicula_id → Peliculas.id #
123
+ # ------------------------------------------------------------------ #
124
+ conn.execute(
125
+ """
126
+ CREATE TABLE IF NOT EXISTS Ciclo_Recomendacion (
127
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
128
+ user_id TEXT NOT NULL,
129
+ emocion_pre_id INTEGER NOT NULL,
130
+ estrategia INTEGER NOT NULL,
131
+ creado_en TEXT NOT NULL,
132
+ pelicula_id TEXT,
133
+ emocion_post_id INTEGER,
134
+ FOREIGN KEY (user_id) REFERENCES Usuarios(id) ON DELETE CASCADE,
135
+ FOREIGN KEY (emocion_pre_id) REFERENCES Emociones(id) ON DELETE RESTRICT,
136
+ FOREIGN KEY (emocion_post_id) REFERENCES Emociones(id) ON DELETE SET NULL,
137
+ FOREIGN KEY (pelicula_id) REFERENCES Peliculas(id) ON DELETE SET NULL
138
+ )
139
+ """
140
+ )
141
+ conn.execute(
142
+ """
143
+ CREATE INDEX IF NOT EXISTS idx_ciclo_user_tiempo
144
+ ON Ciclo_Recomendacion (user_id, creado_en DESC)
145
+ """
146
+ )
147
+
148
+ conn.commit()
backend/conexion_bd.py ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sqlite3
2
+
3
+ from config import HISTORY_DB_PATH
4
+
5
+
6
+ class ConexionBD:
7
+ """Singleton clásico que centraliza el acceso a la conexión SQLite."""
8
+
9
+ _instancia: "ConexionBD | None" = None
10
+
11
+ def __new__(cls) -> "ConexionBD":
12
+ if cls._instancia is None:
13
+ cls._instancia = super().__new__(cls)
14
+ cls._instancia._db_path = HISTORY_DB_PATH
15
+ return cls._instancia
16
+
17
+ @classmethod
18
+ def instancia(cls) -> "ConexionBD":
19
+ return cls()
20
+
21
+ def obtener_conexion(self) -> sqlite3.Connection:
22
+ conn = sqlite3.connect(self._db_path)
23
+ conn.row_factory = sqlite3.Row
24
+ conn.execute("PRAGMA foreign_keys = ON")
25
+ return conn
backend/config.py CHANGED
@@ -1,6 +1,6 @@
1
  """
2
  Este archivo contiene rutas, constantes y configuracion como modelos pre-cargados
3
- que se utilizan en varias partes del backend.
4
  """
5
 
6
  import os
@@ -31,9 +31,14 @@ EMOTION_MAP = {
31
  POSITIVE_EMOTIONS = {"alegria", "sorpresa", "neutral"}
32
  NEGATIVE_EMOTIONS = {"tristeza", "ira", "miedo", "asco"}
33
 
34
- # Nombre del modelo de Ollama que se utilizara para generar texto del chatbot.
35
- TEXT_MODEL_NAME = "llama3.2"
36
- OLLAMA_URL = "http://localhost:11434/api/generate"
 
 
 
 
 
37
  # Valoracion minima para considerar que al usuario le gusto la pelicula.
38
  LIKE_THRESHOLD = 4.0
39
  # Prior de suavizado para score global (evita sesgo por pocas valoraciones).
 
1
  """
2
  Este archivo contiene rutas, constantes y configuracion como modelos pre-cargados
3
+ que se utilizan en varias partes del
4
  """
5
 
6
  import os
 
31
  POSITIVE_EMOTIONS = {"alegria", "sorpresa", "neutral"}
32
  NEGATIVE_EMOTIONS = {"tristeza", "ira", "miedo", "asco"}
33
 
34
+ # HuggingFace token en https://huggingface.co/settings/tokens
35
+ HF_TOKEN = os.getenv("HF_TOKEN", "")
36
+ # Modelo de emociones: cargado localmente via transformers (no usa Inference API)
37
+ HF_EMOTION_MODEL = "pysentimiento/robertuito-emotion-analysis"
38
+ # Modelo de texto: usa el nuevo router de HuggingFace Inference Providers
39
+ HF_TEXT_MODEL = "meta-llama/Llama-3.2-3B-Instruct"
40
+ HF_INFERENCE_URL = "https://router.huggingface.co/hf-inference/models"
41
+
42
  # Valoracion minima para considerar que al usuario le gusto la pelicula.
43
  LIKE_THRESHOLD = 4.0
44
  # Prior de suavizado para score global (evita sesgo por pocas valoraciones).
backend/dao/ciclo_dao.py ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from conexion_bd import ConexionBD
2
+ from modelos import CicloRecomendacion
3
+
4
+
5
+ class CicloDAO:
6
+ def __init__(self):
7
+ self._bd = ConexionBD.instancia()
8
+
9
+ def obtener_conexion(self):
10
+ return self._bd.obtener_conexion()
11
+
12
+ def crear(self, user_id: str, emocion_pre_id: int, estrategia: int, tiempo_pre: str) -> CicloRecomendacion | None:
13
+ try:
14
+ with self.obtener_conexion() as con:
15
+ cur = con.execute(
16
+ """INSERT INTO Ciclo_Recomendacion
17
+ (user_id, emocion_pre_id, estrategia, creado_en)
18
+ VALUES (?, ?, ?, ?)""",
19
+ (user_id, emocion_pre_id, estrategia, tiempo_pre),
20
+ )
21
+ con.commit()
22
+ return CicloRecomendacion(
23
+ id=cur.lastrowid, user_id=user_id,
24
+ emocion_pre_id=emocion_pre_id, estrategia=estrategia,
25
+ creado_en=tiempo_pre, pelicula_id=None, emocion_post_id=None,
26
+ )
27
+ except Exception:
28
+ return None
29
+
30
+ def obtener_por_id(self, ciclo_id: int, user_id: str) -> CicloRecomendacion | None:
31
+ with self.obtener_conexion() as con:
32
+ row = con.execute(
33
+ """SELECT id, user_id, emocion_pre_id, estrategia, creado_en,
34
+ pelicula_id, emocion_post_id
35
+ FROM Ciclo_Recomendacion
36
+ WHERE id = ? AND user_id = ?""",
37
+ (ciclo_id, user_id),
38
+ ).fetchone()
39
+ if not row:
40
+ return None
41
+ return self._row_a_ciclo(row)
42
+
43
+ def obtener_por_usuario(self, user_id: str, limit: int = 50) -> list[CicloRecomendacion]:
44
+ with self.obtener_conexion() as con:
45
+ rows = con.execute(
46
+ """SELECT id, user_id, emocion_pre_id, estrategia, creado_en,
47
+ pelicula_id, emocion_post_id
48
+ FROM Ciclo_Recomendacion
49
+ WHERE user_id = ?
50
+ ORDER BY creado_en DESC
51
+ LIMIT ?""",
52
+ (user_id, limit),
53
+ ).fetchall()
54
+ return [self._row_a_ciclo(r) for r in rows]
55
+
56
+ def cerrar_ciclo(self, ciclo_id: int, user_id: str,
57
+ pelicula_id: str, emocion_post_id: int) -> bool:
58
+ try:
59
+ with self.obtener_conexion() as con:
60
+ con.execute(
61
+ """UPDATE Ciclo_Recomendacion
62
+ SET pelicula_id = ?, emocion_post_id = ?
63
+ WHERE id = ? AND user_id = ?""",
64
+ (pelicula_id, emocion_post_id, ciclo_id, user_id),
65
+ )
66
+ con.commit()
67
+ return True
68
+ except Exception:
69
+ return False
70
+
71
+ def borrar_por_usuario(self, user_id: str) -> bool:
72
+ try:
73
+ with self.obtener_conexion() as con:
74
+ con.execute("DELETE FROM Ciclo_Recomendacion WHERE user_id = ?", (user_id,))
75
+ con.commit()
76
+ return True
77
+ except Exception:
78
+ return False
79
+
80
+ def _row_a_ciclo(self, row) -> CicloRecomendacion:
81
+ return CicloRecomendacion(
82
+ id=row["id"], user_id=row["user_id"],
83
+ emocion_pre_id=row["emocion_pre_id"], estrategia=row["estrategia"],
84
+ creado_en=row["creado_en"], pelicula_id=row["pelicula_id"],
85
+ emocion_post_id=row["emocion_post_id"],
86
+ )
backend/dao/emocion_dao.py CHANGED
@@ -1,8 +1,80 @@
1
- from db import obtener_conexion_bd
 
 
2
 
3
  class EmocionDAO:
4
  def __init__(self):
5
- self.obtener_conexion = obtener_conexion_bd()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
 
7
- def añadir(self, user_id: str, texto: str, emocion: str, tiempo: str):
8
- con = self.
 
 
 
 
 
1
+ from conexion_bd import ConexionBD
2
+ from modelos import Emocion
3
+
4
 
5
  class EmocionDAO:
6
  def __init__(self):
7
+ self._bd = ConexionBD.instancia()
8
+
9
+ def obtener_conexion(self):
10
+ return self._bd.obtener_conexion()
11
+
12
+ def añadir(self, user_id: str, texto: str, emocion: str, valencia: str, tiempo: str) -> Emocion | None:
13
+ try:
14
+ with self.obtener_conexion() as con:
15
+ cur = con.execute(
16
+ """INSERT INTO Emociones (user_id, texto_analizado, emocion, valencia, analizado_en)
17
+ VALUES (?, ?, ?, ?, ?)""",
18
+ (user_id, texto, emocion, valencia, tiempo),
19
+ )
20
+ con.commit()
21
+ return Emocion(
22
+ id=cur.lastrowid, user_id=user_id,
23
+ texto_analizado=texto, emocion=emocion,
24
+ valencia=valencia, analizado_en=tiempo,
25
+ )
26
+ except Exception:
27
+ return None
28
+
29
+ def obtener_por_usuario(self, user_id: str, limit: int = 50) -> list[Emocion]:
30
+ with self.obtener_conexion() as con:
31
+ rows = con.execute(
32
+ """SELECT id, user_id, texto_analizado, emocion, valencia, analizado_en
33
+ FROM Emociones
34
+ WHERE user_id = ?
35
+ ORDER BY analizado_en DESC
36
+ LIMIT ?""",
37
+ (user_id, limit),
38
+ ).fetchall()
39
+ return [self._row_a_emocion(r) for r in rows]
40
+
41
+ def obtener_ultima(self, user_id: str) -> Emocion | None:
42
+ with self.obtener_conexion() as con:
43
+ row = con.execute(
44
+ """SELECT id, user_id, texto_analizado, emocion, valencia, analizado_en
45
+ FROM Emociones
46
+ WHERE user_id = ?
47
+ ORDER BY analizado_en DESC
48
+ LIMIT 1""",
49
+ (user_id,),
50
+ ).fetchone()
51
+ if not row:
52
+ return None
53
+ return self._row_a_emocion(row)
54
+
55
+ def obtener_por_id(self, emocion_id: int) -> Emocion | None:
56
+ with self.obtener_conexion() as con:
57
+ row = con.execute(
58
+ """SELECT id, user_id, texto_analizado, emocion, valencia, analizado_en
59
+ FROM Emociones WHERE id = ?""",
60
+ (emocion_id,),
61
+ ).fetchone()
62
+ if not row:
63
+ return None
64
+ return self._row_a_emocion(row)
65
+
66
+ def borrar_por_usuario(self, user_id: str) -> bool:
67
+ try:
68
+ with self.obtener_conexion() as con:
69
+ con.execute("DELETE FROM Emociones WHERE user_id = ?", (user_id,))
70
+ con.commit()
71
+ return True
72
+ except Exception:
73
+ return False
74
 
75
+ def _row_a_emocion(self, row) -> Emocion:
76
+ return Emocion(
77
+ id=row["id"], user_id=row["user_id"],
78
+ texto_analizado=row["texto_analizado"], emocion=row["emocion"],
79
+ valencia=row["valencia"], analizado_en=row["analizado_en"],
80
+ )
backend/dao/historial_dao.py CHANGED
@@ -1,26 +1,107 @@
1
- from db import obtener_conexion_bd
2
- from models import PeliculaVista, Emocion
 
3
 
4
  class HistorialDAO:
5
  def __init__(self):
6
- self.obtener_conexion = obtener_conexion_bd()
7
 
8
- def añadir_pelicula(self, user_id: str, titulo: str, emocion: str, rating: float, texto: str, tiempo: str):
9
- con = self.obtener_conexion()
10
- con.execute(
11
- "INSERT INTO historial_peliculas () "
12
- )
13
- con.comit()
14
-
15
- def obtener_por_usuario(self, user_id: str):
16
- con = self.obtener_conexion()
17
- peliculas = con.execute(
18
- "SELECT from historial_peliculas WHERE user_id = ?",
19
- (user_id),
20
- ).fetchall()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
  return [
23
- PeliculaVista(
24
- id = peli["id"], user_id = ["user_id"], movie_id = peli["movie_id"], titulo = peli["title"], emocion = peli["emotion"], valoracion = peli["user_rating"], texto = peli["session_text"], tiempo = peli["viewed_at"],
25
- ) for peli in peliculas
 
 
 
 
 
 
 
 
26
  ]
 
 
 
 
 
 
 
 
1
+ from conexion_bd import ConexionBD
2
+ from modelos import HistorialPelicula, PeliculaVistaVO
3
+
4
 
5
  class HistorialDAO:
6
  def __init__(self):
7
+ self._bd = ConexionBD.instancia()
8
 
9
+ def obtener_conexion(self):
10
+ return self._bd.obtener_conexion()
11
+
12
+ def añadir_pelicula(self, user_id: str, pelicula_id: str, emocion_id: int | None,
13
+ valoracion: float | None, texto: str | None, tiempo: str) -> HistorialPelicula | None:
14
+ try:
15
+ with self.obtener_conexion() as con:
16
+ cur = con.execute(
17
+ """INSERT INTO Historial_Peliculas
18
+ (user_id, pelicula_id, emocion_id, valoracion, texto_sesion, visto_en)
19
+ VALUES (?, ?, ?, ?, ?, ?)""",
20
+ (user_id, pelicula_id, emocion_id, valoracion, texto, tiempo),
21
+ )
22
+ con.commit()
23
+ return HistorialPelicula(
24
+ id=cur.lastrowid, user_id=user_id, pelicula_id=pelicula_id,
25
+ emocion_id=emocion_id, valoracion=valoracion,
26
+ texto_sesion=texto, visto_en=tiempo,
27
+ )
28
+ except Exception:
29
+ return None
30
+
31
+ def obtener_por_usuario(self, user_id: str, limit: int = 50) -> list[HistorialPelicula]:
32
+ with self.obtener_conexion() as con:
33
+ rows = con.execute(
34
+ """SELECT id, user_id, pelicula_id, emocion_id, valoracion, texto_sesion, visto_en
35
+ FROM Historial_Peliculas
36
+ WHERE user_id = ?
37
+ ORDER BY visto_en DESC
38
+ LIMIT ?""",
39
+ (user_id, limit),
40
+ ).fetchall()
41
+ return [self._row_a_historial(r) for r in rows]
42
+
43
+ def obtener_entre_fechas(self, user_id: str, inicio: str, fin: str) -> list[HistorialPelicula]:
44
+ with self.obtener_conexion() as con:
45
+ rows = con.execute(
46
+ """SELECT id, user_id, pelicula_id, emocion_id, valoracion, texto_sesion, visto_en
47
+ FROM Historial_Peliculas
48
+ WHERE user_id = ? AND visto_en >= ? AND visto_en <= ?
49
+ ORDER BY visto_en DESC""",
50
+ (user_id, inicio, fin),
51
+ ).fetchall()
52
+ return [self._row_a_historial(r) for r in rows]
53
 
54
+ def actualizar_valoracion(self, historial_id: int, valoracion: float) -> bool:
55
+ try:
56
+ with self.obtener_conexion() as con:
57
+ con.execute(
58
+ "UPDATE Historial_Peliculas SET valoracion = ? WHERE id = ?",
59
+ (valoracion, historial_id),
60
+ )
61
+ con.commit()
62
+ return True
63
+ except Exception:
64
+ return False
65
+
66
+ def borrar_por_usuario(self, user_id: str) -> bool:
67
+ try:
68
+ with self.obtener_conexion() as con:
69
+ con.execute("DELETE FROM Historial_Peliculas WHERE user_id = ?", (user_id,))
70
+ con.commit()
71
+ return True
72
+ except Exception:
73
+ return False
74
+
75
+ def obtener_vistas_por_usuario(self, user_id: str, limit: int = 50) -> list[PeliculaVistaVO]:
76
+ with self.obtener_conexion() as con:
77
+ rows = con.execute(
78
+ """SELECT h.id, h.user_id, h.pelicula_id, h.valoracion, h.texto_sesion, h.visto_en,
79
+ p.titulo, e.emocion
80
+ FROM Historial_Peliculas h
81
+ LEFT JOIN Peliculas p ON p.id = h.pelicula_id
82
+ LEFT JOIN Emociones e ON e.id = h.emocion_id
83
+ WHERE h.user_id = ?
84
+ ORDER BY h.visto_en DESC
85
+ LIMIT ?""",
86
+ (user_id, limit),
87
+ ).fetchall()
88
  return [
89
+ PeliculaVistaVO(
90
+ id=row["id"],
91
+ user_id=row["user_id"],
92
+ movie_id=row["pelicula_id"],
93
+ titulo=row["titulo"] or "",
94
+ emocion=row["emocion"],
95
+ valoracion=row["valoracion"],
96
+ texto_sesion=row["texto_sesion"],
97
+ visto_en=row["visto_en"],
98
+ )
99
+ for row in rows
100
  ]
101
+
102
+ def _row_a_historial(self, row) -> HistorialPelicula:
103
+ return HistorialPelicula(
104
+ id=row["id"], user_id=row["user_id"], pelicula_id=row["pelicula_id"],
105
+ emocion_id=row["emocion_id"], valoracion=row["valoracion"],
106
+ texto_sesion=row["texto_sesion"], visto_en=row["visto_en"],
107
+ )
backend/dao/pelicula_dao.py ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from conexion_bd import ConexionBD
2
+ from modelos import Pelicula
3
+
4
+
5
+ class PeliculaDAO:
6
+ def __init__(self):
7
+ self._bd = ConexionBD.instancia()
8
+
9
+ def obtener_conexion(self):
10
+ return self._bd.obtener_conexion()
11
+
12
+ def guardar_si_no_existe(self, pelicula_id: str, titulo: str, anio: str | None = None,
13
+ genero: str | None = None, poster_url: str | None = None) -> Pelicula:
14
+ with self.obtener_conexion() as con:
15
+ con.execute(
16
+ """INSERT INTO Peliculas (id, titulo, anio, genero, poster_url)
17
+ VALUES (?, ?, ?, ?, ?)
18
+ ON CONFLICT(id) DO UPDATE SET
19
+ anio = COALESCE(Peliculas.anio, excluded.anio),
20
+ genero = COALESCE(Peliculas.genero, excluded.genero),
21
+ poster_url = COALESCE(Peliculas.poster_url, excluded.poster_url)""",
22
+ (pelicula_id, titulo, anio, genero, poster_url),
23
+ )
24
+ con.commit()
25
+ row = con.execute(
26
+ "SELECT id, titulo, anio, genero, poster_url FROM Peliculas WHERE id = ?",
27
+ (pelicula_id,),
28
+ ).fetchone()
29
+ return self._row_a_pelicula(row)
30
+
31
+ def obtener_por_id(self, pelicula_id: str) -> Pelicula | None:
32
+ with self.obtener_conexion() as con:
33
+ row = con.execute(
34
+ "SELECT id, titulo, anio, genero, poster_url FROM Peliculas WHERE id = ?",
35
+ (pelicula_id,),
36
+ ).fetchone()
37
+ if not row:
38
+ return None
39
+ return self._row_a_pelicula(row)
40
+
41
+ def buscar_por_titulo(self, texto: str, limit: int = 20) -> list[Pelicula]:
42
+ with self.obtener_conexion() as con:
43
+ rows = con.execute(
44
+ """SELECT id, titulo, anio, genero, poster_url FROM Peliculas
45
+ WHERE titulo LIKE ?
46
+ ORDER BY titulo
47
+ LIMIT ?""",
48
+ (f"%{texto}%", limit),
49
+ ).fetchall()
50
+ return [self._row_a_pelicula(r) for r in rows]
51
+
52
+ def actualizar(self, pelicula_id: str, titulo: str | None = None, anio: str | None = None,
53
+ genero: str | None = None, poster_url: str | None = None) -> bool:
54
+ campos = {k: v for k, v in
55
+ {"titulo": titulo, "anio": anio, "genero": genero, "poster_url": poster_url}.items()
56
+ if v is not None}
57
+ if not campos:
58
+ return False
59
+ sets = ", ".join(f"{col} = ?" for col in campos)
60
+ valores = list(campos.values()) + [pelicula_id]
61
+ try:
62
+ with self.obtener_conexion() as con:
63
+ con.execute(f"UPDATE Peliculas SET {sets} WHERE id = ?", valores)
64
+ con.commit()
65
+ return True
66
+ except Exception:
67
+ return False
68
+
69
+ def _row_a_pelicula(self, row) -> Pelicula:
70
+ return Pelicula(
71
+ id=row["id"], titulo=row["titulo"], anio=row["anio"],
72
+ genero=row["genero"], poster_url=row["poster_url"],
73
+ )
backend/dao/usuario_dao.py CHANGED
@@ -1,94 +1,117 @@
1
- from db import obtener_conexion_bd
2
- from models import Usuario
3
  import uuid
4
- import datetime
 
 
 
 
 
 
5
 
6
  class UsuarioDao:
7
  def __init__(self):
8
- self.obtener_conexion = obtener_conexion_bd()
 
 
 
9
 
10
- # Crea usuario
11
- def registrar(self, nombre: str, contraseña: str):
12
  user_id = str(uuid.uuid4())
13
  token = str(uuid.uuid4())
14
-
15
  try:
16
- con = self.obtener_conexion()
17
- usuario = con.execute(
18
- """INSERT INTO usuarios (id, username , password_hash, session_token, created_at)
19
- VALUES (?, ?, ?, ?, ?, ?)""",
20
- (user_id, nombre, hashear(contraseña), token, datetime.now().isoformat()),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
  )
22
  con.commit()
23
-
24
- return Usuario(user_id, nombre, token)
25
- except Exception:
 
 
 
 
 
 
26
  return None
27
-
28
- # Inicia sesion
29
- def login(self, nombre: str, contraseña: str):
30
- con = self.obtener_conexion()
31
- usuario = con.execute(
32
- "SELECT id, username, password_hash FROM usuarios WHERE username = ?",
33
- (nombre),
34
  ).fetchone()
35
- if not usuario or not comprobar_contraseña(usuario["password_hash", contraseña]):
36
  return None
37
- token = str(uuid.uiid4())
38
- con = self.obtener_conexion()
39
- con.execute("UPDATE Usuarios SET session_token = ? where id = ?",
40
- (token, usuario["id"]))
41
- con.commit()
42
- return Usuario(id = usuario["id"], nombre = usuario["username"], token = token)
43
-
44
- # Obtener por username
45
- def obtener_por_nombre(self, nombre: str):
46
- con = self.obtener_conexion()
47
- usuario = con.execute(
48
- "SELECT id, username FROM usuarios WHERE username = ?",
49
- (nombre),
50
- ).fetchone()
51
- if not usuario: return None
52
 
53
- return Usuario(usuario["id"], usuario["name"], usuario["token"])
54
-
55
- # Obtener por token
56
- def obtener_por_token(self, token:str):
57
- con = self.obtener_conexion()
58
- usuario = con.execute(
59
- "SELECT id, username FROM usuarios WHERE token = ?",
60
- (token),
61
- ).fetchone()
62
- if not usuario: return None
63
 
64
- return Usuario(usuario["id"], usuario["name"], usuario["token"])
65
-
66
- def actualizar_token(self, user_id: str, token: str):
67
- con = self.obtener_conexion()
68
- con.execute(
69
- "UPDATE Usuarios set session_token = ? where user_id = ?",
70
- (token, user_id),
71
- )
72
- con.comit()
73
- return Usuario()
74
-
75
- def actualizar_contraseña(self, user_id: str, contraseña_hash: str):
76
- con = self.obtener_conexion()
77
- con.execute(
78
- "UPDATE Usuarios set password = ? where user_id = ?",
79
- (contraseña_hash, user_id)
80
- )
81
- con.comit()
82
- return Usuario()
83
-
84
- def eliminar(self, user_id: str):
85
- con = self.obtener_conexion()
86
- con.execute(
87
- "DELETE from Usuarios where user_id = ?",
88
- (user_id),
89
- )
90
- con.commit()
91
- return
92
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
93
 
94
-
 
 
 
 
 
 
 
 
 
 
1
  import uuid
2
+ from datetime import datetime, timezone
3
+
4
+ from werkzeug.security import check_password_hash, generate_password_hash
5
+
6
+ from conexion_bd import ConexionBD
7
+ from modelos import Usuario
8
+
9
 
10
  class UsuarioDao:
11
  def __init__(self):
12
+ self._bd = ConexionBD.instancia()
13
+
14
+ def obtener_conexion(self):
15
+ return self._bd.obtener_conexion()
16
 
17
+ def registrar(self, nombre: str, contraseña: str) -> Usuario | None:
 
18
  user_id = str(uuid.uuid4())
19
  token = str(uuid.uuid4())
20
+ created_at = datetime.now(timezone.utc).isoformat()
21
  try:
22
+ with self.obtener_conexion() as con:
23
+ con.execute(
24
+ """INSERT INTO Usuarios (id, username, email, password_hash, session_token, created_at)
25
+ VALUES (?, ?, ?, ?, ?, ?)""",
26
+ (user_id, nombre, "", generate_password_hash(contraseña), token, created_at),
27
+ )
28
+ con.commit()
29
+ return Usuario(id=user_id, username=nombre, token=token)
30
+ except Exception:
31
+ return None
32
+
33
+ def login(self, nombre: str, contraseña: str) -> Usuario | None:
34
+ with self.obtener_conexion() as con:
35
+ row = con.execute(
36
+ "SELECT id, username, password_hash FROM Usuarios WHERE username = ?",
37
+ (nombre,),
38
+ ).fetchone()
39
+ if not row or not check_password_hash(row["password_hash"], contraseña):
40
+ return None
41
+ token = str(uuid.uuid4())
42
+ with self.obtener_conexion() as con:
43
+ con.execute(
44
+ "UPDATE Usuarios SET session_token = ? WHERE id = ?",
45
+ (token, row["id"]),
46
  )
47
  con.commit()
48
+ return Usuario(id=row["id"], username=row["username"], token=token)
49
+
50
+ def obtener_por_id(self, user_id: str) -> Usuario | None:
51
+ with self.obtener_conexion() as con:
52
+ row = con.execute(
53
+ "SELECT id, username, session_token FROM Usuarios WHERE id = ?",
54
+ (user_id,),
55
+ ).fetchone()
56
+ if not row:
57
  return None
58
+ return Usuario(id=row["id"], username=row["username"], token=row["session_token"] or "")
59
+
60
+ def obtener_por_nombre(self, nombre: str) -> Usuario | None:
61
+ with self.obtener_conexion() as con:
62
+ row = con.execute(
63
+ "SELECT id, username, session_token FROM Usuarios WHERE username = ?",
64
+ (nombre,),
65
  ).fetchone()
66
+ if not row:
67
  return None
68
+ return Usuario(id=row["id"], username=row["username"], token=row["session_token"] or "")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69
 
70
+ def obtener_por_token(self, token: str) -> Usuario | None:
71
+ with self.obtener_conexion() as con:
72
+ row = con.execute(
73
+ "SELECT id, username, session_token FROM Usuarios WHERE session_token = ?",
74
+ (token,),
75
+ ).fetchone()
76
+ if not row:
77
+ return None
78
+ return Usuario(id=row["id"], username=row["username"], token=row["session_token"])
 
79
 
80
+ def actualizar_token(self, user_id: str, token: str) -> bool:
81
+ try:
82
+ with self.obtener_conexion() as con:
83
+ con.execute("UPDATE Usuarios SET session_token = ? WHERE id = ?", (token, user_id))
84
+ con.commit()
85
+ return True
86
+ except Exception:
87
+ return False
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
88
 
89
+ def cerrar_sesion(self, token: str) -> bool:
90
+ try:
91
+ with self.obtener_conexion() as con:
92
+ con.execute("UPDATE Usuarios SET session_token = NULL WHERE session_token = ?", (token,))
93
+ con.commit()
94
+ return True
95
+ except Exception:
96
+ return False
97
+
98
+ def actualizar_contraseña(self, user_id: str, contraseña_nueva: str) -> bool:
99
+ try:
100
+ with self.obtener_conexion() as con:
101
+ con.execute(
102
+ "UPDATE Usuarios SET password_hash = ? WHERE id = ?",
103
+ (generate_password_hash(contraseña_nueva), user_id),
104
+ )
105
+ con.commit()
106
+ return True
107
+ except Exception:
108
+ return False
109
 
110
+ def eliminar(self, user_id: str) -> bool:
111
+ try:
112
+ with self.obtener_conexion() as con:
113
+ con.execute("DELETE FROM Usuarios WHERE id = ?", (user_id,))
114
+ con.commit()
115
+ return True
116
+ except Exception:
117
+ return False
backend/db.py DELETED
@@ -1,112 +0,0 @@
1
- """
2
- Este archivo se encarga de gestionar la conexion con la BD
3
- así como de crear las tablas iniciales necesarias para almacenar
4
- el historial de visionado
5
- """
6
-
7
- import sqlite3
8
-
9
- from config import HISTORY_DB_PATH
10
-
11
-
12
- def obtener_conexion_bd() -> sqlite3.Connection:
13
- """
14
- Crea la conexión con la base de datos SQLite
15
- """
16
- # Cada conexion usa sqlite3.Row para acceder por nombre de columna.
17
- conn = sqlite3.connect(HISTORY_DB_PATH) # Se conecta a la BD en la ruta configurada
18
- conn.row_factory = sqlite3.Row # Permite acceder a las filas como diccionarios por nombre de columna
19
- return conn
20
-
21
-
22
- def iniciar_historial_usuario() -> None:
23
- """
24
- Inicia la BD creando todas las tablas necesarias
25
- """
26
- with obtener_conexion_bd() as conn:
27
- # Historial de visionado con rating opcional por usuario.
28
- conn.execute(
29
- """
30
- CREATE TABLE IF NOT EXISTS historial_peliculas (
31
- id INTEGER PRIMARY KEY AUTOINCREMENT,
32
- user_id TEXT NOT NULL,
33
- movie_id TEXT NOT NULL,
34
- title TEXT,
35
- emotion TEXT,
36
- user_rating REAL,
37
- session_text TEXT,
38
- viewed_at TEXT NOT NULL
39
- )
40
- """
41
- )
42
- # Compatibilidad con BDs existentes creadas sin columna de valoracion.
43
- columns = conn.execute("PRAGMA table_info(historial_peliculas)").fetchall()
44
- column_names = {row[1] for row in columns}
45
- if "user_rating" not in column_names:
46
- conn.execute("ALTER TABLE historial_peliculas ADD COLUMN user_rating REAL")
47
-
48
- # Indices para lecturas frecuentes por usuario + fecha.
49
- conn.execute(
50
- """
51
- CREATE INDEX IF NOT EXISTS idx_historial_peliculas_user_viewed_at
52
- ON historial_peliculas (user_id, viewed_at DESC)
53
- """
54
- )
55
- # Crea una tabla para eventos de emocion detectada con texto analizado.
56
- # Tiene para los distintos usuarios un registro del texto a analizar, la emocion detectada y cuando se hizo
57
- conn.execute(
58
- """
59
- CREATE TABLE IF NOT EXISTS eventos_emociones (
60
- id INTEGER PRIMARY KEY AUTOINCREMENT,
61
- user_id TEXT NOT NULL,
62
- text TEXT,
63
- emotion TEXT NOT NULL,
64
- analyzed_at TEXT NOT NULL
65
- )
66
- """
67
- )
68
- conn.execute(
69
- """
70
- CREATE INDEX IF NOT EXISTS idx_eventos_emociones_user_analyzed_at
71
- ON eventos_emociones (user_id, analyzed_at DESC)
72
- """
73
- )
74
- # Tabla para ciclos de recomendacion con datos pre y post recomendacion.
75
- conn.execute(
76
- """
77
- CREATE TABLE IF NOT EXISTS ciclos_recomendaciones (
78
- id INTEGER PRIMARY KEY AUTOINCREMENT,
79
- user_id TEXT NOT NULL,
80
- pre_text TEXT,
81
- pre_emotion TEXT NOT NULL,
82
- pre_valence TEXT NOT NULL,
83
- recommendation_mode TEXT NOT NULL,
84
- created_at TEXT NOT NULL,
85
- selected_movie_id TEXT,
86
- selected_movie_title TEXT,
87
- post_text TEXT,
88
- post_emotion TEXT,
89
- post_valence TEXT,
90
- post_analyzed_at TEXT
91
- )
92
- """
93
- )
94
- conn.execute(
95
- """
96
- CREATE INDEX IF NOT EXISTS idx_ciclos_recomendaciones_user_created_at
97
- ON ciclos_recomendaciones (user_id, created_at DESC)
98
- """
99
- )
100
- conn.execute(
101
- """
102
- CREATE TABLE IF NOT EXISTS usuarios (
103
- id TEXT PRIMARY KEY,
104
- username TEXT UNIQUE NOT NULL,
105
- email TEXT,
106
- password_hash TEXT NOT NULL,
107
- session_token TEXT,
108
- created_at TEXT NOT NULL
109
- )
110
- """
111
- )
112
- conn.commit()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
backend/main.py ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Este archivo es el punto de entrada del servidor.
3
+ Importa la app Flask singleton y la ejecuta en el puerto 5000.
4
+ """
5
+
6
+ from aplicacion import app
7
+
8
+ if __name__ == "__main__":
9
+ app.run(port=5000)
backend/modelos.py ADDED
@@ -0,0 +1,132 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from dataclasses import dataclass, field
2
+
3
+
4
+ # ---------------------------------------------------------------------------
5
+ # Entidades de dominio (mapeadas 1:1 con tablas)
6
+ # ---------------------------------------------------------------------------
7
+
8
+ @dataclass
9
+ class Usuario:
10
+ id: str
11
+ username: str
12
+ token: str
13
+
14
+
15
+ @dataclass
16
+ class Pelicula:
17
+ id: str
18
+ titulo: str
19
+ anio: str | None
20
+ genero: str | None
21
+ poster_url: str | None
22
+
23
+
24
+ @dataclass
25
+ class Emocion:
26
+ id: int
27
+ user_id: str
28
+ texto_analizado: str | None
29
+ emocion: str
30
+ valencia: str
31
+ analizado_en: str
32
+
33
+
34
+ @dataclass
35
+ class HistorialPelicula:
36
+ id: int
37
+ user_id: str
38
+ pelicula_id: str
39
+ emocion_id: int | None
40
+ valoracion: float | None
41
+ texto_sesion: str | None
42
+ visto_en: str
43
+
44
+
45
+ @dataclass
46
+ class CicloRecomendacion:
47
+ id: int
48
+ user_id: str
49
+ emocion_pre_id: int
50
+ estrategia: int
51
+ creado_en: str
52
+ pelicula_id: str | None
53
+ emocion_post_id: int | None
54
+
55
+
56
+ # ---------------------------------------------------------------------------
57
+ # Value Objects (VOs) — datos de solo lectura que cruzan capas
58
+ # ---------------------------------------------------------------------------
59
+
60
+ @dataclass(frozen=True)
61
+ class EmocionVO:
62
+ """Vista plana de una Emocion para transferir entre capas."""
63
+ id: int
64
+ emocion: str
65
+ valencia: str
66
+ analizado_en: str
67
+ texto_analizado: str | None = None
68
+
69
+ @staticmethod
70
+ def desde(e: Emocion) -> "EmocionVO":
71
+ return EmocionVO(
72
+ id=e.id,
73
+ emocion=e.emocion,
74
+ valencia=e.valencia,
75
+ analizado_en=e.analizado_en,
76
+ texto_analizado=e.texto_analizado,
77
+ )
78
+
79
+
80
+ @dataclass(frozen=True)
81
+ class PeliculaVistaVO:
82
+ """Vista plana de Historial + Pelicula para serializar a JSON."""
83
+ id: int
84
+ user_id: str
85
+ movie_id: str
86
+ titulo: str
87
+ emocion: str | None
88
+ valoracion: float | None
89
+ texto_sesion: str | None
90
+ visto_en: str
91
+
92
+
93
+ # ---------------------------------------------------------------------------
94
+ # Modelos internos de servicios
95
+ # ---------------------------------------------------------------------------
96
+
97
+ @dataclass
98
+ class ContextoEmocional:
99
+ emocion_es: str
100
+ arousal_actual: float
101
+ valencia_actual: float
102
+ historico_arousal: list[float] = field(default_factory=list)
103
+
104
+
105
+ @dataclass
106
+ class PerfilUsuario:
107
+ peliculas_vistas: set[str] = field(default_factory=set)
108
+ probabilidades_generos: dict[str, float] = field(default_factory=dict)
109
+ contador_generos_gustados: dict = field(default_factory=dict)
110
+ medias_rating_por_genero: dict[str, float] = field(default_factory=dict)
111
+ zona_confort: set[str] = field(default_factory=set)
112
+ ranking_generos: dict[str, int] = field(default_factory=dict)
113
+ tiene_historial: bool = False
114
+
115
+
116
+ @dataclass
117
+ class ResultadoAnalisis:
118
+ emociones: list[dict]
119
+ emocion_dominante: str
120
+ valencia_dominante: str
121
+ valencia_continua: float
122
+ arousal_actual: float
123
+ estrategia: str
124
+ debug_recomendacion: dict
125
+ historico_arousal_size: int
126
+ emocion_anterior: str | None
127
+ modo_recomendacion: str
128
+ ciclo_recomendacion_id: int | None
129
+ chatbot_texto: str
130
+ chatbot_fuente: str
131
+ pelicula_transicion: dict | None
132
+ recomendaciones: list[dict]
backend/models.py DELETED
@@ -1,45 +0,0 @@
1
- from collections import Counter
2
- from dataclasses import dataclass, field
3
- from datetime import datetime
4
-
5
-
6
- @dataclass
7
- class Usuario:
8
- id: str
9
- name: str
10
- token: str
11
-
12
- @dataclass
13
- class PeliculaVista:
14
- id: int
15
- user_id: str
16
- movie_id: str
17
- titulo: str
18
- emocion: str
19
- valoracion: float | None
20
- texto: str
21
- tiempo: str
22
-
23
- @dataclass
24
- class Emocion:
25
- id: int
26
- user_id: int
27
- texto: str
28
- emocion: str
29
- tiempo: str
30
-
31
- @dataclass
32
- class Ciclo:
33
- id: int
34
- user_id: str
35
- texto_pre: str
36
- texto_post: str
37
- emocion_pre_: str
38
- emocion_post: str
39
- valencia_pre: str
40
- valencia_post: str
41
- tiempo_pre: str
42
- tiempo_post: str
43
- estrategia: int
44
- movie_id: str | None
45
- movie_titulo: str | None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
backend/repositories/__init__.py DELETED
@@ -1,2 +0,0 @@
1
- #Se utiliza para marcar el paquete de Python
2
- #Asi los imports relativos dentro del backend funcionan
 
 
 
backend/repositories/auth_repository.py DELETED
@@ -1,91 +0,0 @@
1
- import uuid
2
- from datetime import datetime, timezone
3
-
4
- from werkzeug.security import check_password_hash, generate_password_hash
5
-
6
- from db import obtener_conexion_bd
7
-
8
-
9
- def registrar_usuario(username: str, password: str, email: str = "") -> dict | None:
10
- user_id = str(uuid.uuid4())
11
- created_at = datetime.now(timezone.utc).isoformat()
12
- password_hash = generate_password_hash(password)
13
- token = str(uuid.uuid4())
14
- try:
15
- with obtener_conexion_bd() as conn:
16
- conn.execute(
17
- """INSERT INTO usuarios (id, username, email, password_hash, session_token, created_at)
18
- VALUES (?, ?, ?, ?, ?, ?)""",
19
- (user_id, username, email, password_hash, token, created_at),
20
- )
21
- conn.commit()
22
- return {"user_id": user_id, "username": username, "token": token}
23
- except Exception:
24
- return None
25
-
26
-
27
- def iniciar_sesion(username: str, password: str) -> dict | None:
28
- with obtener_conexion_bd() as conn:
29
- row = conn.execute(
30
- "SELECT id, username, password_hash FROM usuarios WHERE username = ?",
31
- (username,),
32
- ).fetchone()
33
- if not row or not check_password_hash(row["password_hash"], password):
34
- return None
35
- token = str(uuid.uuid4())
36
- with obtener_conexion_bd() as conn:
37
- conn.execute("UPDATE usuarios SET session_token = ? WHERE id = ?", (token, row["id"]))
38
- conn.commit()
39
- return {"user_id": row["id"], "username": row["username"], "token": token}
40
-
41
-
42
- def obtener_usuario_por_token(token: str) -> dict | None:
43
- if not token:
44
- return None
45
- with obtener_conexion_bd() as conn:
46
- row = conn.execute(
47
- "SELECT id, username FROM usuarios WHERE session_token = ?",
48
- (token,),
49
- ).fetchone()
50
- return dict(row) if row else None
51
-
52
-
53
- def cerrar_sesion(token: str) -> None:
54
- if not token:
55
- return
56
- with obtener_conexion_bd() as conn:
57
- conn.execute("UPDATE usuarios SET session_token = NULL WHERE session_token = ?", (token,))
58
- conn.commit()
59
-
60
-
61
- def cambiar_contraseña(token: str, old_password: str, new_password: str) -> bool:
62
- user = obtener_usuario_por_token(token)
63
- if not user:
64
- return False
65
- with obtener_conexion_bd() as conn:
66
- row = conn.execute(
67
- "SELECT password_hash FROM usuarios WHERE id = ?", (user["id"],)
68
- ).fetchone()
69
- if not row or not check_password_hash(row["password_hash"], old_password):
70
- return False
71
- with obtener_conexion_bd() as conn:
72
- conn.execute(
73
- "UPDATE usuarios SET password_hash = ? WHERE id = ?",
74
- (generate_password_hash(new_password), user["id"]),
75
- )
76
- conn.commit()
77
- return True
78
-
79
-
80
- def eliminar_cuenta(token: str) -> dict | None:
81
- user = obtener_usuario_por_token(token)
82
- if not user:
83
- return None
84
- user_id = user["id"]
85
- with obtener_conexion_bd() as conn:
86
- conn.execute("DELETE FROM historial_peliculas WHERE user_id = ?", (user_id,))
87
- conn.execute("DELETE FROM eventos_emociones WHERE user_id = ?", (user_id,))
88
- conn.execute("DELETE FROM ciclos_recomendaciones WHERE user_id = ?", (user_id,))
89
- conn.execute("DELETE FROM usuarios WHERE id = ?", (user_id,))
90
- conn.commit()
91
- return user
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
backend/repositories/history_repository.py DELETED
@@ -1,522 +0,0 @@
1
- """
2
- Este archivo contiene funciones para realizar sobre la BD operaciones sobre el
3
- historial de visionado del usuario y el seguimiento del ciclo de recomendacion
4
- siguiendo eventos emocionales del usuario antes y despues de la recomendacion.
5
-
6
- Solo hay funciones que acceden a la informacion de las tablas de la BD
7
- """
8
-
9
- # Se importan la funcion que conecta con la BD de Sqlite para ejecutar las consultas
10
- from db import obtener_conexion_bd
11
-
12
- def borrar_historial_usuario(user_id: str) -> int:
13
- """
14
- Borra el historial de visionado de un usuario.
15
-
16
- Args:
17
- - user_id: El ID del usuario cuyo historial se desea borrar.
18
- Returns:
19
- - El número de registros eliminados del historial.
20
- """
21
- with obtener_conexion_bd() as conn:
22
- # Se ejecuta DELETE para borrar historial de visionado del usuario.
23
- cur = conn.execute(
24
- """
25
- DELETE FROM historial_peliculas
26
- WHERE user_id = ?
27
- """,
28
- (user_id,),
29
- )
30
- conn.commit() # Se hace commit para confirmar cambios con insert, update o delete
31
- return int(cur.rowcount or 0)
32
-
33
-
34
- def obtener_historial_usuario(user_id: str, limit: int = 200) -> list[dict]:
35
- """
36
- Obtiene las filas del historial de visionado de un usuario.
37
-
38
- Args:
39
- - user_id: El ID del usuario cuyo historial se desea obtener.
40
- - limit: El número máximo de registros a obtener.
41
-
42
- Returns:
43
- - Una lista de diccionarios con los registros del historial.
44
- """
45
- with obtener_conexion_bd() as conn:
46
- # Consulta base para perfilar recomendaciones con movie_id y user_rating.
47
- filas = conn.execute(
48
- """
49
- SELECT movie_id, user_rating
50
- FROM historial_peliculas
51
- WHERE user_id = ?
52
- ORDER BY viewed_at DESC
53
- LIMIT ?
54
- """,
55
- (user_id, limit),
56
- ).fetchall()
57
-
58
- # Devuelve un diccionario con (movie_id, user_rating) para cada fila del historial del usuario
59
- return [dict(fila) for fila in filas]
60
-
61
-
62
- def añadir_evento_emocional(user_id: str, text: str, emotion: str, analyzed_at: str) -> int | None:
63
- """
64
- Añade cuando se le detecta una emocion al usuario
65
-
66
- Args:
67
- - user_id: El ID del usuario al que se le detecta la emocion.
68
- - text: El texto analizado para detectar la emocion.
69
- - emotion: La emocion detectada.
70
- - analyzed_at: La fecha y hora en formato ISO cuando se analizo el texto.
71
- Returns:
72
- - El ID del evento emocional insertado o None si no se pudo insertar.
73
- """
74
-
75
- if not user_id:
76
- return None
77
-
78
- with obtener_conexion_bd() as conn:
79
- # Registro temporal de cada analisis emocional del usuario.
80
- cur = conn.execute(
81
- """
82
- INSERT INTO eventos_emociones (user_id, text, emotion, analyzed_at)
83
- VALUES (?, ?, ?, ?)
84
- """,
85
- (user_id, text, emotion, analyzed_at),
86
- )
87
- conn.commit()
88
- return cur.lastrowid
89
-
90
-
91
- def crear_ciclo_recomendacion(
92
- user_id: str,
93
- pre_text: str,
94
- pre_emotion: str,
95
- pre_valence: str,
96
- recommendation_mode: str,
97
- created_at: str,
98
- ) -> int | None:
99
- """
100
- Crea un nuevo ciclo de recomendacion para seguimiento posterior.
101
- Un ciclo de recomendacion contiene el estado emocional previo a la recomendacion, el modo de recomendacion aplicado,
102
- y luego se actualiza con el estado posterior y pelicula elegida por el usuario.
103
-
104
- Args:
105
- - user_id: El ID del usuario para el que se crea el ciclo de recomendacion
106
- - pre_text: El texto analizado para detectar la emocion previa a la recomendacion
107
- - pre_emotion: La emocion detectada previa a la recomendacion
108
- - pre_valence: La valencia detectada previa a la recomendacion
109
- - recommendation_mode: El modo de recomendacion aplicado (exploracion o zona conocida)
110
- - created_at: La fecha y hora en formato ISO cuando se creo el ciclo de recomendación
111
- Returns:
112
- - El ID del ciclo de recomendacion creado o None si no se pudo crear
113
-
114
- """
115
- if not user_id:
116
- return None
117
-
118
- with obtener_conexion_bd() as conn:
119
- # Se guarda el estado previo a la recomendacion para seguimiento posterior.
120
- cur = conn.execute(
121
- """
122
- INSERT INTO ciclos_recomendaciones (
123
- user_id,
124
- pre_text,
125
- pre_emotion,
126
- pre_valence,
127
- recommendation_mode,
128
- created_at
129
- )
130
- VALUES (?, ?, ?, ?, ?, ?)
131
- """,
132
- (user_id, pre_text, pre_emotion, pre_valence, recommendation_mode, created_at),
133
- )
134
- conn.commit()
135
- return cur.lastrowid
136
-
137
-
138
- def obtener_ciclo_recomendacion(cycle_id: int, user_id: str) -> dict | None:
139
- """
140
- Funcion que devuelve un ciclo de recomendacion de la BD
141
-
142
- Args:
143
- - cycle_id: El ID del ciclo de recomendacion a obtener
144
- - user_id: El ID del usuario al que pertenece el ciclo de recomendacion
145
- Returns:
146
- - Un diccionario con los datos del ciclo de recomendacion o None si no se encuentra
147
- """
148
-
149
- with obtener_conexion_bd() as conn:
150
- fila = conn.execute(
151
- """
152
- SELECT id, user_id, pre_text, pre_emotion, pre_valence, recommendation_mode,
153
- created_at, selected_movie_id, selected_movie_title,
154
- post_text, post_emotion, post_valence, post_analyzed_at
155
- FROM ciclos_recomendaciones
156
- WHERE id = ? AND user_id = ?
157
- LIMIT 1
158
- """,
159
- (cycle_id, user_id),
160
- ).fetchone() # Se usa fetchone porque solo hay un ciclo correspondiente
161
- return dict(fila) if fila else None
162
-
163
- def obtener_ciclos_usuario(user_id: str, limit: int = 500) -> list[dict]:
164
- """
165
- Funcion encargada de extraer todos los ciclos de recomendacion para poder aprender a inferir
166
-
167
- Args:
168
- - user_id: Identificador del usuario a obtener ciclos
169
- - limit: num maximo de ciclos a obtener
170
- Returns:
171
- - Array con los ciclos de recomendacion del usuario
172
- """
173
- if not user_id:
174
- return []
175
- with obtener_conexion_bd() as conn:
176
- filas = conn.execute(
177
- """
178
- SELECT id, user_id, pre_emotion, pre_valence, recommendation_mode, created_at,
179
- selected_movie_id,
180
- post_emotion, post_valence, post_analyzed_at
181
- FROM ciclos_recomendaciones
182
- WHERE user_id = ?
183
- AND selected_movie_id IS NOT NULL
184
- AND post_emotion IS NOT NULL
185
- ORDER BY created_at DESC
186
- LIMIT ?
187
- """,
188
- (user_id, limit),
189
- ).fetchall()
190
- return [dict(f) for f in filas]
191
-
192
- def obtener_rating_pelicula_recomendada(
193
- user_id: str,
194
- movie_id: str,
195
- center_iso: str,
196
- window_minutes: int = 240,
197
- ) -> float | None:
198
- """
199
- Funcion que devuelve el rating del historial para esa película visto cerca del instante del ciclo.
200
- Como el rating de la pelicula recomendada no se almacena en el ciclo de recomendacion se accede al hisrtoal
201
-
202
- Args:
203
- - user_id: Identificador del usuario que ha visto la pelicula
204
- - movie_id: Identificador de la pelicula recomendada que ha visto
205
- - center_iso:
206
- - window_minutes:
207
- Returns:
208
- -
209
- """
210
- if not user_id or not movie_id or not center_iso:
211
- return None
212
-
213
- with obtener_conexion_bd() as conn:
214
- fila = conn.execute(
215
- """
216
- SELECT user_rating, viewed_at
217
- FROM historial_peliculas
218
- WHERE user_id = ?
219
- AND movie_id = ?
220
- AND viewed_at >= datetime(?, '-' || ? || ' minutes')
221
- AND viewed_at <= datetime(?, '+' || ? || ' minutes')
222
- ORDER BY ABS(strftime('%s', viewed_at) - strftime('%s', ?)) ASC
223
- LIMIT 1
224
- """,
225
- (user_id, movie_id, center_iso, window_minutes, center_iso, window_minutes, center_iso),
226
- ).fetchone()
227
-
228
- if not fila:
229
- return None
230
- try:
231
- return float(fila["user_rating"]) if fila["user_rating"] is not None else None
232
- except (TypeError, ValueError, KeyError):
233
- return None
234
-
235
- def guardar_estado_posterior(
236
- cycle_id: int,
237
- user_id: str,
238
- post_text: str,
239
- post_emotion: str,
240
- post_valence: str,
241
- post_analyzed_at: str,
242
- movie_id: str,
243
- movie_title: str,
244
- ) -> None:
245
- """
246
- Funcion que añade a un ciclo de recomendacion el estado posterior a la recomendacion
247
- Se le preguntara al usuario que introduzca un rating de la pelicula vista
248
- Asi como que exprese su emocion una vez vista la pelicula, para medir el cambio emocional y de valencia
249
-
250
- Args:
251
- - cycle_id: El ID del ciclo de recomendacion a actualizar
252
- - user_id: El ID del usuario al que pertenece el ciclo de recomendacion
253
- - post_text: El texto analizado para detectar la emocion posterior a la recomendacion
254
- - post_emotion: La emocion detectada posterior a la recomendacion
255
- - post_valence: La valencia detectada posterior a la recomendacion
256
- - post_analyzed_at: La fecha y hora en formato ISO cuando se analizo el texto posterior a la recomendacion
257
- - movie_id: El ID de la pelicula vista que se le pregunta al usuario
258
- - movie_title: El titulo de la pelicula vista que se le pregunta al usuario
259
- Returns:
260
- - None (es un update de una instancia ya creada en la BD)
261
- """
262
- with obtener_conexion_bd() as conn:
263
- # Actualiza el ciclo con pelicula elegida y estado emocional posterior.
264
- conn.execute(
265
- """
266
- UPDATE ciclos_recomendaciones
267
- SET selected_movie_id = ?,
268
- selected_movie_title = ?,
269
- post_text = ?,
270
- post_emotion = ?,
271
- post_valence = ?,
272
- post_analyzed_at = ?
273
- WHERE id = ? AND user_id = ?
274
- """,
275
- (movie_id, movie_title, post_text, post_emotion, post_valence, post_analyzed_at, cycle_id, user_id),
276
- )
277
- conn.commit()
278
-
279
-
280
- def obtener_ultima_emocion(user_id: str) -> dict | None:
281
- """
282
- Funcion que devuelve el ultimo evento emocional registrado para un usuario
283
- Se utiliza para detectar la emocion previa a una recomendacion y medir transiciones emocionales
284
-
285
- Args:
286
- - user_id: El ID del usuario del que se desea obtener el ultimo evento emocional
287
- Returns:
288
- - Un diccionario con los datos del ultimo evento emocional o None si no se encuentra. El diccionario contiene las claves: id, user_id, text, emotion, analyzed_at
289
- """
290
-
291
- if not user_id:
292
- return None
293
-
294
- with obtener_conexion_bd() as conn:
295
- # Se realiza la consulta, se ordena descendentemente por fecha y nos quedamos con la ultima instancia
296
- fila = conn.execute(
297
- """
298
- SELECT id, user_id, text, emotion, analyzed_at
299
- FROM eventos_emociones
300
- WHERE user_id = ?
301
- ORDER BY analyzed_at DESC
302
- LIMIT 1
303
- """,
304
- (user_id,),
305
- ).fetchone()
306
-
307
- return dict(fila) if fila else None
308
-
309
-
310
- def obtener_historial_emocional(user_id: str, limit: int = 200) -> list[dict]:
311
- """
312
- Devuelve eventos emocionales recientes de un usuario.
313
-
314
- Args:
315
- - user_id: ID de usuario.
316
- - limit: Numero maximo de eventos a devolver.
317
- Returns:
318
- - Lista de eventos con claves emotion y analyzed_at, ordenados por fecha descendente.
319
- """
320
- if not user_id:
321
- return []
322
-
323
- with obtener_conexion_bd() as conn:
324
- filas = conn.execute(
325
- """
326
- SELECT emotion, analyzed_at
327
- FROM eventos_emociones
328
- WHERE user_id = ?
329
- ORDER BY analyzed_at DESC
330
- LIMIT ?
331
- """,
332
- (user_id, limit),
333
- ).fetchall()
334
-
335
- return [dict(fila) for fila in filas]
336
-
337
-
338
- def obtener_pelicula_vista_entre(user_id: str, start_iso: str, end_iso: str) -> dict | None:
339
- """
340
- Funcion que obtiene la pelicula vista por el usuario entre dos momentos determinados
341
- Se usa para detectar la pelicula vista entre dos eventos de recogida de estado emocional
342
-
343
- Args:
344
- - user_id: El ID del usuario del que se desea obtener la pelicula vista
345
- - start_iso: El momento inicial en formato ISO entre el que se desea obtener la pelicula vista
346
- - end_iso: El momento final en formato ISO entre el que se desea obtener la pelicula vista
347
- Returns:
348
- - Un diccionario con los datos de la pelicula vista entre ambos momentos o None si no se encuentra. El diccionario contiene las claves: movie_id, title, viewed_at
349
- """
350
- with obtener_conexion_bd() as conn:
351
- fila = conn.execute(
352
- """
353
- SELECT movie_id, title, viewed_at
354
- FROM historial_peliculas
355
- WHERE user_id = ?
356
- AND viewed_at > ?
357
- AND viewed_at <= ?
358
- ORDER BY viewed_at DESC
359
- LIMIT 1
360
- """,
361
- (user_id, start_iso, end_iso),
362
- ).fetchone()
363
-
364
- return dict(fila) if fila else None
365
-
366
-
367
- def añadir_pelicula_a_historial(
368
- user_id: str,
369
- movie_id: str,
370
- title: str,
371
- emotion: str,
372
- user_rating: float | None,
373
- session_text: str,
374
- viewed_at: str,
375
- ) -> int:
376
- """
377
- Funcion que añade al historial de visualizaciones una nueva pelicula, junto con su valoracion y emocion
378
-
379
- Args:
380
- - user_id: El ID del usuario al que se le añade la pelicula al historial
381
- - movie_id: El ID de la pelicula vista
382
- - title: El titulo de la pelicula vista
383
- - emotion: La emocion asociada a la pelicula vista (puede ser la emocion detectada en el texto del usuario)
384
- - user_rating: La valoracion que el usuario da a la pelicula vista (puede ser None si no se proporciona)
385
- - session_text: El texto de la sesion que se asocia a la pelicula vista
386
- - viewed_at: La fecha y hora en formato ISO cuando se visualizo la pelicula
387
- Returns:
388
- - El ID del registro insertado en el historial de visualizaciones
389
- """
390
- with obtener_conexion_bd() as conn:
391
- cur = conn.execute(
392
- """
393
- INSERT INTO historial_peliculas (user_id, movie_id, title, emotion, user_rating, session_text, viewed_at)
394
- VALUES (?, ?, ?, ?, ?, ?, ?)
395
- """,
396
- (user_id, movie_id, title, emotion, user_rating, session_text, viewed_at),
397
- )
398
- conn.commit()
399
- return int(cur.lastrowid)
400
-
401
-
402
- def obtener_peliculas_del_historial(user_id: str, limit: int) -> list[dict]:
403
- """
404
- Funcion que devuelve las ultimas peliculas vistas por el usuario junto con su emocion asociada
405
- Se utiliza para detectar transiciones emocionales entre peliculas vistas y medir su frecuencia
406
-
407
- Args:
408
- - user_id: El ID del usuario del que se desea obtener las peliculas vistas
409
- - limit: El número máximo de registros a obtener
410
-
411
- Returns:
412
- - Una lista de diccionarios con los datos de las peliculas vistas por el usuario. Cada diccionario contiene las claves: movie_id, title, emotion, viewed_at; ordenado por fecha de visualizacion empezando por la mas reciente
413
- """
414
- with obtener_conexion_bd() as conn:
415
- filas = conn.execute(
416
- """
417
- SELECT id, user_id, movie_id, title, emotion, user_rating, session_text, viewed_at
418
- FROM historial_peliculas
419
- WHERE user_id = ?
420
- ORDER BY viewed_at DESC
421
- LIMIT ?
422
- """,
423
- (user_id, limit),
424
- ).fetchall()
425
- return [dict(fila) for fila in filas]
426
-
427
-
428
- def obtener_relacion_pelicula_emocion(user_id: str, limit: int) -> list[dict]:
429
- """
430
- Funcion que calcula que peliculas han sido vistas por el usuario entre eventos emocionales con cambio de emocion
431
- De esta manera se detecta que peliculas estan asociadas a que transiciones emocionales y con que frecuencia
432
-
433
- Args:
434
- - user_id: El ID del usuario del que se desea obtener las transiciones emocionales asociadas a peliculas vistas
435
- - limit: El número máximo de registros a obtener
436
-
437
- Returns:
438
- - Una lista de diccionarios con los datos de las transiciones emocionales asociadas a peliculas vistas por el usuario. Cada diccionario contiene las claves: movie_id, title, from_emotion, to_emotion, count; ordenado por frecuencia de la transicion empezando por la mas frecuente
439
- """
440
-
441
- with obtener_conexion_bd() as conn:
442
- # Se obtiene el historial de eventos emocionales (texto y emocion asociada)
443
- filas_emociones = conn.execute(
444
- """
445
- SELECT id, user_id, text, emotion, analyzed_at
446
- FROM eventos_emociones
447
- WHERE user_id = ?
448
- ORDER BY analyzed_at ASC
449
- LIMIT 500
450
- """,
451
- (user_id,),
452
- ).fetchall()
453
-
454
- # Se obtiene el historial de peliculas vistas (movie_id, title y momento del visionado)
455
- filas_peliculas = conn.execute(
456
- """
457
- SELECT movie_id, title, viewed_at
458
- FROM historial_peliculas
459
- WHERE user_id = ?
460
- ORDER BY viewed_at ASC
461
- LIMIT 1000
462
- """,
463
- (user_id,),
464
- ).fetchall()
465
-
466
- emociones = [dict(fila) for fila in filas_emociones]
467
- peliculas = [dict(fila) for fila in filas_peliculas]
468
-
469
- # Se recorren los eventos emocionales buscando transiciones de emocion entre eventos consecutivos
470
- # (movie_id, title, emocion_origen, emocion_destino) -> conteo.
471
- transition_counter: dict[tuple[str, str, str, str], int] = {}
472
-
473
- for idx in range(1, len(emociones)):
474
- emocion_previa = emociones[idx - 1]
475
- emocion_actual = emociones[idx]
476
-
477
- #Si son la misma emocion no se ha transicionado
478
- if emocion_previa.get("emotion") == emocion_actual.get("emotion"):
479
- continue
480
-
481
- #En caso de haber cambiado de estado emocional se obtiene el momento de ambos eventos para buscar la pelicula vista
482
- #entre ambos momentos
483
- momento_inicio = emocion_previa.get("analyzed_at", "")
484
- momento_fin = emocion_actual.get("analyzed_at", "")
485
-
486
- # Se busca la pelicula vista en ese momento en especifico
487
- matched_movie = None
488
- for pelicula in reversed(peliculas):
489
- viewed_at = pelicula.get("viewed_at", "")
490
- if momento_inicio < viewed_at <= momento_fin:
491
- matched_movie = pelicula
492
- break
493
-
494
- if not matched_movie:
495
- continue
496
-
497
- # Si se encuentra una pelicula vista entre ambos eventos emocionales
498
- # se cuenta la transicion emocional asociada a esa pelicula, para detectar
499
- # que peliculas estan mas asociadas a que transiciones emocionales
500
- key = (
501
- str(matched_movie.get("movie_id", "")),
502
- matched_movie.get("title") or "",
503
- emocion_previa.get("emotion", ""),
504
- emocion_actual.get("emotion", ""),
505
- )
506
- transition_counter[key] = transition_counter.get(key, 0) + 1
507
-
508
- items = []
509
- # Se construye la lista de diccionarios con los datos de las peliculas asociadas a transiciones emocionales y su frecuencia
510
- for (movie_id, title, from_emotion, to_emotion), count in transition_counter.items():
511
- items.append(
512
- {
513
- "movie_id": movie_id,
514
- "title": title,
515
- "from_emotion": from_emotion,
516
- "to_emotion": to_emotion,
517
- "count": count,
518
- }
519
- )
520
-
521
- items.sort(key=lambda x: x["count"], reverse=True)
522
- return items[:limit]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
backend/scripts/crear_bd.py ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Crea la base de datos SQLite con todas las tablas e índices del esquema BCNF."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ BACKEND_DIR = Path(__file__).resolve().parent.parent
11
+ ROOT_DIR = BACKEND_DIR.parent
12
+ if str(ROOT_DIR) not in sys.path:
13
+ sys.path.insert(0, str(ROOT_DIR))
14
+
15
+ from base_datos import iniciar_historial_usuario
16
+ from config import HISTORY_DB_PATH
17
+
18
+
19
+ def main() -> int:
20
+ parser = argparse.ArgumentParser(description="Crea la BD SQLite con el esquema completo.")
21
+ parser.add_argument(
22
+ "--db-path",
23
+ type=Path,
24
+ default=HISTORY_DB_PATH,
25
+ help=f"Ruta de la base de datos (por defecto: {HISTORY_DB_PATH})",
26
+ )
27
+ args = parser.parse_args()
28
+
29
+ db_path: Path = args.db_path.resolve()
30
+ db_path.parent.mkdir(parents=True, exist_ok=True)
31
+
32
+ iniciar_historial_usuario()
33
+
34
+ print(f"BD creada/verificada: {db_path}")
35
+ print("Tablas: Usuarios, Peliculas, Emociones, Historial_Peliculas, Ciclo_Recomendacion")
36
+ return 0
37
+
38
+
39
+ if __name__ == "__main__":
40
+ raise SystemExit(main())
backend/scripts/limpiar_bdm.py CHANGED
@@ -1,30 +1,25 @@
1
  #!/usr/bin/env python3
2
- """Limpia todos los datos de la base de datos SQLite del backend."""
3
 
4
  from __future__ import annotations
5
 
6
  import argparse
7
  import sqlite3
8
- from pathlib import Path
9
  import sys
 
10
 
11
- CURRENT_DIR = Path(__file__).resolve().parent
12
- BACKEND_DIR = CURRENT_DIR.parent
13
- if str(BACKEND_DIR) not in sys.path:
14
- sys.path.insert(0, str(BACKEND_DIR))
15
 
 
16
  from config import HISTORY_DB_PATH
17
- from db import iniciar_historial_usuario
18
 
19
 
20
- def _obtener_tablas_usuario(conn: sqlite3.Connection) -> list[str]:
21
  rows = conn.execute(
22
- """
23
- SELECT name
24
- FROM sqlite_master
25
- WHERE type = 'table' AND name NOT LIKE 'sqlite_%'
26
- ORDER BY name
27
- """
28
  ).fetchall()
29
  return [str(r[0]) for r in rows]
30
 
@@ -36,13 +31,14 @@ def limpiar_datos(db_path: Path) -> dict[str, int]:
36
  deleted_by_table: dict[str, int] = {}
37
  with sqlite3.connect(db_path) as conn:
38
  conn.execute("PRAGMA foreign_keys = OFF")
39
- tables = _obtener_tablas_usuario(conn)
40
 
41
  for table_name in tables:
42
  cur = conn.execute(f"DELETE FROM {table_name}")
43
  deleted_by_table[table_name] = int(cur.rowcount or 0)
44
 
45
- if "sqlite_sequence" in [r[0] for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall()]:
 
46
  conn.execute("DELETE FROM sqlite_sequence")
47
 
48
  conn.commit()
@@ -66,11 +62,11 @@ def main() -> int:
66
  total = sum(deleted_by_table.values())
67
  print(f"BD limpiada: {db_path}")
68
  for table_name, count in deleted_by_table.items():
69
- print(f"- {table_name}: {count} filas eliminadas")
70
  print(f"Total eliminado: {total} filas")
71
 
72
  iniciar_historial_usuario()
73
- print("Esquema verificado (tablas e indices creados si no existian).")
74
  return 0
75
 
76
 
 
1
  #!/usr/bin/env python3
2
+ """Limpia todos los datos de la base de datos SQLite del """
3
 
4
  from __future__ import annotations
5
 
6
  import argparse
7
  import sqlite3
 
8
  import sys
9
+ from pathlib import Path
10
 
11
+ BACKEND_DIR = Path(__file__).resolve().parent.parent
12
+ ROOT_DIR = BACKEND_DIR.parent
13
+ if str(ROOT_DIR) not in sys.path:
14
+ sys.path.insert(0, str(ROOT_DIR))
15
 
16
+ from base_datos import iniciar_historial_usuario
17
  from config import HISTORY_DB_PATH
 
18
 
19
 
20
+ def _obtener_tablas(conn: sqlite3.Connection) -> list[str]:
21
  rows = conn.execute(
22
+ "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name"
 
 
 
 
 
23
  ).fetchall()
24
  return [str(r[0]) for r in rows]
25
 
 
31
  deleted_by_table: dict[str, int] = {}
32
  with sqlite3.connect(db_path) as conn:
33
  conn.execute("PRAGMA foreign_keys = OFF")
34
+ tables = _obtener_tablas(conn)
35
 
36
  for table_name in tables:
37
  cur = conn.execute(f"DELETE FROM {table_name}")
38
  deleted_by_table[table_name] = int(cur.rowcount or 0)
39
 
40
+ all_tables = {r[0] for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall()}
41
+ if "sqlite_sequence" in all_tables:
42
  conn.execute("DELETE FROM sqlite_sequence")
43
 
44
  conn.commit()
 
62
  total = sum(deleted_by_table.values())
63
  print(f"BD limpiada: {db_path}")
64
  for table_name, count in deleted_by_table.items():
65
+ print(f" {table_name}: {count} filas eliminadas")
66
  print(f"Total eliminado: {total} filas")
67
 
68
  iniciar_historial_usuario()
69
+ print("Esquema verificado (tablas e índices recreados si faltaban).")
70
  return 0
71
 
72
 
backend/scripts/verificar_recomendador.py DELETED
@@ -1,205 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Comprueba de forma automatica que el recomendador se comporta correctamente."""
3
-
4
- from __future__ import annotations
5
-
6
- import argparse
7
- from datetime import datetime, timedelta, timezone
8
- from pathlib import Path
9
- import sqlite3
10
- import sys
11
-
12
- CURRENT_DIR = Path(__file__).resolve().parent
13
- BACKEND_DIR = CURRENT_DIR.parent
14
- if str(BACKEND_DIR) not in sys.path:
15
- sys.path.insert(0, str(BACKEND_DIR))
16
-
17
- from config import HISTORY_DB_PATH
18
- from db import iniciar_historial_usuario
19
- from models import ContextoEmocional
20
- from services.recommender_service import cargar_dataset_movies, recomendar_peliculas
21
-
22
-
23
- TEST_USER = "__test_algo__"
24
- EMPTY_USER = "__test_algo_sin_historial__"
25
-
26
-
27
- def _genres_of(row: dict) -> set[str]:
28
- genres = str(row.get("genres", "")).split("|")
29
- return {g.strip() for g in genres if g.strip() and g.strip() != "(no genres listed)"}
30
-
31
-
32
- def _limpiar_usuario(conn: sqlite3.Connection, user_id: str) -> None:
33
- conn.execute("DELETE FROM historial_peliculas WHERE user_id = ?", (user_id,))
34
- conn.execute("DELETE FROM eventos_emociones WHERE user_id = ?", (user_id,))
35
- conn.execute("DELETE FROM ciclos_recomendaciones WHERE user_id = ?", (user_id,))
36
-
37
-
38
- def _insertar_historial_sintetico(conn: sqlite3.Connection, movies: list[dict], g1: str, g2: str) -> set[str]:
39
- favoritas = []
40
- for row in movies:
41
- row_genres = _genres_of(row)
42
- if g1 in row_genres or g2 in row_genres:
43
- favoritas.append(row)
44
- if len(favoritas) >= 10:
45
- break
46
-
47
- if len(favoritas) < 6:
48
- raise RuntimeError("No hay suficientes peliculas para crear historial sintetico")
49
-
50
- now = datetime.now(timezone.utc)
51
- watched_ids: set[str] = set()
52
- for idx, row in enumerate(favoritas):
53
- movie_id = str(row.get("movieId", "")).strip()
54
- if not movie_id:
55
- continue
56
- watched_ids.add(movie_id)
57
- viewed_at = (now - timedelta(days=idx + 1)).isoformat()
58
- conn.execute(
59
- """
60
- INSERT INTO historial_peliculas (user_id, movie_id, title, emotion, user_rating, session_text, viewed_at)
61
- VALUES (?, ?, ?, ?, ?, ?, ?)
62
- """,
63
- (
64
- TEST_USER,
65
- movie_id,
66
- str(row.get("title", "")),
67
- "tristeza",
68
- 4.5,
69
- "historial sintetico",
70
- viewed_at,
71
- ),
72
- )
73
-
74
- return watched_ids
75
-
76
-
77
- def _seleccionar_generos(movies: list[dict], min_pool: int = 30) -> tuple[str, str]:
78
- counts: dict[str, int] = {}
79
- for row in movies:
80
- for g in _genres_of(row):
81
- counts[g] = counts.get(g, 0) + 1
82
-
83
- ranked = sorted(counts.items(), key=lambda x: x[1], reverse=True)
84
- filtered = [g for g, n in ranked if n >= min_pool]
85
- if len(filtered) < 2:
86
- raise RuntimeError("No hay suficientes generos con masa critica en el dataset")
87
- return filtered[0], filtered[1]
88
-
89
-
90
- def _ratio_inside(recs: list[dict], zona_confort: set[str]) -> float:
91
- if not recs:
92
- return 0.0
93
- inside = 0
94
- for row in recs:
95
- if _genres_of(row) & zona_confort:
96
- inside += 1
97
- return inside / len(recs)
98
-
99
-
100
- def main() -> int:
101
- parser = argparse.ArgumentParser(description="Valida el algoritmo de recomendacion con datos de prueba.")
102
- parser.add_argument("--limit", type=int, default=5, help="Numero de recomendaciones por escenario")
103
- parser.add_argument(
104
- "--db-path",
105
- type=Path,
106
- default=HISTORY_DB_PATH,
107
- help=f"Ruta de base de datos (por defecto: {HISTORY_DB_PATH})",
108
- )
109
- args = parser.parse_args()
110
-
111
- iniciar_historial_usuario()
112
- movies_df, media_global = cargar_dataset_movies()
113
- if not movies_df:
114
- print("ERROR: no se pudo cargar movies.csv para validar el recomendador")
115
- return 1
116
-
117
- db_path = args.db_path.resolve()
118
- with sqlite3.connect(db_path) as conn:
119
- _limpiar_usuario(conn, TEST_USER)
120
- _limpiar_usuario(conn, EMPTY_USER)
121
-
122
- g1, g2 = _seleccionar_generos(movies_df)
123
- zona_confort = {g1, g2}
124
- watched_ids = _insertar_historial_sintetico(conn, movies_df, g1, g2)
125
- conn.commit()
126
-
127
- ctx_pos = ContextoEmocional(emocion_es="alegria", arousal_actual=0.5, valencia_actual=None)
128
- ctx_neg = ContextoEmocional(emocion_es="tristeza", arousal_actual=0.5, valencia_actual=None)
129
-
130
- recs_empty = recomendar_peliculas(
131
- contexto=ctx_pos,
132
- user_id=EMPTY_USER,
133
- limit=args.limit,
134
- movies_df=movies_df,
135
- media_global_ratings=media_global,
136
- estrategia_recomendacion="v1",
137
- )
138
- recs_pos = recomendar_peliculas(
139
- contexto=ctx_pos,
140
- user_id=TEST_USER,
141
- limit=args.limit,
142
- movies_df=movies_df,
143
- media_global_ratings=media_global,
144
- estrategia_recomendacion="v1",
145
- )
146
- recs_neg = recomendar_peliculas(
147
- contexto=ctx_neg,
148
- user_id=TEST_USER,
149
- limit=args.limit,
150
- movies_df=movies_df,
151
- media_global_ratings=media_global,
152
- estrategia_recomendacion="v1",
153
- )
154
-
155
- empty_ok = len(recs_empty) == args.limit
156
- pos_ok_len = len(recs_pos) == args.limit
157
- neg_ok_len = len(recs_neg) == args.limit
158
-
159
- pos_inside_ratio = _ratio_inside(recs_pos, zona_confort)
160
- neg_inside_ratio = _ratio_inside(recs_neg, zona_confort)
161
-
162
- recs_pos_ids = {str(r.get("movieId", "")).strip() for r in recs_pos}
163
- seen_leak = bool(recs_pos_ids & watched_ids)
164
-
165
- print("=== Verificacion recomendador ===")
166
- print(f"Dataset cargado: {len(movies_df)} peliculas")
167
- print(f"Zona de confort sintetica: {sorted(zona_confort)}")
168
- print(f"Escenario sin historial: {len(recs_empty)} recomendaciones")
169
- print(f"Escenario positivo (alegria): {len(recs_pos)} recomendaciones")
170
- print(f"Escenario negativo (tristeza): {len(recs_neg)} recomendaciones")
171
- print(f"Ratio recomendaciones dentro de zona (positivo): {pos_inside_ratio:.2f}")
172
- print(f"Ratio recomendaciones dentro de zona (negativo): {neg_inside_ratio:.2f}")
173
- print(f"Fuga de peliculas ya vistas (positivo): {seen_leak}")
174
-
175
- checks = {
176
- "sin_historial_limite": empty_ok,
177
- "positivo_limite": pos_ok_len,
178
- "negativo_limite": neg_ok_len,
179
- "positivo_fuera_zona_predomina": pos_inside_ratio <= 0.50,
180
- "negativo_dentro_zona_predomina": neg_inside_ratio >= 0.50,
181
- "sin_fuga_vistas_en_positivo": not seen_leak,
182
- }
183
-
184
- failed = [name for name, ok in checks.items() if not ok]
185
- exit_code = 0
186
- if failed:
187
- print("RESULTADO: FAIL")
188
- print("Checks fallidos:")
189
- for name in failed:
190
- print(f"- {name}")
191
- exit_code = 1
192
- else:
193
- print("RESULTADO: OK")
194
-
195
- # Evita dejar datos sintéticos de test en la base de datos real.
196
- with sqlite3.connect(db_path) as conn:
197
- _limpiar_usuario(conn, TEST_USER)
198
- _limpiar_usuario(conn, EMPTY_USER)
199
- conn.commit()
200
-
201
- return exit_code
202
-
203
-
204
- if __name__ == "__main__":
205
- raise SystemExit(main())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
backend/server.py DELETED
@@ -1,11 +0,0 @@
1
- """
2
- Este archivo es el punto de entrada del servidor
3
- Crea la aplicacion Flask y la ejecuta en el puerto 5000.
4
- """
5
-
6
- from app_factory import create_app
7
-
8
- app = create_app()
9
-
10
- if __name__ == "__main__":
11
- app.run(port=5000)
 
 
 
 
 
 
 
 
 
 
 
 
backend/services/{emotion_service.py → analisis_sentimientos.py} RENAMED
@@ -1,14 +1,9 @@
1
  """
2
- Este archivo contiene la logica del analisis de sentimientos de los textos introducidos por los usuarios
 
3
  """
4
 
5
- from transformers import pipeline
6
-
7
- """
8
- Se tiene EMOTION:MAP para traducir emociones del español al ingles
9
- Se tiene NEGATIVE_EMOTIONS y POSITIVE_EMOTIONS para mapear emociones a valencia (positivo, negativo o neutro)
10
- """
11
- from config import EMOTION_MAP, NEGATIVE_EMOTIONS, POSITIVE_EMOTIONS
12
 
13
 
14
  AROUSAL_BY_MODEL_LABEL = {
@@ -58,36 +53,50 @@ def mapeo_emocion_valencia(emocion: str) -> str:
58
  return "neutro"
59
 
60
 
 
 
 
61
  def crear_clasificador_emociones():
62
- """
63
- Funcion que crea y devuelve el modelo preentrenado de clasificacion de emociones de pysentimiento
64
- """
65
-
66
- return pipeline(
67
- "text-classification",
68
- model="pysentimiento/robertuito-emotion-analysis",
69
- top_k=None,
70
- device=-1,
71
- )
 
 
 
 
72
 
73
 
74
  def analizar_texto(modelo, texto: str) -> tuple[list[dict], str, str]:
75
  """
76
- Funcion que analiza un texto dado con el modelo/clasificador de pysentimiento
77
 
78
- Args:
79
- - modelo: El modelo de clasificacion de emociones ya cargado (pipeline de HuggingFace).
80
- - texto: El texto que se desea analizar.
81
  Returns:
82
- - resultado: Lista de diccionarios con las emociones detectadas y sus scores, ordenada de mayor a menor score. Cada diccionario tiene la forma {"label": "alegria", "score": 0.85}.
83
- - emocion_dominante: La emocion con mayor score, traducida al español (e.g. "alegria", "tristeza", etc.)
84
- - valencia: La valencia de la emocion dominante, que puede ser "positivo", "negativo" o "neutro".
85
  """
86
- # Ordena por score para devolver la emocion dominante en la primera posicion.
87
- resultado = sorted(modelo(texto)[0], key=lambda x: x["score"], reverse=True)
88
- dominant_model = resultado[0]["label"] if resultado else "others" # Si no se detecta ninguna usa others como neutral.
89
- dominant_es = EMOTION_MAP.get(dominant_model, "neutral") # Si no se encuentra en el mapeo, se asume neutral.
90
- dominant_valence = mapeo_emocion_valencia(dominant_es) # Mapea la emocion dominante a su valencia. Si es positivo, negativo o neutro.
 
 
 
 
 
 
 
 
 
 
91
  return resultado, dominant_es, dominant_valence
92
 
93
  def estimar_arousal_emocion_es(emocion_es: str) -> float:
 
1
  """
2
+ Este archivo contiene la logica del analisis de sentimientos de los textos introducidos por los usuarios.
3
+ Carga el modelo pysentimiento/robertuito-emotion-analysis localmente via transformers.
4
  """
5
 
6
+ from config import EMOTION_MAP, NEGATIVE_EMOTIONS, POSITIVE_EMOTIONS, HF_EMOTION_MODEL
 
 
 
 
 
 
7
 
8
 
9
  AROUSAL_BY_MODEL_LABEL = {
 
53
  return "neutro"
54
 
55
 
56
+ _pipeline_emociones = None
57
+
58
+
59
  def crear_clasificador_emociones():
60
+ """Carga el pipeline de emociones de pysentimiento (se descarga la primera vez)."""
61
+ global _pipeline_emociones
62
+ if _pipeline_emociones is None:
63
+ try:
64
+ from transformers import pipeline
65
+ _pipeline_emociones = pipeline(
66
+ "text-classification",
67
+ model=HF_EMOTION_MODEL,
68
+ top_k=None,
69
+ )
70
+ except Exception as exc:
71
+ print(f"Aviso: no se pudo cargar el modelo de emociones ({exc}). Se usara neutral.")
72
+ _pipeline_emociones = None
73
+ return _pipeline_emociones
74
 
75
 
76
  def analizar_texto(modelo, texto: str) -> tuple[list[dict], str, str]:
77
  """
78
+ Analiza un texto usando el pipeline local de transformers.
79
 
 
 
 
80
  Returns:
81
+ - resultado: lista de {"label": ..., "score": ...} ordenada de mayor a menor.
82
+ - emocion_dominante: emocion con mayor score en español.
83
+ - valencia: "positivo", "negativo" o "neutro".
84
  """
85
+ clf = modelo if modelo is not None else crear_clasificador_emociones()
86
+
87
+ try:
88
+ if clf is not None:
89
+ raw = clf(texto)[0]
90
+ resultado = sorted(raw, key=lambda x: x["score"], reverse=True)
91
+ else:
92
+ resultado = [{"label": "others", "score": 1.0}]
93
+ except Exception as exc:
94
+ print(f"Aviso: fallo analisis de emociones ({exc}). Se usa neutral.")
95
+ resultado = [{"label": "others", "score": 1.0}]
96
+
97
+ dominant_model = resultado[0]["label"] if resultado else "others"
98
+ dominant_es = EMOTION_MAP.get(dominant_model, "neutral")
99
+ dominant_valence = mapeo_emocion_valencia(dominant_es)
100
  return resultado, dominant_es, dominant_valence
101
 
102
  def estimar_arousal_emocion_es(emocion_es: str) -> float:
backend/services/{recommender_service.py → calculos.py} RENAMED
@@ -1,119 +1,15 @@
1
- """
2
- Motor de recomendaciones de peliculas basado en el estado emocional del usuario y su historial de visualizacion.
3
- Combina calidad global (suavizado bayesiano), similitud de generos y preferencias personales.
4
- Adapta la estrategia segun el estado emocional usando el patron Strategy.
5
- """
6
-
7
- import csv
8
  import random
9
- from abc import ABC, abstractmethod
10
  from collections import Counter
11
 
12
- from config import GLOBAL_PRIOR_COUNT, LIKE_THRESHOLD, POSITIVE_EMOTIONS, ROOT_DIR
13
- from models import ContextoEmocional, PerfilUsuario
14
- from repositories.history_repository import obtener_historial_usuario
15
-
16
-
17
- # ---------------------------------------------------------------------------
18
- # Carga de datos
19
- # ---------------------------------------------------------------------------
20
-
21
- def _cargar_estadisticas_ratings() -> tuple[dict[str, tuple[float, int]], float]:
22
- ratings_path = ROOT_DIR / "data" / "ml-latest" / "ratings.csv"
23
- if not ratings_path.exists():
24
- return {}, 0.0
25
-
26
- movie_sum_count: dict[str, list[float | int]] = {}
27
- total_sum = 0.0
28
- total_count = 0
29
-
30
- with open(ratings_path, "r", encoding="utf-8", newline="") as f:
31
- for row in csv.DictReader(f):
32
- movie_id = str(row.get("movieId", "")).strip()
33
- if not movie_id:
34
- continue
35
- try:
36
- rating = float(row.get("rating", 0) or 0)
37
- except (TypeError, ValueError):
38
- continue
39
- if movie_id not in movie_sum_count:
40
- movie_sum_count[movie_id] = [0.0, 0]
41
- movie_sum_count[movie_id][0] += rating
42
- movie_sum_count[movie_id][1] += 1
43
- total_sum += rating
44
- total_count += 1
45
-
46
- stats: dict[str, tuple[float, int]] = {
47
- mid: (s / c, int(c))
48
- for mid, (s, c) in movie_sum_count.items()
49
- if c
50
- }
51
- global_mean = (total_sum / total_count) if total_count else 0.0
52
- return stats, global_mean
53
-
54
-
55
- def _cargar_links() -> dict[str, str]:
56
- """Returns {movieId: imdbId} from links.csv (tries full dataset first, then small)."""
57
- candidates = [
58
- ROOT_DIR / "data" / "ml-latest" / "links.csv",
59
- ROOT_DIR / "notebooks" / "data" / "raw" / "ml-latest-small" / "links.csv",
60
- ]
61
- for path in candidates:
62
- if path.exists():
63
- with open(path, "r", encoding="utf-8", newline="") as f:
64
- return {
65
- str(row.get("movieId", "")).strip(): str(row.get("imdbId", "")).strip()
66
- for row in csv.DictReader(f)
67
- if str(row.get("imdbId", "")).strip()
68
- }
69
- return {}
70
-
71
-
72
- def cargar_dataset_movies() -> tuple[list[dict], float]:
73
- rating_stats, global_mean = _cargar_estadisticas_ratings()
74
- links = _cargar_links()
75
- path = ROOT_DIR / "data" / "ml-latest" / "movies.csv"
76
-
77
- if path.exists():
78
- with open(path, "r", encoding="utf-8", newline="") as f:
79
- rows = list(csv.DictReader(f))
80
-
81
- for row in rows:
82
- movie_id = str(row.get("movieId", "")).strip()
83
- mean, count = rating_stats.get(movie_id, (0.0, 0))
84
- row["rating_count"] = int(count)
85
- row["rating_mean"] = float(mean)
86
- row["imdb_id"] = links.get(movie_id, "")
87
-
88
- return rows, global_mean
89
-
90
- fallback_path = ROOT_DIR / "data" / "procesado" / "peliculas_100_emociones.csv"
91
- if fallback_path.exists():
92
- with open(fallback_path, "r", encoding="utf-8", newline="") as f:
93
- rows = list(csv.DictReader(f))
94
- for row in rows:
95
- movie_id = str(row.get("movieId", "")).strip()
96
- row["imdb_id"] = links.get(movie_id, "")
97
- total_w = sum(float(r.get("rating_mean", 0) or 0) * int(r.get("rating_count", 0) or 0) for r in rows)
98
- total_n = sum(int(r.get("rating_count", 0) or 0) for r in rows)
99
- fallback_mean = (total_w / total_n) if total_n else 3.5
100
- return rows, fallback_mean
101
-
102
- return [], global_mean
103
-
104
-
105
- # ---------------------------------------------------------------------------
106
- # Helpers de perfil y scoring
107
- # ---------------------------------------------------------------------------
108
-
109
- def _obtener_generos_pelicula(row: dict) -> set[str]:
110
  return {g.strip() for g in row.get("genres", "").split("|") if g.strip()}
111
 
112
 
113
- def _construir_perfil_usuario(
114
- movies_df: list[dict],
115
- history_rows: list[dict],
116
- ) -> PerfilUsuario:
117
  peliculas_vistas = {
118
  str(row.get("movie_id", "")).strip()
119
  for row in history_rows
@@ -157,7 +53,7 @@ def _construir_perfil_usuario(
157
  movie_id = str(movie.get("movieId", "")).strip()
158
  if movie_id not in peliculas_vistas:
159
  continue
160
- genres = _obtener_generos_pelicula(movie)
161
  contador_generos_vistos.update(genres)
162
  if movie_id in peliculas_gustadas:
163
  contador_generos_gustados.update(genres)
@@ -201,7 +97,7 @@ def _construir_perfil_usuario(
201
  )
202
 
203
 
204
- def _puntuacion_calidad_global(row: dict, media_global_ratings: float) -> float:
205
  cantidad = float(row.get("rating_count", 0) or 0)
206
  media = float(row.get("rating_mean", 0) or 0)
207
  if cantidad <= 0:
@@ -211,22 +107,22 @@ def _puntuacion_calidad_global(row: dict, media_global_ratings: float) -> float:
211
  return max(0.0, min(1.0, suavizado / 5.0))
212
 
213
 
214
- def _grado_confort_vector(peli: dict, zona_confort: set[str]) -> float:
215
- generos = list(_obtener_generos_pelicula(peli))
216
  if not generos or not zona_confort:
217
  return 0.0
218
  return sum(1 for g in generos if g in zona_confort) / len(generos)
219
 
220
 
221
- def _calcular_pertenencia_zona_confort(peli: dict, zona_confort_ponderada: dict[str, float]) -> float:
222
- generos = _obtener_generos_pelicula(peli)
223
  if not generos or not zona_confort_ponderada:
224
  return 0.0
225
  pesos = [zona_confort_ponderada.get(g, 0.0) for g in generos]
226
  return sum(pesos) / len(pesos)
227
 
228
 
229
- def _percentil_desde_cero(arousal_actual: float, historico_arousal: list[float]) -> float:
230
  valores = [float(v) for v in historico_arousal if v is not None]
231
  valores.append(0.0)
232
  valores.sort()
@@ -240,7 +136,7 @@ def _percentil_desde_cero(arousal_actual: float, historico_arousal: list[float])
240
  return round(menores / len(valores), 4)
241
 
242
 
243
- def _normalizar_valencia_actual(valencia_actual: str | float | None, emotion_es: str) -> str:
244
  if isinstance(valencia_actual, (int, float)):
245
  if float(valencia_actual) > 0.5:
246
  return "positivo"
@@ -260,7 +156,7 @@ def _normalizar_valencia_actual(valencia_actual: str | float | None, emotion_es:
260
  return "negativo"
261
 
262
 
263
- def _valencia_a_factor(valencia_normalizada: str | float | None) -> float:
264
  if isinstance(valencia_normalizada, (int, float)):
265
  return max(0.0, min(1.0, float(valencia_normalizada)))
266
  if valencia_normalizada == "positivo":
@@ -270,8 +166,8 @@ def _valencia_a_factor(valencia_normalizada: str | float | None) -> float:
270
  return 0.5
271
 
272
 
273
- def _recomendar_calidad_aleatoria(candidatas: list[dict], media_global: float, limit: int) -> list[dict]:
274
- ranked = sorted(candidatas, key=lambda r: _puntuacion_calidad_global(r, media_global), reverse=True)
275
  pool = ranked[:max(limit * 4, limit)]
276
  if len(pool) <= limit:
277
  random.shuffle(pool)
@@ -279,152 +175,10 @@ def _recomendar_calidad_aleatoria(candidatas: list[dict], media_global: float, l
279
  return random.sample(pool, k=limit)
280
 
281
 
282
- def _sample_from_ranked(ranked: list[dict], limit: int) -> list[dict]:
283
- """Sample randomly from the top pool to avoid always returning identical results."""
284
  pool_size = max(limit * 5, 30)
285
  pool = ranked[:pool_size]
286
  if len(pool) <= limit:
287
  random.shuffle(pool)
288
  return pool
289
  return random.sample(pool, k=limit)
290
-
291
-
292
- # ---------------------------------------------------------------------------
293
- # Strategy pattern
294
- # ---------------------------------------------------------------------------
295
-
296
- class EstrategiaRecomendacion(ABC):
297
- @abstractmethod
298
- def recomendar(
299
- self,
300
- peliculas_candidatas: list[dict],
301
- perfil: PerfilUsuario,
302
- media_global_ratings: float,
303
- limit: int,
304
- contexto: ContextoEmocional,
305
- debug_context: dict | None,
306
- ) -> list[dict]: ...
307
-
308
-
309
- class EstrategiaV1(EstrategiaRecomendacion):
310
- """Clasificador binario: emocion positiva → fuera de zona de confort, negativa → dentro."""
311
-
312
- def recomendar(self, peliculas_candidatas, perfil, media_global_ratings, limit, contexto, debug_context):
313
- is_positive = contexto.emocion_es in POSITIVE_EMOTIONS
314
-
315
- if is_positive:
316
- outside = [r for r in peliculas_candidatas if not (_obtener_generos_pelicula(r) & perfil.zona_confort)]
317
- base = outside if outside else peliculas_candidatas
318
- else:
319
- inside = [r for r in peliculas_candidatas if _obtener_generos_pelicula(r) & perfil.zona_confort]
320
- base = inside if inside else peliculas_candidatas
321
-
322
- ranked = sorted(base, key=lambda r: _puntuacion_calidad_global(r, media_global_ratings), reverse=True)
323
- return _sample_from_ranked(ranked, limit)
324
-
325
-
326
- class EstrategiaV2(EstrategiaRecomendacion):
327
- """Intensidad adaptativa: grado de confort continuo ponderado por calidad."""
328
-
329
- def recomendar(self, peliculas_candidatas, perfil, media_global_ratings, limit, contexto, debug_context):
330
- is_positive = contexto.emocion_es in POSITIVE_EMOTIONS
331
-
332
- if is_positive:
333
- outside = [r for r in peliculas_candidatas if not (_obtener_generos_pelicula(r) & perfil.zona_confort)]
334
- base = outside if outside else peliculas_candidatas
335
- ranked = sorted(
336
- base,
337
- key=lambda r: (1.0 - _grado_confort_vector(r, perfil.zona_confort))
338
- * _puntuacion_calidad_global(r, media_global_ratings),
339
- reverse=True,
340
- )
341
- else:
342
- inside = [r for r in peliculas_candidatas if _obtener_generos_pelicula(r) & perfil.zona_confort]
343
- base = inside if inside else peliculas_candidatas
344
- ranked = sorted(
345
- base,
346
- key=lambda r: _grado_confort_vector(r, perfil.zona_confort)
347
- * _puntuacion_calidad_global(r, media_global_ratings),
348
- reverse=True,
349
- )
350
-
351
- return _sample_from_ranked(ranked, limit)
352
-
353
-
354
- class EstrategiaV3(EstrategiaRecomendacion):
355
- """Target de confort continuo: Cd = 0.5 + A * (0.5 - V), donde A es arousal percentilado."""
356
-
357
- def recomendar(self, peliculas_candidatas, perfil, media_global_ratings, limit, contexto, debug_context):
358
- a = _percentil_desde_cero(contexto.arousal_actual, contexto.historico_arousal)
359
-
360
- if isinstance(contexto.valencia_actual, (int, float)):
361
- v = _valencia_a_factor(contexto.valencia_actual)
362
- else:
363
- v = _valencia_a_factor(_normalizar_valencia_actual(contexto.valencia_actual, contexto.emocion_es))
364
-
365
- if 0.45 <= v <= 0.55:
366
- target_confort = 0.5
367
- else:
368
- target_confort = max(0.0, min(1.0, 0.5 + a * (0.5 - v)))
369
-
370
- if debug_context is not None:
371
- valencia_label = (
372
- _normalizar_valencia_actual(contexto.valencia_actual, contexto.emocion_es)
373
- if not isinstance(contexto.valencia_actual, (int, float))
374
- else ("positivo" if v > 0.5 else "negativo" if v < 0.5 else "neutro")
375
- )
376
- debug_context["arousal_percentil"] = round(a, 4)
377
- debug_context["valencia_factor"] = round(v, 4)
378
- debug_context["target_confort"] = round(target_confort, 4)
379
- debug_context["valencia_normalizada"] = valencia_label
380
-
381
- ranked = sorted(
382
- peliculas_candidatas,
383
- key=lambda peli: (
384
- 1 - abs(_calcular_pertenencia_zona_confort(peli, perfil.probabilidades_generos) - target_confort)
385
- ) * _puntuacion_calidad_global(peli, media_global_ratings),
386
- reverse=True,
387
- )
388
- return _sample_from_ranked(ranked, limit)
389
-
390
-
391
- _ESTRATEGIAS: dict[str, EstrategiaRecomendacion] = {
392
- "v1": EstrategiaV1(),
393
- "v2": EstrategiaV2(),
394
- "v3": EstrategiaV3(),
395
- }
396
-
397
-
398
- # ---------------------------------------------------------------------------
399
- # Punto de entrada principal
400
- # ---------------------------------------------------------------------------
401
-
402
- def recomendar_peliculas(
403
- contexto: ContextoEmocional,
404
- user_id: str,
405
- limit: int,
406
- movies_df: list[dict],
407
- media_global_ratings: float,
408
- estrategia_recomendacion: str,
409
- debug_context: dict | None = None,
410
- ) -> list[dict]:
411
- if not movies_df:
412
- return []
413
-
414
- history_rows = obtener_historial_usuario(user_id) if user_id else []
415
- perfil = _construir_perfil_usuario(movies_df, history_rows)
416
-
417
- peliculas_no_vistas = [r for r in movies_df if str(r.get("movieId", "")).strip() not in perfil.peliculas_vistas]
418
- peliculas_candidatas = peliculas_no_vistas if peliculas_no_vistas else movies_df
419
-
420
- if not peliculas_candidatas:
421
- return []
422
-
423
- if not perfil.tiene_historial:
424
- return _recomendar_calidad_aleatoria(peliculas_candidatas, media_global_ratings, limit)
425
-
426
- estrategia = _ESTRATEGIAS.get(estrategia_recomendacion)
427
- if estrategia is None:
428
- return _recomendar_calidad_aleatoria(peliculas_candidatas, media_global_ratings, limit)
429
-
430
- return estrategia.recomendar(peliculas_candidatas, perfil, media_global_ratings, limit, contexto, debug_context)
 
 
 
 
 
 
 
 
1
  import random
 
2
  from collections import Counter
3
 
4
+ from config import GLOBAL_PRIOR_COUNT, LIKE_THRESHOLD, POSITIVE_EMOTIONS
5
+ from modelos import PerfilUsuario
6
+
7
+
8
+ def obtener_generos_pelicula(row: dict) -> set[str]:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
  return {g.strip() for g in row.get("genres", "").split("|") if g.strip()}
10
 
11
 
12
+ def construir_perfil_usuario(movies_df: list[dict], history_rows: list[dict]) -> PerfilUsuario:
 
 
 
13
  peliculas_vistas = {
14
  str(row.get("movie_id", "")).strip()
15
  for row in history_rows
 
53
  movie_id = str(movie.get("movieId", "")).strip()
54
  if movie_id not in peliculas_vistas:
55
  continue
56
+ genres = obtener_generos_pelicula(movie)
57
  contador_generos_vistos.update(genres)
58
  if movie_id in peliculas_gustadas:
59
  contador_generos_gustados.update(genres)
 
97
  )
98
 
99
 
100
+ def puntuacion_calidad_global(row: dict, media_global_ratings: float) -> float:
101
  cantidad = float(row.get("rating_count", 0) or 0)
102
  media = float(row.get("rating_mean", 0) or 0)
103
  if cantidad <= 0:
 
107
  return max(0.0, min(1.0, suavizado / 5.0))
108
 
109
 
110
+ def grado_confort_vector(peli: dict, zona_confort: set[str]) -> float:
111
+ generos = list(obtener_generos_pelicula(peli))
112
  if not generos or not zona_confort:
113
  return 0.0
114
  return sum(1 for g in generos if g in zona_confort) / len(generos)
115
 
116
 
117
+ def calcular_pertenencia_zona_confort(peli: dict, zona_confort_ponderada: dict[str, float]) -> float:
118
+ generos = obtener_generos_pelicula(peli)
119
  if not generos or not zona_confort_ponderada:
120
  return 0.0
121
  pesos = [zona_confort_ponderada.get(g, 0.0) for g in generos]
122
  return sum(pesos) / len(pesos)
123
 
124
 
125
+ def percentil_desde_cero(arousal_actual: float, historico_arousal: list[float]) -> float:
126
  valores = [float(v) for v in historico_arousal if v is not None]
127
  valores.append(0.0)
128
  valores.sort()
 
136
  return round(menores / len(valores), 4)
137
 
138
 
139
+ def normalizar_valencia_actual(valencia_actual: str | float | None, emotion_es: str) -> str:
140
  if isinstance(valencia_actual, (int, float)):
141
  if float(valencia_actual) > 0.5:
142
  return "positivo"
 
156
  return "negativo"
157
 
158
 
159
+ def valencia_a_factor(valencia_normalizada: str | float | None) -> float:
160
  if isinstance(valencia_normalizada, (int, float)):
161
  return max(0.0, min(1.0, float(valencia_normalizada)))
162
  if valencia_normalizada == "positivo":
 
166
  return 0.5
167
 
168
 
169
+ def recomendar_calidad_aleatoria(candidatas: list[dict], media_global: float, limit: int) -> list[dict]:
170
+ ranked = sorted(candidatas, key=lambda r: puntuacion_calidad_global(r, media_global), reverse=True)
171
  pool = ranked[:max(limit * 4, limit)]
172
  if len(pool) <= limit:
173
  random.shuffle(pool)
 
175
  return random.sample(pool, k=limit)
176
 
177
 
178
+ def sample_from_ranked(ranked: list[dict], limit: int) -> list[dict]:
 
179
  pool_size = max(limit * 5, 30)
180
  pool = ranked[:pool_size]
181
  if len(pool) <= limit:
182
  random.shuffle(pool)
183
  return pool
184
  return random.sample(pool, k=limit)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
backend/services/{chatbot_service.py → chatbot.py} RENAMED
@@ -1,13 +1,12 @@
1
  """
2
  Este archivo se encarga de generar el texto que el chatbot mostrará al usuario
3
- basado en su estado emocional, recomendaciones y eventos anteriores. Se intenta usar
4
- Ollama para generar respuestas más naturales, pero se incluye una plantilla de respaldo
5
- en caso de que falle la generación.
6
  """
7
 
8
  import requests as http_requests
9
 
10
- from config import OLLAMA_URL, TEXT_MODEL_NAME
11
 
12
  def construir_respuesta_manual(
13
  emocion_dominante: str,
@@ -130,7 +129,6 @@ def generar_texto_chatbot(
130
  )
131
 
132
  try:
133
- # Primero se intenta generar el texto con Ollama
134
  prompt = _construir_prompt(
135
  emocion_dominante=emocion_dominante,
136
  modo_recomendacion=modo_recomendacion,
@@ -139,28 +137,28 @@ def generar_texto_chatbot(
139
  pelicula_transicion=pelicula_transicion,
140
  )
141
 
142
- # Se hace la llamada a Ollama con el prompt construido
 
 
 
 
 
143
  payload = {
144
- "model": TEXT_MODEL_NAME,
145
- "prompt": prompt,
146
- "stream": False, # No necesitamos streaming para esta respuesta, queremos la respuesta completa de una vez.
147
- "options": {
148
- "temperature": 0.7, # Un poco de aleatoriedad para respuestas mas variadas, pero sin perder coherencia.
149
- "top_p": 0.9, # Consideramos el top 90% de las opciones para generar respuestas mas naturales.
150
- "num_predict": 120, # Limite de tokens para la respuesta, suficiente para 3 frases pero evitando respuestas demasiado largas.
151
- },
152
  }
153
- # Se hace la solicitud POST a Ollama
154
- #res = http_requests.post(OLLAMA_URL, json=payload, timeout=20)
155
- res = http_requests.post(OLLAMA_URL, json=payload) # Se elimina timeout para forzar Ollama
156
- if res.ok:
157
- data = res.json()
158
- generated = str(data.get("response", "")).strip()
159
- if generated:
160
- return generated, "ollama"
161
-
162
- print(f"Aviso: Ollama devolvio HTTP {res.status_code}. Se usa plantilla.")
163
  except Exception as exc:
164
- print(f"Aviso: fallo generando texto con Ollama ({exc}). Se usa plantilla.")
165
 
166
  return respuesta_manual, "template-fallback"
 
1
  """
2
  Este archivo se encarga de generar el texto que el chatbot mostrará al usuario
3
+ basado en su estado emocional, recomendaciones y eventos anteriores. Se usa la
4
+ Inference API de HuggingFace como generador principal, con plantilla de respaldo.
 
5
  """
6
 
7
  import requests as http_requests
8
 
9
+ from config import HF_TOKEN, HF_TEXT_MODEL, HF_INFERENCE_URL
10
 
11
  def construir_respuesta_manual(
12
  emocion_dominante: str,
 
129
  )
130
 
131
  try:
 
132
  prompt = _construir_prompt(
133
  emocion_dominante=emocion_dominante,
134
  modo_recomendacion=modo_recomendacion,
 
137
  pelicula_transicion=pelicula_transicion,
138
  )
139
 
140
+ # Nuevo router HuggingFace usa formato OpenAI-compatible
141
+ url = f"{HF_INFERENCE_URL}/{HF_TEXT_MODEL}/v1/chat/completions"
142
+ headers = {
143
+ "Authorization": f"Bearer {HF_TOKEN}",
144
+ "Content-Type": "application/json",
145
+ }
146
  payload = {
147
+ "model": HF_TEXT_MODEL,
148
+ "messages": [{"role": "user", "content": prompt}],
149
+ "max_tokens": 120,
150
+ "temperature": 0.7,
151
+ "top_p": 0.9,
 
 
 
152
  }
153
+ res = http_requests.post(url, headers=headers, json=payload, timeout=20)
154
+ res.raise_for_status()
155
+ data = res.json()
156
+ generated = str(data["choices"][0]["message"]["content"]).strip() if data.get("choices") else ""
157
+ if generated:
158
+ return generated, "huggingface"
159
+
160
+ print("Aviso: HuggingFace devolvio respuesta vacia. Se usa plantilla.")
 
 
161
  except Exception as exc:
162
+ print(f"Aviso: fallo generando texto con HuggingFace ({exc}). Se usa plantilla.")
163
 
164
  return respuesta_manual, "template-fallback"
backend/services/estrategias_recomendacion.py ADDED
@@ -0,0 +1,132 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from abc import ABC, abstractmethod
2
+
3
+ from config import POSITIVE_EMOTIONS
4
+ from modelos import ContextoEmocional, PerfilUsuario
5
+ from services.calculos import (
6
+ calcular_pertenencia_zona_confort,
7
+ grado_confort_vector,
8
+ normalizar_valencia_actual,
9
+ obtener_generos_pelicula,
10
+ percentil_desde_cero,
11
+ puntuacion_calidad_global,
12
+ sample_from_ranked,
13
+ valencia_a_factor,
14
+ )
15
+
16
+
17
+ class EstrategiaRecomendacion(ABC):
18
+ @abstractmethod
19
+ def recomendar(
20
+ self,
21
+ peliculas_candidatas: list[dict],
22
+ perfil: PerfilUsuario,
23
+ media_global_ratings: float,
24
+ limit: int,
25
+ contexto: ContextoEmocional,
26
+ debug_context: dict | None,
27
+ ) -> list[dict]: ...
28
+
29
+
30
+ class EstrategiaV1(EstrategiaRecomendacion):
31
+ """Clasificador binario: emocion positiva → fuera de zona de confort, negativa → dentro."""
32
+
33
+ def recomendar(self, peliculas_candidatas, perfil, media_global_ratings, limit, contexto, debug_context):
34
+ is_positive = contexto.emocion_es in POSITIVE_EMOTIONS
35
+
36
+ if is_positive:
37
+ outside = [r for r in peliculas_candidatas if not (obtener_generos_pelicula(r) & perfil.zona_confort)]
38
+ base = outside if outside else peliculas_candidatas
39
+ else:
40
+ inside = [r for r in peliculas_candidatas if obtener_generos_pelicula(r) & perfil.zona_confort]
41
+ base = inside if inside else peliculas_candidatas
42
+
43
+ ranked = sorted(base, key=lambda r: puntuacion_calidad_global(r, media_global_ratings), reverse=True)
44
+ return sample_from_ranked(ranked, limit)
45
+
46
+
47
+ class EstrategiaV2(EstrategiaRecomendacion):
48
+ """Intensidad adaptativa: grado de confort continuo ponderado por calidad."""
49
+
50
+ def recomendar(self, peliculas_candidatas, perfil, media_global_ratings, limit, contexto, debug_context):
51
+ is_positive = contexto.emocion_es in POSITIVE_EMOTIONS
52
+
53
+ if is_positive:
54
+ outside = [r for r in peliculas_candidatas if not (obtener_generos_pelicula(r) & perfil.zona_confort)]
55
+ base = outside if outside else peliculas_candidatas
56
+ ranked = sorted(
57
+ base,
58
+ key=lambda r: (1.0 - grado_confort_vector(r, perfil.zona_confort))
59
+ * puntuacion_calidad_global(r, media_global_ratings),
60
+ reverse=True,
61
+ )
62
+ else:
63
+ inside = [r for r in peliculas_candidatas if obtener_generos_pelicula(r) & perfil.zona_confort]
64
+ base = inside if inside else peliculas_candidatas
65
+ ranked = sorted(
66
+ base,
67
+ key=lambda r: grado_confort_vector(r, perfil.zona_confort)
68
+ * puntuacion_calidad_global(r, media_global_ratings),
69
+ reverse=True,
70
+ )
71
+
72
+ return sample_from_ranked(ranked, limit)
73
+
74
+
75
+ class EstrategiaV3(EstrategiaRecomendacion):
76
+ """Target de confort continuo: Cd = 0.5 + A * (0.5 - V), donde A es arousal percentilado."""
77
+
78
+ def recomendar(self, peliculas_candidatas, perfil, media_global_ratings, limit, contexto, debug_context):
79
+ a = percentil_desde_cero(contexto.arousal_actual, contexto.historico_arousal)
80
+
81
+ if isinstance(contexto.valencia_actual, (int, float)):
82
+ v = valencia_a_factor(contexto.valencia_actual)
83
+ else:
84
+ v = valencia_a_factor(normalizar_valencia_actual(contexto.valencia_actual, contexto.emocion_es))
85
+
86
+ if 0.45 <= v <= 0.55:
87
+ target_confort = 0.5
88
+ else:
89
+ target_confort = max(0.0, min(1.0, 0.5 + a * (0.5 - v)))
90
+
91
+ if debug_context is not None:
92
+ valencia_label = (
93
+ normalizar_valencia_actual(contexto.valencia_actual, contexto.emocion_es)
94
+ if not isinstance(contexto.valencia_actual, (int, float))
95
+ else ("positivo" if v > 0.5 else "negativo" if v < 0.5 else "neutro")
96
+ )
97
+ debug_context["arousal_percentil"] = round(a, 4)
98
+ debug_context["valencia_factor"] = round(v, 4)
99
+ debug_context["target_confort"] = round(target_confort, 4)
100
+ debug_context["valencia_normalizada"] = valencia_label
101
+
102
+ ranked = sorted(
103
+ peliculas_candidatas,
104
+ key=lambda peli: (
105
+ 1 - abs(calcular_pertenencia_zona_confort(peli, perfil.probabilidades_generos) - target_confort)
106
+ ) * puntuacion_calidad_global(peli, media_global_ratings),
107
+ reverse=True,
108
+ )
109
+ return sample_from_ranked(ranked, limit)
110
+
111
+
112
+ class EstrategiaFactory:
113
+ _registro: dict[str, type[EstrategiaRecomendacion]] = {
114
+ "v1": EstrategiaV1,
115
+ "v2": EstrategiaV2,
116
+ "v3": EstrategiaV3,
117
+ }
118
+
119
+ @classmethod
120
+ def crear(cls, nombre: str) -> EstrategiaRecomendacion:
121
+ clase = cls._registro.get(nombre)
122
+ if clase is None:
123
+ raise ValueError(f"Estrategia desconocida: '{nombre}'. Disponibles: {list(cls._registro)}")
124
+ return clase()
125
+
126
+ @classmethod
127
+ def registrar(cls, nombre: str, clase: type[EstrategiaRecomendacion]) -> None:
128
+ cls._registro[nombre] = clase
129
+
130
+ @classmethod
131
+ def estrategias_disponibles(cls) -> list[str]:
132
+ return list(cls._registro)
backend/services/{analysis_service.py → pipeline.py} RENAMED
@@ -6,22 +6,19 @@ Desacopla la logica de negocio de las rutas Flask.
6
  from datetime import datetime, timezone
7
 
8
  from config import POSITIVE_EMOTIONS
9
- from models import ContextoEmocional, ResultadoAnalisis
10
- from repositories.history_repository import (
11
- añadir_evento_emocional,
12
- crear_ciclo_recomendacion,
13
- obtener_historial_emocional,
14
- obtener_pelicula_vista_entre,
15
- obtener_ultima_emocion,
16
- )
17
- from services.chatbot_service import generar_texto_chatbot
18
- from services.emotion_service import (
19
  analizar_texto,
20
  calcular_valencia_continua,
21
  calculo_arousal,
22
  estimar_arousal_emocion_es,
23
  )
24
- from services.recommender_service import recomendar_peliculas
25
 
26
 
27
  class AnalysisService:
@@ -29,20 +26,26 @@ class AnalysisService:
29
  self._modelo = modelo
30
  self._movies_df = movies_df
31
  self._media_global_ratings = media_global_ratings
 
 
 
 
32
 
33
  def analizar(self, texto: str, user_id: str, estrategia: str) -> ResultadoAnalisis:
34
  momento_analisis = datetime.now(timezone.utc).isoformat()
35
- emocion_previa = obtener_ultima_emocion(user_id)
 
 
 
36
 
37
  resultado, emocion_dominante, valencia_dominante = analizar_texto(self._modelo, texto)
38
  arousal_actual = calculo_arousal(resultado)
39
  valencia_continua = calcular_valencia_continua(resultado)
40
 
41
- historial_eventos = obtener_historial_emocional(user_id=user_id, limit=200)
42
  historico_arousal = [
43
- estimar_arousal_emocion_es(str(ev.get("emotion", "")).strip())
44
- for ev in historial_eventos
45
- if ev.get("emotion")
46
  ]
47
 
48
  contexto = ContextoEmocional(
@@ -65,35 +68,54 @@ class AnalysisService:
65
 
66
  modo_recomendacion = "diferente" if emocion_dominante in POSITIVE_EMOTIONS else "similar"
67
 
68
- cycle_id = crear_ciclo_recomendacion(
 
69
  user_id=user_id,
70
- pre_text=texto,
71
- pre_emotion=emocion_dominante,
72
- pre_valence=valencia_dominante,
73
- recommendation_mode=modo_recomendacion,
74
- created_at=momento_analisis,
75
  )
76
 
77
- añadir_evento_emocional(
78
- user_id=user_id,
79
- text=texto,
80
- emotion=emocion_dominante,
81
- analyzed_at=momento_analisis,
82
- )
 
 
 
 
83
 
 
84
  pelicula_transicion = None
85
- if emocion_previa:
86
- pelicula_transicion = obtener_pelicula_vista_entre(
87
  user_id=user_id,
88
- start_iso=emocion_previa.get("momento_analisis", ""),
89
- end_iso=momento_analisis,
90
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
91
 
92
  chatbot_texto, chatbot_fuente = generar_texto_chatbot(
93
  emocion_dominante=emocion_dominante,
94
  modo_recomendacion=modo_recomendacion,
95
  recomendaciones=recomendaciones,
96
- emocion_previa=emocion_previa,
97
  pelicula_transicion=pelicula_transicion,
98
  )
99
 
@@ -106,7 +128,7 @@ class AnalysisService:
106
  estrategia=estrategia,
107
  debug_recomendacion=debug_reco,
108
  historico_arousal_size=len(historico_arousal),
109
- emocion_anterior=emocion_previa.get("emotion") if emocion_previa else None,
110
  modo_recomendacion=modo_recomendacion,
111
  ciclo_recomendacion_id=cycle_id,
112
  chatbot_texto=chatbot_texto,
 
6
  from datetime import datetime, timezone
7
 
8
  from config import POSITIVE_EMOTIONS
9
+ from dao.ciclo_dao import CicloDAO
10
+ from dao.emocion_dao import EmocionDAO
11
+ from dao.historial_dao import HistorialDAO
12
+ from dao.pelicula_dao import PeliculaDAO
13
+ from modelos import ContextoEmocional, EmocionVO, ResultadoAnalisis
14
+ from services.chatbot import generar_texto_chatbot
15
+ from services.analisis_sentimientos import (
 
 
 
16
  analizar_texto,
17
  calcular_valencia_continua,
18
  calculo_arousal,
19
  estimar_arousal_emocion_es,
20
  )
21
+ from services.recomendacion import recomendar_peliculas
22
 
23
 
24
  class AnalysisService:
 
26
  self._modelo = modelo
27
  self._movies_df = movies_df
28
  self._media_global_ratings = media_global_ratings
29
+ self._emocion_dao = EmocionDAO()
30
+ self._ciclo_dao = CicloDAO()
31
+ self._historial_dao = HistorialDAO()
32
+ self._pelicula_dao = PeliculaDAO()
33
 
34
  def analizar(self, texto: str, user_id: str, estrategia: str) -> ResultadoAnalisis:
35
  momento_analisis = datetime.now(timezone.utc).isoformat()
36
+
37
+ # Emoción previa antes de registrar la actual
38
+ emocion_previa_obj = self._emocion_dao.obtener_ultima(user_id)
39
+ emocion_previa_vo = EmocionVO.desde(emocion_previa_obj) if emocion_previa_obj else None
40
 
41
  resultado, emocion_dominante, valencia_dominante = analizar_texto(self._modelo, texto)
42
  arousal_actual = calculo_arousal(resultado)
43
  valencia_continua = calcular_valencia_continua(resultado)
44
 
45
+ historial_eventos = self._emocion_dao.obtener_por_usuario(user_id=user_id, limit=200)
46
  historico_arousal = [
47
+ estimar_arousal_emocion_es(e.emocion)
48
+ for e in historial_eventos
 
49
  ]
50
 
51
  contexto = ContextoEmocional(
 
68
 
69
  modo_recomendacion = "diferente" if emocion_dominante in POSITIVE_EMOTIONS else "similar"
70
 
71
+ # Registrar emoción actual
72
+ emocion_actual = self._emocion_dao.añadir(
73
  user_id=user_id,
74
+ texto=texto,
75
+ emocion=emocion_dominante,
76
+ valencia=valencia_dominante,
77
+ tiempo=momento_analisis,
 
78
  )
79
 
80
+ # Crear ciclo vinculado a la emoción recién registrada
81
+ cycle_id = None
82
+ if emocion_actual:
83
+ ciclo = self._ciclo_dao.crear(
84
+ user_id=user_id,
85
+ emocion_pre_id=emocion_actual.id,
86
+ estrategia=modo_recomendacion,
87
+ tiempo_pre=momento_analisis,
88
+ )
89
+ cycle_id = ciclo.id if ciclo else None
90
 
91
+ # Película vista entre la emoción previa y la actual
92
  pelicula_transicion = None
93
+ if emocion_previa_vo:
94
+ hp = self._historial_dao.obtener_entre_fechas(
95
  user_id=user_id,
96
+ inicio=emocion_previa_vo.analizado_en,
97
+ fin=momento_analisis,
98
  )
99
+ if hp:
100
+ peli = self._pelicula_dao.obtener_por_id(hp[0].pelicula_id)
101
+ if peli:
102
+ pelicula_transicion = {
103
+ "movie_id": peli.id,
104
+ "title": peli.titulo,
105
+ "viewed_at": hp[0].visto_en,
106
+ }
107
+
108
+ # VO plano para chatbot (espera dict con claves "emotion" y "analyzed_at")
109
+ emocion_previa_dict = (
110
+ {"emotion": emocion_previa_vo.emocion, "analyzed_at": emocion_previa_vo.analizado_en}
111
+ if emocion_previa_vo else None
112
+ )
113
 
114
  chatbot_texto, chatbot_fuente = generar_texto_chatbot(
115
  emocion_dominante=emocion_dominante,
116
  modo_recomendacion=modo_recomendacion,
117
  recomendaciones=recomendaciones,
118
+ emocion_previa=emocion_previa_dict,
119
  pelicula_transicion=pelicula_transicion,
120
  )
121
 
 
128
  estrategia=estrategia,
129
  debug_recomendacion=debug_reco,
130
  historico_arousal_size=len(historico_arousal),
131
+ emocion_anterior=emocion_previa_vo.emocion if emocion_previa_vo else None,
132
  modo_recomendacion=modo_recomendacion,
133
  ciclo_recomendacion_id=cycle_id,
134
  chatbot_texto=chatbot_texto,
backend/services/recomendacion.py ADDED
@@ -0,0 +1,144 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Motor de recomendaciones de peliculas basado en el estado emocional del usuario y su historial de visualizacion.
3
+ Combina calidad global (suavizado bayesiano), similitud de generos y preferencias personales.
4
+ Adapta la estrategia segun el estado emocional usando el patron Strategy con EstrategiaFactory.
5
+ """
6
+
7
+ import csv
8
+
9
+ from config import ROOT_DIR
10
+ from dao.historial_dao import HistorialDAO
11
+ from modelos import ContextoEmocional
12
+ from services.estrategias_recomendacion import EstrategiaFactory
13
+ from services.calculos import construir_perfil_usuario, recomendar_calidad_aleatoria
14
+
15
+ _historial_dao = HistorialDAO()
16
+
17
+
18
+ def obtener_historial_usuario(user_id: str, limit: int = 200) -> list[dict]:
19
+ entradas = _historial_dao.obtener_por_usuario(user_id, limit=limit)
20
+ return [{"movie_id": h.pelicula_id, "user_rating": h.valoracion} for h in entradas]
21
+
22
+
23
+ # ---------------------------------------------------------------------------
24
+ # Carga de datos
25
+ # ---------------------------------------------------------------------------
26
+
27
+ def _cargar_estadisticas_ratings() -> tuple[dict[str, tuple[float, int]], float]:
28
+ ratings_path = ROOT_DIR / "data" / "ml-latest" / "ratings.csv"
29
+ if not ratings_path.exists():
30
+ return {}, 0.0
31
+
32
+ movie_sum_count: dict[str, list[float | int]] = {}
33
+ total_sum = 0.0
34
+ total_count = 0
35
+
36
+ with open(ratings_path, "r", encoding="utf-8", newline="") as f:
37
+ for row in csv.DictReader(f):
38
+ movie_id = str(row.get("movieId", "")).strip()
39
+ if not movie_id:
40
+ continue
41
+ try:
42
+ rating = float(row.get("rating", 0) or 0)
43
+ except (TypeError, ValueError):
44
+ continue
45
+ if movie_id not in movie_sum_count:
46
+ movie_sum_count[movie_id] = [0.0, 0]
47
+ movie_sum_count[movie_id][0] += rating
48
+ movie_sum_count[movie_id][1] += 1
49
+ total_sum += rating
50
+ total_count += 1
51
+
52
+ stats: dict[str, tuple[float, int]] = {
53
+ mid: (s / c, int(c))
54
+ for mid, (s, c) in movie_sum_count.items()
55
+ if c
56
+ }
57
+ global_mean = (total_sum / total_count) if total_count else 0.0
58
+ return stats, global_mean
59
+
60
+
61
+ def _cargar_links() -> dict[str, str]:
62
+ """Returns {movieId: imdbId} from links.csv (tries full dataset first, then small)."""
63
+ candidates = [
64
+ ROOT_DIR / "data" / "ml-latest" / "links.csv",
65
+ ROOT_DIR / "notebooks" / "data" / "raw" / "ml-latest-small" / "links.csv",
66
+ ]
67
+ for path in candidates:
68
+ if path.exists():
69
+ with open(path, "r", encoding="utf-8", newline="") as f:
70
+ return {
71
+ str(row.get("movieId", "")).strip(): str(row.get("imdbId", "")).strip()
72
+ for row in csv.DictReader(f)
73
+ if str(row.get("imdbId", "")).strip()
74
+ }
75
+ return {}
76
+
77
+
78
+ def cargar_dataset_movies() -> tuple[list[dict], float]:
79
+ rating_stats, global_mean = _cargar_estadisticas_ratings()
80
+ links = _cargar_links()
81
+ path = ROOT_DIR / "data" / "ml-latest" / "movies.csv"
82
+
83
+ if path.exists():
84
+ with open(path, "r", encoding="utf-8", newline="") as f:
85
+ rows = list(csv.DictReader(f))
86
+
87
+ for row in rows:
88
+ movie_id = str(row.get("movieId", "")).strip()
89
+ mean, count = rating_stats.get(movie_id, (0.0, 0))
90
+ row["rating_count"] = int(count)
91
+ row["rating_mean"] = float(mean)
92
+ row["imdb_id"] = links.get(movie_id, "")
93
+
94
+ return rows, global_mean
95
+
96
+ fallback_path = ROOT_DIR / "data" / "procesado" / "peliculas_100_emociones.csv"
97
+ if fallback_path.exists():
98
+ with open(fallback_path, "r", encoding="utf-8", newline="") as f:
99
+ rows = list(csv.DictReader(f))
100
+ for row in rows:
101
+ movie_id = str(row.get("movieId", "")).strip()
102
+ row["imdb_id"] = links.get(movie_id, "")
103
+ total_w = sum(float(r.get("rating_mean", 0) or 0) * int(r.get("rating_count", 0) or 0) for r in rows)
104
+ total_n = sum(int(r.get("rating_count", 0) or 0) for r in rows)
105
+ fallback_mean = (total_w / total_n) if total_n else 3.5
106
+ return rows, fallback_mean
107
+
108
+ return [], global_mean
109
+
110
+
111
+ # ---------------------------------------------------------------------------
112
+ # Punto de entrada principal
113
+ # ---------------------------------------------------------------------------
114
+
115
+ def recomendar_peliculas(
116
+ contexto: ContextoEmocional,
117
+ user_id: str,
118
+ limit: int,
119
+ movies_df: list[dict],
120
+ media_global_ratings: float,
121
+ estrategia_recomendacion: str,
122
+ debug_context: dict | None = None,
123
+ ) -> list[dict]:
124
+ if not movies_df:
125
+ return []
126
+
127
+ history_rows = obtener_historial_usuario(user_id) if user_id else []
128
+ perfil = construir_perfil_usuario(movies_df, history_rows)
129
+
130
+ peliculas_no_vistas = [r for r in movies_df if str(r.get("movieId", "")).strip() not in perfil.peliculas_vistas]
131
+ peliculas_candidatas = peliculas_no_vistas if peliculas_no_vistas else movies_df
132
+
133
+ if not peliculas_candidatas:
134
+ return []
135
+
136
+ if not perfil.tiene_historial:
137
+ return recomendar_calidad_aleatoria(peliculas_candidatas, media_global_ratings, limit)
138
+
139
+ try:
140
+ estrategia = EstrategiaFactory.crear(estrategia_recomendacion)
141
+ except ValueError:
142
+ return recomendar_calidad_aleatoria(peliculas_candidatas, media_global_ratings, limit)
143
+
144
+ return estrategia.recomendar(peliculas_candidatas, perfil, media_global_ratings, limit, contexto, debug_context)
chatbot/src/views/ChatView.vue CHANGED
@@ -248,9 +248,32 @@ function showSnack(text, color = "success") {
248
  snackbar.value = { show: true, text, color };
249
  }
250
 
251
- onMounted(() => {
252
- userId.value = localStorage.getItem("vs_user_id") || "";
253
- username.value = localStorage.getItem("vs_username") || "";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
254
  });
255
 
256
  watch(estrategia, v => localStorage.setItem("vs_estrategia", v));
@@ -295,7 +318,7 @@ async function analyze() {
295
  recommendations: data.recomendaciones || [],
296
  });
297
  } catch {
298
- messages.value.push({ type: "error", text: "Error al conectar con el backend." });
299
  } finally {
300
  loading.value = false;
301
  }
 
248
  snackbar.value = { show: true, text, color };
249
  }
250
 
251
+ onMounted(async () => {
252
+ const storedUserId = localStorage.getItem("vs_user_id") || "";
253
+ const storedToken = localStorage.getItem("vs_token") || "";
254
+ const storedUsername = localStorage.getItem("vs_username") || "";
255
+
256
+ if (storedToken) {
257
+ try {
258
+ const res = await fetch("http://localhost:5000/auth/verify", {
259
+ method: "POST",
260
+ headers: { "Content-Type": "application/json" },
261
+ body: JSON.stringify({ token: storedToken }),
262
+ });
263
+ if (res.ok) {
264
+ userId.value = storedUserId;
265
+ username.value = storedUsername;
266
+ } else {
267
+ localStorage.removeItem("vs_user_id");
268
+ localStorage.removeItem("vs_username");
269
+ localStorage.removeItem("vs_token");
270
+ }
271
+ } catch {
272
+ localStorage.removeItem("vs_user_id");
273
+ localStorage.removeItem("vs_username");
274
+ localStorage.removeItem("vs_token");
275
+ }
276
+ }
277
  });
278
 
279
  watch(estrategia, v => localStorage.setItem("vs_estrategia", v));
 
318
  recommendations: data.recomendaciones || [],
319
  });
320
  } catch {
321
+ messages.value.push({ type: "error", text: "Error al conectar con el " });
322
  } finally {
323
  loading.value = false;
324
  }
docs/diagrama_er.md ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Diagrama Entidad-Relación — ValorSentimental
2
+
3
+ Esquema normalizado en **BCNF (Boyce-Codd Normal Form)**.
4
+
5
+ ```mermaid
6
+ erDiagram
7
+ Usuarios {
8
+ TEXT id PK
9
+ TEXT username UK "NOT NULL"
10
+ TEXT email
11
+ TEXT password_hash "NOT NULL"
12
+ TEXT session_token
13
+ TEXT created_at "NOT NULL"
14
+ }
15
+
16
+ Peliculas {
17
+ TEXT id PK "IMDb/OMDB id"
18
+ TEXT titulo "NOT NULL"
19
+ TEXT anio
20
+ TEXT genero
21
+ TEXT poster_url
22
+ }
23
+
24
+ Emociones {
25
+ INTEGER id PK
26
+ TEXT user_id FK
27
+ TEXT texto_analizado
28
+ TEXT emocion "NOT NULL"
29
+ TEXT valencia "NOT NULL"
30
+ TEXT analizado_en "NOT NULL"
31
+ }
32
+
33
+ Historial_Peliculas {
34
+ INTEGER id PK
35
+ TEXT user_id FK
36
+ TEXT pelicula_id FK
37
+ INTEGER emocion_id FK "nullable"
38
+ REAL valoracion
39
+ TEXT texto_sesion
40
+ TEXT visto_en "NOT NULL"
41
+ }
42
+
43
+ Ciclo_Recomendacion {
44
+ INTEGER id PK
45
+ TEXT user_id FK
46
+ INTEGER emocion_pre_id FK "NOT NULL"
47
+ INTEGER estrategia "NOT NULL"
48
+ TEXT creado_en "NOT NULL"
49
+ TEXT pelicula_id FK "nullable"
50
+ INTEGER emocion_post_id FK "nullable"
51
+ }
52
+
53
+ Usuarios ||--o{ Emociones : "registra"
54
+ Usuarios ||--o{ Historial_Peliculas : "visualiza"
55
+ Usuarios ||--o{ Ciclo_Recomendacion : "inicia"
56
+ Peliculas ||--o{ Historial_Peliculas : "aparece en"
57
+ Peliculas ||--o{ Ciclo_Recomendacion : "recomendada en"
58
+ Emociones ||--o{ Historial_Peliculas : "emocion_id (al ver)"
59
+ Emociones ||--o{ Ciclo_Recomendacion : "emocion_pre_id"
60
+ Emociones ||--o{ Ciclo_Recomendacion : "emocion_post_id"
61
+ ```
62
+
63
+ ## Justificación BCNF
64
+
65
+ | Tabla | Dependencias funcionales | Cumple BCNF |
66
+ |---|---|---|
67
+ | **Usuarios** | `id → username, email, password_hash, session_token, created_at` | ✅ Solo la PK determina atributos |
68
+ | **Peliculas** | `id → titulo, anio, genero, poster_url` | ✅ Separada de historial para evitar anomalías de actualización si cambia el título |
69
+ | **Emociones** | `id → user_id, texto_analizado, emocion, valencia, analizado_en` | ✅ Entidad propia; evita repetir `(emocion, valencia)` en otras tablas |
70
+ | **Historial_Peliculas** | `id → user_id, pelicula_id, emocion_id, valoracion, texto_sesion, visto_en` | ✅ Ningún atributo no-clave determina a otro |
71
+ | **Ciclo_Recomendacion** | `id → user_id, emocion_pre_id, estrategia, creado_en, pelicula_id, emocion_post_id` | ✅ FK a Emociones elimina dependencia transitiva de `(emocion, valencia)` |
72
+
73
+ ## Reglas de integridad referencial
74
+
75
+ | FK | ON DELETE |
76
+ |---|---|
77
+ | `Emociones.user_id → Usuarios.id` | CASCADE |
78
+ | `Historial_Peliculas.user_id → Usuarios.id` | CASCADE |
79
+ | `Historial_Peliculas.pelicula_id → Peliculas.id` | RESTRICT |
80
+ | `Historial_Peliculas.emocion_id → Emociones.id` | SET NULL |
81
+ | `Ciclo_Recomendacion.user_id → Usuarios.id` | CASCADE |
82
+ | `Ciclo_Recomendacion.emocion_pre_id → Emociones.id` | RESTRICT |
83
+ | `Ciclo_Recomendacion.emocion_post_id → Emociones.id` | SET NULL |
84
+ | `Ciclo_Recomendacion.pelicula_id → Peliculas.id` | SET NULL |
package-lock.json ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ {
2
+ "name": "ValorSentimental",
3
+ "lockfileVersion": 3,
4
+ "requires": true,
5
+ "packages": {}
6
+ }
requirements.txt CHANGED
@@ -4,11 +4,10 @@ flask-cors==4.0.0
4
  requests==2.31.0
5
  python-dotenv==1.0.0
6
 
7
- # Procesar lenguaje natural y modelos de lenguaje
8
- transformers==4.35.2
9
- torch==2.0.1
10
- flair==0.12.2
11
- pysentimiento>=0.7.0
12
 
13
  # Usadas en los Jupyter Notebooks
14
  deep-translator==1.11.4
@@ -23,3 +22,4 @@ pandas==2.1.4
23
  numpy==1.24.3
24
  matplotlib==3.8.3
25
  seaborn==0.13.1
 
 
4
  requests==2.31.0
5
  python-dotenv==1.0.0
6
 
7
+ # Modelo de emociones local (robertuito)
8
+ transformers>=4.40.0
9
+ torch>=2.1.0
10
+ werkzeug>=2.3.0
 
11
 
12
  # Usadas en los Jupyter Notebooks
13
  deep-translator==1.11.4
 
22
  numpy==1.24.3
23
  matplotlib==3.8.3
24
  seaborn==0.13.1
25
+