Spaces:
Sleeping
Sleeping
BD modificada, renombre, DAO, DTO, VO, Singleton e Factory
Browse files- .claude/settings.local.json +2 -1
- .codex +0 -0
- backend/aplicacion.py +392 -0
- backend/app_factory.py +0 -293
- backend/base_datos.py +148 -0
- backend/conexion_bd.py +25 -0
- backend/config.py +9 -4
- backend/dao/ciclo_dao.py +86 -0
- backend/dao/emocion_dao.py +76 -4
- backend/dao/historial_dao.py +100 -19
- backend/dao/pelicula_dao.py +73 -0
- backend/dao/usuario_dao.py +100 -77
- backend/db.py +0 -112
- backend/main.py +9 -0
- backend/modelos.py +132 -0
- backend/models.py +0 -45
- backend/repositories/__init__.py +0 -2
- backend/repositories/auth_repository.py +0 -91
- backend/repositories/history_repository.py +0 -522
- backend/scripts/crear_bd.py +40 -0
- backend/scripts/limpiar_bdm.py +14 -18
- backend/scripts/verificar_recomendador.py +0 -205
- backend/server.py +0 -11
- backend/services/{emotion_service.py → analisis_sentimientos.py} +39 -30
- backend/services/{recommender_service.py → calculos.py} +18 -264
- backend/services/{chatbot_service.py → chatbot.py} +23 -25
- backend/services/estrategias_recomendacion.py +132 -0
- backend/services/{analysis_service.py → pipeline.py} +56 -34
- backend/services/recomendacion.py +144 -0
- chatbot/src/views/ChatView.vue +27 -4
- docs/diagrama_er.md +84 -0
- package-lock.json +6 -0
- requirements.txt +5 -5
.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
|
| 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 |
-
#
|
| 35 |
-
|
| 36 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
| 2 |
|
| 3 |
class EmocionDAO:
|
| 4 |
def __init__(self):
|
| 5 |
-
self.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
|
| 7 |
-
def
|
| 8 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 2 |
-
from
|
|
|
|
| 3 |
|
| 4 |
class HistorialDAO:
|
| 5 |
def __init__(self):
|
| 6 |
-
self.
|
| 7 |
|
| 8 |
-
def
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
return [
|
| 23 |
-
|
| 24 |
-
id
|
| 25 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
|
|
|
|
|
|
|
|
|
| 9 |
|
| 10 |
-
|
| 11 |
-
def registrar(self, nombre: str, contraseña: str):
|
| 12 |
user_id = str(uuid.uuid4())
|
| 13 |
token = str(uuid.uuid4())
|
| 14 |
-
|
| 15 |
try:
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
)
|
| 22 |
con.commit()
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
return None
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
def
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
(nombre),
|
| 34 |
).fetchone()
|
| 35 |
-
if not
|
| 36 |
return None
|
| 37 |
-
|
| 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 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
if not usuario: return None
|
| 63 |
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 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
|
| 3 |
|
| 4 |
from __future__ import annotations
|
| 5 |
|
| 6 |
import argparse
|
| 7 |
import sqlite3
|
| 8 |
-
from pathlib import Path
|
| 9 |
import sys
|
|
|
|
| 10 |
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
if str(
|
| 14 |
-
sys.path.insert(0, str(
|
| 15 |
|
|
|
|
| 16 |
from config import HISTORY_DB_PATH
|
| 17 |
-
from db import iniciar_historial_usuario
|
| 18 |
|
| 19 |
|
| 20 |
-
def
|
| 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 =
|
| 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 |
-
|
|
|
|
| 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"
|
| 70 |
print(f"Total eliminado: {total} filas")
|
| 71 |
|
| 72 |
iniciar_historial_usuario()
|
| 73 |
-
print("Esquema verificado (tablas e
|
| 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
|
| 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 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
|
| 73 |
|
| 74 |
def analizar_texto(modelo, texto: str) -> tuple[list[dict], str, str]:
|
| 75 |
"""
|
| 76 |
-
|
| 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:
|
| 83 |
-
- emocion_dominante:
|
| 84 |
-
- valencia:
|
| 85 |
"""
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 13 |
-
from
|
| 14 |
-
|
| 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
|
| 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 =
|
| 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
|
| 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
|
| 215 |
-
generos = list(
|
| 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
|
| 222 |
-
generos =
|
| 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
|
| 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
|
| 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
|
| 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
|
| 274 |
-
ranked = sorted(candidatas, key=lambda r:
|
| 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
|
| 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
|
| 4 |
-
|
| 5 |
-
en caso de que falle la generación.
|
| 6 |
"""
|
| 7 |
|
| 8 |
import requests as http_requests
|
| 9 |
|
| 10 |
-
from config import
|
| 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 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 143 |
payload = {
|
| 144 |
-
"model":
|
| 145 |
-
"
|
| 146 |
-
"
|
| 147 |
-
"
|
| 148 |
-
|
| 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 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
if
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 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
|
| 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
|
| 10 |
-
from
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 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.
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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 =
|
| 42 |
historico_arousal = [
|
| 43 |
-
estimar_arousal_emocion_es(
|
| 44 |
-
for
|
| 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 |
-
|
|
|
|
| 69 |
user_id=user_id,
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
created_at=momento_analisis,
|
| 75 |
)
|
| 76 |
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
|
|
|
|
| 84 |
pelicula_transicion = None
|
| 85 |
-
if
|
| 86 |
-
|
| 87 |
user_id=user_id,
|
| 88 |
-
|
| 89 |
-
|
| 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=
|
| 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=
|
| 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 |
-
|
| 253 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 |
-
#
|
| 8 |
-
transformers=
|
| 9 |
-
torch=
|
| 10 |
-
|
| 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 |
+
|