iagofp commited on
Commit
2cb2b62
·
1 Parent(s): 4ce5638

Refactorizacion, scripts limpieza, mock e cargar datos

Browse files
backend/app_factory.py CHANGED
@@ -85,7 +85,7 @@ def create_app() -> Flask:
85
  )
86
 
87
  # Se añade el evento emocional al historial del usuario para seguimiento futuro.
88
- añadir_evento_emocional(user_id = user_id, text = texto, emotion = dominant_es, momento_analisis = momento_analisis)
89
 
90
  # Si existe evento anterior, se busca pelicula puente entre ambos estados.
91
  pelicula_de_transicion = None
@@ -98,11 +98,11 @@ def create_app() -> Flask:
98
 
99
  # Se genera el texto del chatbot explicando
100
  chatbot_text, chatbot_source = generar_texto_chatbot(
101
- dominant_emotion=dominant_es,
102
- recommendation_mode = recommendation_mode,
103
- recommendations = recomendaciones,
104
  emocion_previa = emocion_previa,
105
- pelicula_de_transicion = pelicula_de_transicion,
106
  )
107
 
108
  # Se devuelve toda la informacion relevante al frontend en un JSON para mostrar al usuario.
@@ -132,7 +132,7 @@ def create_app() -> Flask:
132
  # Se obtienen del payload los campos necesarios
133
  user_id = str(payload.get("user_id", "")).strip()
134
  texto_posterior = str(payload.get("texto_post", "")).strip()
135
- id_pelicula = str(payload.get("id_pelicula", "")).strip()
136
  titulo_pelicula = str(payload.get("title", "")).strip()
137
 
138
  # Se valida que el ciclo de recomendacion exista
@@ -155,17 +155,17 @@ def create_app() -> Flask:
155
  result_post, emocion_posterior, valencia_posterior = analizar_texto(modelo, texto_posterior)
156
 
157
  # Se registra la emocion posterior al visionado en el historial
158
- añadir_evento_emocional(user_id = user_id, text = texto_posterior, emotion = emocion_posterior, momento_analisis = momento_analisis)
159
  # Se modifica el ciclo de recomendacion anterior con la informacion posterior
160
  guardar_estado_posterior(
161
  cycle_id=cycle_id,
162
  user_id = user_id,
163
- texto_posterior = texto_posterior,
164
- emocion_posterior = emocion_posterior,
165
- valencia_posterior = valencia_posterior,
166
- post_momento_analisis = momento_analisis,
167
- id_pelicula = id_pelicula,
168
- titulo_pelicula = titulo_pelicula,
169
  )
170
 
171
  return jsonify(
@@ -175,8 +175,8 @@ def create_app() -> Flask:
175
  "title": titulo_pelicula,
176
  "pre_emotion": cycle.get("pre_emotion"),
177
  "pre_valence": cycle.get("pre_valence"),
178
- "emocion_posterior": emocion_posterior,
179
- "valencia_posterior": valencia_posterior,
180
  "cambio_emocional": cycle.get("pre_emotion") != emocion_posterior,
181
  "cambio_valencia": cycle.get("pre_valence") != valencia_posterior,
182
  "emociones_post": result_post,
@@ -191,7 +191,7 @@ def create_app() -> Flask:
191
  # Guarda una pelicula vista junto a su valoracion del usuario.
192
  payload = request.json or {}
193
  user_id = str(payload.get("user_id", "")).strip()
194
- id_pelicula = str(payload.get("id_pelicula", "")).strip()
195
 
196
  # Si no existen usuario o pelicula se devuelve un error
197
  if not user_id or not id_pelicula:
@@ -215,23 +215,24 @@ def create_app() -> Flask:
215
 
216
  id_pelicula_anadida = añadir_pelicula_a_historial(
217
  user_id = user_id,
218
- id_pelicula = id_pelicula,
219
  title = titulo_pelicula,
220
  emotion = emocion,
221
  user_rating = rating_usuario,
222
  session_text = texto,
223
- momento_visionado = momento_visionado,
224
  )
225
 
226
  return jsonify(
227
  {
228
  "id": id_pelicula_anadida,
229
  "user_id": user_id,
230
- "id_pelicula": id_pelicula,
231
  "title": titulo_pelicula,
232
  "emotion": emocion,
233
  "user_rating": rating_usuario,
234
  "session_text": texto,
 
235
  "momento_visionado": momento_visionado,
236
  }
237
  ), 201
 
85
  )
86
 
87
  # Se añade el evento emocional al historial del usuario para seguimiento futuro.
88
+ añadir_evento_emocional(user_id = user_id, text = texto, emotion = dominant_es, analyzed_at = momento_analisis)
89
 
90
  # Si existe evento anterior, se busca pelicula puente entre ambos estados.
91
  pelicula_de_transicion = None
 
98
 
99
  # Se genera el texto del chatbot explicando
100
  chatbot_text, chatbot_source = generar_texto_chatbot(
101
+ emocion_dominante = dominant_es,
102
+ modo_recomendacion = recommendation_mode,
103
+ recomendaciones = recomendaciones,
104
  emocion_previa = emocion_previa,
105
+ pelicula_transicion = pelicula_de_transicion,
106
  )
107
 
108
  # Se devuelve toda la informacion relevante al frontend en un JSON para mostrar al usuario.
 
132
  # Se obtienen del payload los campos necesarios
133
  user_id = str(payload.get("user_id", "")).strip()
134
  texto_posterior = str(payload.get("texto_post", "")).strip()
135
+ id_pelicula = str(payload.get("id_pelicula") or payload.get("movie_id") or "").strip()
136
  titulo_pelicula = str(payload.get("title", "")).strip()
137
 
138
  # Se valida que el ciclo de recomendacion exista
 
155
  result_post, emocion_posterior, valencia_posterior = analizar_texto(modelo, texto_posterior)
156
 
157
  # Se registra la emocion posterior al visionado en el historial
158
+ añadir_evento_emocional(user_id = user_id, text = texto_posterior, emotion = emocion_posterior, analyzed_at = momento_analisis)
159
  # Se modifica el ciclo de recomendacion anterior con la informacion posterior
160
  guardar_estado_posterior(
161
  cycle_id=cycle_id,
162
  user_id = user_id,
163
+ post_text = texto_posterior,
164
+ post_emotion = emocion_posterior,
165
+ post_valence = valencia_posterior,
166
+ post_analyzed_at = momento_analisis,
167
+ movie_id = id_pelicula,
168
+ movie_title = titulo_pelicula,
169
  )
170
 
171
  return jsonify(
 
175
  "title": titulo_pelicula,
176
  "pre_emotion": cycle.get("pre_emotion"),
177
  "pre_valence": cycle.get("pre_valence"),
178
+ "post_emotion": emocion_posterior,
179
+ "post_valence": valencia_posterior,
180
  "cambio_emocional": cycle.get("pre_emotion") != emocion_posterior,
181
  "cambio_valencia": cycle.get("pre_valence") != valencia_posterior,
182
  "emociones_post": result_post,
 
191
  # Guarda una pelicula vista junto a su valoracion del usuario.
192
  payload = request.json or {}
193
  user_id = str(payload.get("user_id", "")).strip()
194
+ id_pelicula = str(payload.get("id_pelicula") or payload.get("movie_id") or "").strip()
195
 
196
  # Si no existen usuario o pelicula se devuelve un error
197
  if not user_id or not id_pelicula:
 
215
 
216
  id_pelicula_anadida = añadir_pelicula_a_historial(
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
  )
225
 
226
  return jsonify(
227
  {
228
  "id": id_pelicula_anadida,
229
  "user_id": user_id,
230
+ "movie_id": id_pelicula,
231
  "title": titulo_pelicula,
232
  "emotion": emocion,
233
  "user_rating": rating_usuario,
234
  "session_text": texto,
235
+ "viewed_at": momento_visionado,
236
  "momento_visionado": momento_visionado,
237
  }
238
  ), 201
backend/history.db CHANGED
Binary files a/backend/history.db and b/backend/history.db differ
 
backend/scripts/limpiar_bdm.py ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Limpia todos los datos de la base de datos SQLite del backend."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import sqlite3
8
+ from pathlib import Path
9
+ import sys
10
+
11
+ CURRENT_DIR = Path(__file__).resolve().parent
12
+ BACKEND_DIR = CURRENT_DIR.parent
13
+ if str(BACKEND_DIR) not in sys.path:
14
+ sys.path.insert(0, str(BACKEND_DIR))
15
+
16
+ from config import HISTORY_DB_PATH
17
+ from db import iniciar_historial_usuario
18
+
19
+
20
+ def _obtener_tablas_usuario(conn: sqlite3.Connection) -> list[str]:
21
+ rows = conn.execute(
22
+ """
23
+ SELECT name
24
+ FROM sqlite_master
25
+ WHERE type = 'table' AND name NOT LIKE 'sqlite_%'
26
+ ORDER BY name
27
+ """
28
+ ).fetchall()
29
+ return [str(r[0]) for r in rows]
30
+
31
+
32
+ def limpiar_datos(db_path: Path) -> dict[str, int]:
33
+ if not db_path.exists():
34
+ iniciar_historial_usuario()
35
+
36
+ deleted_by_table: dict[str, int] = {}
37
+ with sqlite3.connect(db_path) as conn:
38
+ conn.execute("PRAGMA foreign_keys = OFF")
39
+ tables = _obtener_tablas_usuario(conn)
40
+
41
+ for table_name in tables:
42
+ cur = conn.execute(f"DELETE FROM {table_name}")
43
+ deleted_by_table[table_name] = int(cur.rowcount or 0)
44
+
45
+ if "sqlite_sequence" in [r[0] for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall()]:
46
+ conn.execute("DELETE FROM sqlite_sequence")
47
+
48
+ conn.commit()
49
+
50
+ return deleted_by_table
51
+
52
+
53
+ def main() -> int:
54
+ parser = argparse.ArgumentParser(description="Elimina todos los datos de la BDM (SQLite) del proyecto.")
55
+ parser.add_argument(
56
+ "--db-path",
57
+ type=Path,
58
+ default=HISTORY_DB_PATH,
59
+ help=f"Ruta de la base de datos (por defecto: {HISTORY_DB_PATH})",
60
+ )
61
+ args = parser.parse_args()
62
+
63
+ db_path = args.db_path.resolve()
64
+ deleted_by_table = limpiar_datos(db_path)
65
+
66
+ total = sum(deleted_by_table.values())
67
+ print(f"BD limpiada: {db_path}")
68
+ for table_name, count in deleted_by_table.items():
69
+ print(f"- {table_name}: {count} filas eliminadas")
70
+ print(f"Total eliminado: {total} filas")
71
+
72
+ iniciar_historial_usuario()
73
+ print("Esquema verificado (tablas e indices creados si no existian).")
74
+ return 0
75
+
76
+
77
+ if __name__ == "__main__":
78
+ raise SystemExit(main())
backend/scripts/verificar_recomendador.py ADDED
@@ -0,0 +1,198 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 services.recommender_service import cargar_dataset_movies, recomendar_peliculas
20
+
21
+
22
+ TEST_USER = "__test_algo__"
23
+ EMPTY_USER = "__test_algo_sin_historial__"
24
+
25
+
26
+ def _genres_of(row: dict) -> set[str]:
27
+ genres = str(row.get("genres", "")).split("|")
28
+ return {g.strip() for g in genres if g.strip() and g.strip() != "(no genres listed)"}
29
+
30
+
31
+ def _limpiar_usuario(conn: sqlite3.Connection, user_id: str) -> None:
32
+ conn.execute("DELETE FROM historial_peliculas WHERE user_id = ?", (user_id,))
33
+ conn.execute("DELETE FROM eventos_emociones WHERE user_id = ?", (user_id,))
34
+ conn.execute("DELETE FROM ciclos_recomendaciones WHERE user_id = ?", (user_id,))
35
+
36
+
37
+ def _insertar_historial_sintetico(conn: sqlite3.Connection, movies: list[dict], g1: str, g2: str) -> set[str]:
38
+ favoritas = []
39
+ for row in movies:
40
+ row_genres = _genres_of(row)
41
+ if g1 in row_genres or g2 in row_genres:
42
+ favoritas.append(row)
43
+ if len(favoritas) >= 10:
44
+ break
45
+
46
+ if len(favoritas) < 6:
47
+ raise RuntimeError("No hay suficientes peliculas para crear historial sintetico")
48
+
49
+ now = datetime.now(timezone.utc)
50
+ watched_ids: set[str] = set()
51
+ for idx, row in enumerate(favoritas):
52
+ movie_id = str(row.get("movieId", "")).strip()
53
+ if not movie_id:
54
+ continue
55
+ watched_ids.add(movie_id)
56
+ viewed_at = (now - timedelta(days=idx + 1)).isoformat()
57
+ conn.execute(
58
+ """
59
+ INSERT INTO historial_peliculas (user_id, movie_id, title, emotion, user_rating, session_text, viewed_at)
60
+ VALUES (?, ?, ?, ?, ?, ?, ?)
61
+ """,
62
+ (
63
+ TEST_USER,
64
+ movie_id,
65
+ str(row.get("title", "")),
66
+ "tristeza",
67
+ 4.5,
68
+ "historial sintetico",
69
+ viewed_at,
70
+ ),
71
+ )
72
+
73
+ return watched_ids
74
+
75
+
76
+ def _seleccionar_generos(movies: list[dict], min_pool: int = 30) -> tuple[str, str]:
77
+ counts: dict[str, int] = {}
78
+ for row in movies:
79
+ for g in _genres_of(row):
80
+ counts[g] = counts.get(g, 0) + 1
81
+
82
+ ranked = sorted(counts.items(), key=lambda x: x[1], reverse=True)
83
+ filtered = [g for g, n in ranked if n >= min_pool]
84
+ if len(filtered) < 2:
85
+ raise RuntimeError("No hay suficientes generos con masa critica en el dataset")
86
+ return filtered[0], filtered[1]
87
+
88
+
89
+ def _ratio_inside(recs: list[dict], zona_confort: set[str]) -> float:
90
+ if not recs:
91
+ return 0.0
92
+ inside = 0
93
+ for row in recs:
94
+ if _genres_of(row) & zona_confort:
95
+ inside += 1
96
+ return inside / len(recs)
97
+
98
+
99
+ def main() -> int:
100
+ parser = argparse.ArgumentParser(description="Valida el algoritmo de recomendacion con datos de prueba.")
101
+ parser.add_argument("--limit", type=int, default=5, help="Numero de recomendaciones por escenario")
102
+ parser.add_argument(
103
+ "--db-path",
104
+ type=Path,
105
+ default=HISTORY_DB_PATH,
106
+ help=f"Ruta de base de datos (por defecto: {HISTORY_DB_PATH})",
107
+ )
108
+ args = parser.parse_args()
109
+
110
+ iniciar_historial_usuario()
111
+ movies_df, media_global = cargar_dataset_movies()
112
+ if not movies_df:
113
+ print("ERROR: no se pudo cargar movies.csv para validar el recomendador")
114
+ return 1
115
+
116
+ db_path = args.db_path.resolve()
117
+ with sqlite3.connect(db_path) as conn:
118
+ _limpiar_usuario(conn, TEST_USER)
119
+ _limpiar_usuario(conn, EMPTY_USER)
120
+
121
+ g1, g2 = _seleccionar_generos(movies_df)
122
+ zona_confort = {g1, g2}
123
+ watched_ids = _insertar_historial_sintetico(conn, movies_df, g1, g2)
124
+ conn.commit()
125
+
126
+ recs_empty = recomendar_peliculas(
127
+ emotion_es="alegria",
128
+ user_id=EMPTY_USER,
129
+ limit=args.limit,
130
+ movies_df=movies_df,
131
+ media_global_ratings=media_global,
132
+ )
133
+ recs_pos = recomendar_peliculas(
134
+ emotion_es="alegria",
135
+ user_id=TEST_USER,
136
+ limit=args.limit,
137
+ movies_df=movies_df,
138
+ media_global_ratings=media_global,
139
+ )
140
+ recs_neg = recomendar_peliculas(
141
+ emotion_es="tristeza",
142
+ user_id=TEST_USER,
143
+ limit=args.limit,
144
+ movies_df=movies_df,
145
+ media_global_ratings=media_global,
146
+ )
147
+
148
+ empty_ok = len(recs_empty) == args.limit
149
+ pos_ok_len = len(recs_pos) == args.limit
150
+ neg_ok_len = len(recs_neg) == args.limit
151
+
152
+ pos_inside_ratio = _ratio_inside(recs_pos, zona_confort)
153
+ neg_inside_ratio = _ratio_inside(recs_neg, zona_confort)
154
+
155
+ recs_pos_ids = {str(r.get("movieId", "")).strip() for r in recs_pos}
156
+ seen_leak = bool(recs_pos_ids & watched_ids)
157
+
158
+ print("=== Verificacion recomendador ===")
159
+ print(f"Dataset cargado: {len(movies_df)} peliculas")
160
+ print(f"Zona de confort sintetica: {sorted(zona_confort)}")
161
+ print(f"Escenario sin historial: {len(recs_empty)} recomendaciones")
162
+ print(f"Escenario positivo (alegria): {len(recs_pos)} recomendaciones")
163
+ print(f"Escenario negativo (tristeza): {len(recs_neg)} recomendaciones")
164
+ print(f"Ratio recomendaciones dentro de zona (positivo): {pos_inside_ratio:.2f}")
165
+ print(f"Ratio recomendaciones dentro de zona (negativo): {neg_inside_ratio:.2f}")
166
+ print(f"Fuga de peliculas ya vistas (positivo): {seen_leak}")
167
+
168
+ checks = {
169
+ "sin_historial_limite": empty_ok,
170
+ "positivo_limite": pos_ok_len,
171
+ "negativo_limite": neg_ok_len,
172
+ "positivo_fuera_zona_predomina": pos_inside_ratio <= 0.50,
173
+ "negativo_dentro_zona_predomina": neg_inside_ratio >= 0.50,
174
+ "sin_fuga_vistas_en_positivo": not seen_leak,
175
+ }
176
+
177
+ failed = [name for name, ok in checks.items() if not ok]
178
+ exit_code = 0
179
+ if failed:
180
+ print("RESULTADO: FAIL")
181
+ print("Checks fallidos:")
182
+ for name in failed:
183
+ print(f"- {name}")
184
+ exit_code = 1
185
+ else:
186
+ print("RESULTADO: OK")
187
+
188
+ # Evita dejar datos sintéticos de test en la base de datos real.
189
+ with sqlite3.connect(db_path) as conn:
190
+ _limpiar_usuario(conn, TEST_USER)
191
+ _limpiar_usuario(conn, EMPTY_USER)
192
+ conn.commit()
193
+
194
+ return exit_code
195
+
196
+
197
+ if __name__ == "__main__":
198
+ raise SystemExit(main())
backend/services/recommender_service.py CHANGED
@@ -356,7 +356,7 @@ def _score_emocional_ciclos(
356
  scores[movie_id] = (s + alpha) / (n + alpha + beta)
357
  return scores
358
 
359
- def _score_emo_peli(r: dict) -> float:
360
  mid = str(r.get("movieId", "")).strip()
361
  return float(emotional_scores.get(mid, 0.5))
362
 
@@ -439,7 +439,7 @@ def recomendar_peliculas(
439
  base,
440
  key=lambda r: (
441
  _distancia_zona_confort(r, zona_confort, ranking_generos),
442
- _score_emo_peli(r),
443
  _puntuacion_calidad_global(r, media_global_ratings),
444
  ),
445
  reverse=True,
@@ -452,7 +452,7 @@ def recomendar_peliculas(
452
  # Ordenar por calidad global
453
  ranked = sorted(
454
  base,
455
- key=lambda r: (_score_emo_peli(r), _puntuacion_calidad_global(r, media_global_ratings)),
456
  reverse=True,
457
  )
458
 
 
356
  scores[movie_id] = (s + alpha) / (n + alpha + beta)
357
  return scores
358
 
359
+ def _score_emo_peli(r: dict, emotional_scores: dict[str, float]) -> float:
360
  mid = str(r.get("movieId", "")).strip()
361
  return float(emotional_scores.get(mid, 0.5))
362
 
 
439
  base,
440
  key=lambda r: (
441
  _distancia_zona_confort(r, zona_confort, ranking_generos),
442
+ _score_emo_peli(r, emotional_scores),
443
  _puntuacion_calidad_global(r, media_global_ratings),
444
  ),
445
  reverse=True,
 
452
  # Ordenar por calidad global
453
  ranked = sorted(
454
  base,
455
+ key=lambda r: (_score_emo_peli(r, emotional_scores), _puntuacion_calidad_global(r, media_global_ratings)),
456
  reverse=True,
457
  )
458
 
chatbot/src/App.vue CHANGED
@@ -204,12 +204,19 @@ async function clearHistory() {
204
  @import url("https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,600;9..144,700&family=Space+Grotesk:wght@400;500;700&display=swap");
205
 
206
  :root {
207
- --vs-bg-1: #0e1a2b;
208
- --vs-bg-2: #10253d;
209
- --vs-panel: #102033cc;
210
- --vs-border: #3a5675;
211
- --vs-text: #dbe7f5;
212
- --vs-muted: #9fb3c8;
 
 
 
 
 
 
 
213
  }
214
 
215
  html,
@@ -220,21 +227,68 @@ body,
220
 
221
  body {
222
  margin: 0;
223
- background:
224
- radial-gradient(circle at 10% 20%, #103657 0%, transparent 35%),
225
- radial-gradient(circle at 90% 0%, #3b2d58 0%, transparent 30%),
226
- linear-gradient(140deg, var(--vs-bg-1), var(--vs-bg-2));
227
  color: var(--vs-text);
 
228
  }
229
 
230
  .sentimental-app {
231
  font-family: "Space Grotesk", "Segoe UI", sans-serif;
232
  color: var(--vs-text);
 
233
  }
234
 
235
  .app-container {
236
- max-width: 1280px;
237
  margin: 0 auto;
238
- padding: 24px 16px 28px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
239
  }
240
  </style>
 
204
  @import url("https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,600;9..144,700&family=Space+Grotesk:wght@400;500;700&display=swap");
205
 
206
  :root {
207
+ /* Variables para glassmorphism + VIP */
208
+ --vs-bg-1: #05070a;
209
+ --vs-bg-2: #0a0e27;
210
+ --vs-bg-3: #1a1f3a;
211
+ --vs-panel: rgba(30, 41, 82, 0.35);
212
+ --vs-panel-hover: rgba(30, 41, 82, 0.5);
213
+ --vs-border: rgba(212, 175, 55, 0.2);
214
+ --vs-text: #f0f6ff;
215
+ --vs-muted: #a8b8d0;
216
+ --vs-gold: #d4af37;
217
+ --vs-gold-glow: rgba(212, 175, 55, 0.4);
218
+ --vs-glass-blur: 30px;
219
+ --vs-glass-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
220
  }
221
 
222
  html,
 
227
 
228
  body {
229
  margin: 0;
230
+ background: linear-gradient(135deg, #0a0e27 0%, #1a1f3a 50%, #0d1626 100%);
231
+ background-attachment: fixed;
 
 
232
  color: var(--vs-text);
233
+ overflow-x: hidden;
234
  }
235
 
236
  .sentimental-app {
237
  font-family: "Space Grotesk", "Segoe UI", sans-serif;
238
  color: var(--vs-text);
239
+ background: transparent;
240
  }
241
 
242
  .app-container {
243
+ max-width: 1400px;
244
  margin: 0 auto;
245
+ padding: 32px 20px 40px;
246
+ position: relative;
247
+ }
248
+
249
+ /* Efectos de fondo animado */
250
+ @keyframes vipGlowPulse {
251
+ 0%, 100% {
252
+ box-shadow: 0 0 30px rgba(212, 175, 55, 0.3), inset 0 0 30px rgba(212, 175, 55, 0.05);
253
+ }
254
+ 50% {
255
+ box-shadow: 0 0 50px rgba(212, 175, 55, 0.5), inset 0 0 40px rgba(212, 175, 55, 0.1);
256
+ }
257
+ }
258
+
259
+ @keyframes liquidFlow {
260
+ 0%, 100% {
261
+ transform: translateY(0);
262
+ }
263
+ 50% {
264
+ transform: translateY(-2px);
265
+ }
266
+ }
267
+
268
+ /* Decoraciones VIP */
269
+ .app-container::before {
270
+ content: '';
271
+ position: fixed;
272
+ top: -50%;
273
+ right: -10%;
274
+ width: 600px;
275
+ height: 600px;
276
+ background: radial-gradient(circle, rgba(212, 175, 55, 0.15) 0%, transparent 70%);
277
+ border-radius: 50%;
278
+ pointer-events: none;
279
+ z-index: 0;
280
+ }
281
+
282
+ .app-container::after {
283
+ content: '';
284
+ position: fixed;
285
+ bottom: -20%;
286
+ left: -5%;
287
+ width: 500px;
288
+ height: 500px;
289
+ background: radial-gradient(circle, rgba(102, 126, 234, 0.1) 0%, transparent 70%);
290
+ border-radius: 50%;
291
+ pointer-events: none;
292
+ z-index: 0;
293
  }
294
  </style>
chatbot/src/components/AppHero.vue CHANGED
@@ -36,25 +36,62 @@ defineProps({
36
 
37
  <style scoped>
38
  .hero {
39
- border: 1px solid color-mix(in srgb, var(--vs-border), transparent 55%);
40
- background: linear-gradient(135deg, var(--vs-glass-bg-a), var(--vs-glass-bg-b));
41
- box-shadow: var(--vs-glass-shadow);
42
- backdrop-filter: blur(14px) saturate(130%);
43
- -webkit-backdrop-filter: blur(14px) saturate(130%);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
44
  }
45
 
46
  .hero::before {
47
- background:
48
- radial-gradient(600px 220px at 20% 0%,
49
- rgba(255,255,255,0.65),
50
- rgba(255,255,255,0) 60%),
51
- repeating-linear-gradient(
52
- 45deg,
53
- var(--vs-glass-stripe) 0,
54
- var(--vs-glass-stripe) 1px,
55
- transparent 1px,
56
- transparent 12px
57
- );
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
58
  }
59
 
60
  .hero-top {
@@ -62,30 +99,62 @@ defineProps({
62
  align-items: center;
63
  justify-content: space-between;
64
  gap: 16px;
 
 
65
  }
66
 
67
  .kicker {
68
  margin: 0;
69
- letter-spacing: 0.06em;
70
  text-transform: uppercase;
71
- font-size: 0.72rem;
72
- font-weight: 650;
73
- color: color-mix(in srgb, #b45309, #000 10%); /* ámbar más “ink” */
 
 
 
74
  }
75
 
76
  .hero-title {
77
- margin: 6px 0 0;
78
  font-family: "Fraunces", serif;
79
- font-size: clamp(1.55rem, 3vw, 2.15rem);
80
- line-height: 1.1;
81
- color: color-mix(in srgb, #0f172a, transparent 5%);
 
 
 
 
 
82
  }
83
 
84
  .hero-subtitle {
85
- margin: 14px 0 0;
86
  max-width: 74ch;
87
- color: color-mix(in srgb, #0f172a, transparent 35%);
88
- line-height: 1.55;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
89
  }
90
 
91
  @media (max-width: 960px) {
@@ -93,5 +162,9 @@ defineProps({
93
  flex-direction: column;
94
  align-items: flex-start;
95
  }
 
 
 
 
96
  }
97
  </style>
 
36
 
37
  <style scoped>
38
  .hero {
39
+ position: relative;
40
+ overflow: hidden;
41
+ border: 2px solid var(--vs-border);
42
+ background: linear-gradient(135deg, rgba(26, 31, 58, 0.5) 0%, rgba(30, 41, 82, 0.4) 100%);
43
+ box-shadow:
44
+ 0 8px 32px rgba(212, 175, 55, 0.15),
45
+ inset 0 0 50px rgba(212, 175, 55, 0.05),
46
+ 0 0 0 1px rgba(212, 175, 55, 0.1);
47
+ backdrop-filter: blur(30px) saturate(150%);
48
+ -webkit-backdrop-filter: blur(30px) saturate(150%);
49
+ animation: vipGlowPulse 6s ease-in-out infinite;
50
+ }
51
+
52
+ @keyframes vipGlowPulse {
53
+ 0%, 100% {
54
+ box-shadow:
55
+ 0 8px 32px rgba(212, 175, 55, 0.15),
56
+ inset 0 0 50px rgba(212, 175, 55, 0.05),
57
+ 0 0 0 1px rgba(212, 175, 55, 0.1);
58
+ }
59
+ 50% {
60
+ box-shadow:
61
+ 0 8px 32px rgba(212, 175, 55, 0.3),
62
+ inset 0 0 50px rgba(212, 175, 55, 0.1),
63
+ 0 0 30px rgba(212, 175, 55, 0.2);
64
+ }
65
  }
66
 
67
  .hero::before {
68
+ content: '';
69
+ position: absolute;
70
+ top: -50%;
71
+ right: -10%;
72
+ width: 300px;
73
+ height: 300px;
74
+ background: radial-gradient(circle, rgba(212, 175, 55, 0.3) 0%, transparent 70%);
75
+ border-radius: 50%;
76
+ pointer-events: none;
77
+ animation: liquidFlow 8s ease-in-out infinite;
78
+ }
79
+
80
+ .hero::after {
81
+ content: '';
82
+ position: absolute;
83
+ bottom: -30%;
84
+ left: -5%;
85
+ width: 250px;
86
+ height: 250px;
87
+ background: radial-gradient(circle, rgba(102, 126, 234, 0.2) 0%, transparent 70%);
88
+ border-radius: 50%;
89
+ pointer-events: none;
90
+ }
91
+
92
+ @keyframes liquidFlow {
93
+ 0%, 100% { transform: translate(0, 0); }
94
+ 50% { transform: translate(10px, -10px); }
95
  }
96
 
97
  .hero-top {
 
99
  align-items: center;
100
  justify-content: space-between;
101
  gap: 16px;
102
+ position: relative;
103
+ z-index: 1;
104
  }
105
 
106
  .kicker {
107
  margin: 0;
108
+ letter-spacing: 0.1em;
109
  text-transform: uppercase;
110
+ font-size: 0.75rem;
111
+ font-weight: 700;
112
+ background: linear-gradient(90deg, #d4af37 0%, #f4e4c1 50%, #d4af37 100%);
113
+ -webkit-background-clip: text;
114
+ -webkit-text-fill-color: transparent;
115
+ background-clip: text;
116
  }
117
 
118
  .hero-title {
119
+ margin: 8px 0 0;
120
  font-family: "Fraunces", serif;
121
+ font-size: clamp(1.8rem, 4vw, 2.4rem);
122
+ line-height: 1.2;
123
+ background: linear-gradient(135deg, #f0f6ff 0%, #d4af37 50%, #f0f6ff 100%);
124
+ -webkit-background-clip: text;
125
+ -webkit-text-fill-color: transparent;
126
+ background-clip: text;
127
+ font-weight: 700;
128
+ text-shadow: 0 0 30px rgba(212, 175, 55, 0.3);
129
  }
130
 
131
  .hero-subtitle {
132
+ margin: 16px 0 16px;
133
  max-width: 74ch;
134
+ color: var(--vs-muted);
135
+ line-height: 1.6;
136
+ position: relative;
137
+ z-index: 1;
138
+ }
139
+
140
+ :deep(.v-chip) {
141
+ background: linear-gradient(135deg, rgba(212, 175, 55, 0.2), rgba(212, 175, 55, 0.1)) !important;
142
+ border: 1px solid var(--vs-gold) !important;
143
+ color: var(--vs-gold) !important;
144
+ font-weight: 600;
145
+ box-shadow: 0 0 15px rgba(212, 175, 55, 0.3);
146
+ }
147
+
148
+ :deep(.v-btn) {
149
+ background: linear-gradient(135deg, #667eea, #764ba2) !important;
150
+ color: white !important;
151
+ box-shadow: 0 8px 20px rgba(102, 126, 234, 0.4);
152
+ transition: all 0.3s ease;
153
+ }
154
+
155
+ :deep(.v-btn):hover {
156
+ box-shadow: 0 12px 30px rgba(102, 126, 234, 0.6);
157
+ transform: translateY(-2px);
158
  }
159
 
160
  @media (max-width: 960px) {
 
162
  flex-direction: column;
163
  align-items: flex-start;
164
  }
165
+
166
+ .hero-title {
167
+ font-size: clamp(1.5rem, 3vw, 2rem);
168
+ }
169
  }
170
  </style>
chatbot/src/components/MessageComposer.vue CHANGED
@@ -47,14 +47,50 @@ defineEmits(["update:input", "analyze"]);
47
 
48
  <style scoped>
49
  .composer-card {
50
- border: 1px solid color-mix(in srgb, var(--vs-border), transparent 40%);
51
  background: var(--vs-panel);
52
- backdrop-filter: blur(5px);
 
 
 
 
 
 
 
 
53
  }
54
 
55
  .composer-actions {
56
- margin-top: 10px;
57
  display: flex;
58
  justify-content: flex-end;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
59
  }
60
  </style>
 
47
 
48
  <style scoped>
49
  .composer-card {
50
+ border: 1.5px solid var(--vs-border);
51
  background: var(--vs-panel);
52
+ backdrop-filter: blur(20px) saturate(120%);
53
+ -webkit-backdrop-filter: blur(20px) saturate(120%);
54
+ box-shadow: 0 8px 32px rgba(212, 175, 55, 0.1), inset 0 0 30px rgba(212, 175, 55, 0.03);
55
+ transition: all 0.3s ease;
56
+ }
57
+
58
+ .composer-card:focus-within {
59
+ box-shadow: 0 8px 32px rgba(212, 175, 55, 0.2), inset 0 0 30px rgba(212, 175, 55, 0.08);
60
+ border-color: rgba(212, 175, 55, 0.4);
61
  }
62
 
63
  .composer-actions {
64
+ margin-top: 14px;
65
  display: flex;
66
  justify-content: flex-end;
67
+ gap: 10px;
68
+ }
69
+
70
+ :deep(.v-textarea) {
71
+ background: rgba(255, 255, 255, 0.02) !important;
72
+ }
73
+
74
+ :deep(.v-field__input) {
75
+ color: var(--vs-text) !important;
76
+ font-weight: 500;
77
+ }
78
+
79
+ :deep(.v-field__outline__start),
80
+ :deep(.v-field__outline__end) {
81
+ border-color: rgba(212, 175, 55, 0.2) !important;
82
+ }
83
+
84
+ :deep(.v-btn) {
85
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%) !important;
86
+ color: white !important;
87
+ box-shadow: 0 8px 20px rgba(102, 126, 234, 0.4);
88
+ font-weight: 600;
89
+ transition: all 0.3s ease;
90
+ }
91
+
92
+ :deep(.v-btn):hover:not(:disabled) {
93
+ box-shadow: 0 12px 30px rgba(102, 126, 234, 0.6);
94
+ transform: translateY(-2px);
95
  }
96
  </style>
chatbot/src/components/MessageFeed.vue CHANGED
@@ -174,9 +174,11 @@ defineEmits(["mark-viewed", "update:input", "analyze"]);
174
 
175
  <style scoped>
176
  .chat-card {
177
- border: 1px solid color-mix(in srgb, var(--vs-border), transparent 40%);
178
  background: var(--vs-panel);
179
- backdrop-filter: blur(5px);
 
 
180
  }
181
 
182
  .feed-scroll {
@@ -211,16 +213,21 @@ defineEmits(["mark-viewed", "update:input", "analyze"]);
211
 
212
  .user-bubble {
213
  max-width: 78%;
214
- padding: 10px 14px;
215
- background: linear-gradient(125deg, #f59e0b, #f97316);
216
  color: #1f1100;
217
  font-weight: 600;
 
 
218
  }
219
 
220
  .result-card {
221
- border: 1px solid #4f6882;
222
  border-left: 4px solid;
223
- background: #0f2135bf;
 
 
 
224
  }
225
 
226
  .quoted-text {
@@ -266,8 +273,11 @@ defineEmits(["mark-viewed", "update:input", "analyze"]);
266
 
267
  .reco-list {
268
  border-radius: 12px;
269
- padding: 6px;
270
- background: #ffffff06;
 
 
 
271
  }
272
 
273
  .reco-item {
 
174
 
175
  <style scoped>
176
  .chat-card {
177
+ border: 1.5px solid var(--vs-border);
178
  background: var(--vs-panel);
179
+ backdrop-filter: blur(20px) saturate(120%);
180
+ -webkit-backdrop-filter: blur(20px) saturate(120%);
181
+ box-shadow: 0 8px 32px rgba(212, 175, 55, 0.1), inset 0 0 30px rgba(212, 175, 55, 0.03);
182
  }
183
 
184
  .feed-scroll {
 
213
 
214
  .user-bubble {
215
  max-width: 78%;
216
+ padding: 12px 16px;
217
+ background: linear-gradient(135deg, #f59e0b, #f97316);
218
  color: #1f1100;
219
  font-weight: 600;
220
+ box-shadow: 0 8px 20px rgba(245, 158, 11, 0.4);
221
+ border-radius: 18px;
222
  }
223
 
224
  .result-card {
225
+ border: 1.5px solid;
226
  border-left: 4px solid;
227
+ background: rgba(15, 33, 53, 0.5);
228
+ backdrop-filter: blur(15px) saturate(120%);
229
+ -webkit-backdrop-filter: blur(15px) saturate(120%);
230
+ box-shadow: 0 8px 32px rgba(212, 175, 55, 0.08), inset 0 0 25px rgba(212, 175, 55, 0.02);
231
  }
232
 
233
  .quoted-text {
 
273
 
274
  .reco-list {
275
  border-radius: 12px;
276
+ padding: 8px;
277
+ background: rgba(255, 255, 255, 0.05);
278
+ backdrop-filter: blur(10px);
279
+ -webkit-backdrop-filter: blur(10px);
280
+ border: 1px solid rgba(212, 175, 55, 0.1);
281
  }
282
 
283
  .reco-item {
chatbot/src/components/SidePanel.vue CHANGED
@@ -69,9 +69,11 @@ const emit = defineEmits(["clear-history"]);
69
 
70
  <style scoped>
71
  .panel-card {
72
- border: 1px solid color-mix(in srgb, var(--vs-border), transparent 40%);
73
  background: var(--vs-panel);
74
- backdrop-filter: blur(5px);
 
 
75
  }
76
 
77
  .panel-title {
@@ -88,8 +90,18 @@ const emit = defineEmits(["clear-history"]);
88
 
89
  .history-item {
90
  border-radius: 10px;
91
- margin-bottom: 3px;
92
- background: #ffffff05;
 
 
 
 
 
 
 
 
 
 
93
  }
94
 
95
  .history-movie {
 
69
 
70
  <style scoped>
71
  .panel-card {
72
+ border: 1.5px solid var(--vs-border);
73
  background: var(--vs-panel);
74
+ backdrop-filter: blur(20px) saturate(120%);
75
+ -webkit-backdrop-filter: blur(20px) saturate(120%);
76
+ box-shadow: 0 8px 32px rgba(212, 175, 55, 0.1), inset 0 0 30px rgba(212, 175, 55, 0.03);
77
  }
78
 
79
  .panel-title {
 
90
 
91
  .history-item {
92
  border-radius: 10px;
93
+ margin-bottom: 6px;
94
+ background: rgba(255, 255, 255, 0.05);
95
+ backdrop-filter: blur(10px);
96
+ -webkit-backdrop-filter: blur(10px);
97
+ border: 1px solid rgba(212, 175, 55, 0.1);
98
+ transition: all 0.3s ease;
99
+ }
100
+
101
+ .history-item:hover {
102
+ background: rgba(255, 255, 255, 0.08);
103
+ border-color: rgba(212, 175, 55, 0.2);
104
+ box-shadow: 0 4px 12px rgba(212, 175, 55, 0.1);
105
  }
106
 
107
  .history-movie {
chatbot/src/main.js CHANGED
@@ -6,33 +6,105 @@ import { createVuetify } from "vuetify";
6
  import * as components from "vuetify/components";
7
  import * as directives from "vuetify/directives";
8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
  const vuetify = createVuetify({
10
- components,
11
- directives,
12
- theme: {
13
- defaultTheme: "sentimental",
14
- defaultTheme: 'vsLight',
15
  themes: {
16
  vsLight: {
17
  dark: false,
18
  colors: {
19
  background: '#f6f7fb',
20
  surface: '#ffffff',
21
- primary: '#3b82f6',
 
22
  },
23
  },
24
  vsDark: {
25
  dark: true,
26
  colors: {
27
- background: '#0b1220',
28
- surface: '#0f172a',
29
- primary: '#60a5fa',
 
 
 
 
 
 
30
  },
31
  },
32
- },
33
- icons: {
34
- defaultSet: "mdi",
35
- },
 
36
  });
37
 
38
  createApp(App).use(vuetify).mount("#root");
 
6
  import * as components from "vuetify/components";
7
  import * as directives from "vuetify/directives";
8
 
9
+ // Estilos globales glassmorphism + liquid + VIP
10
+ const globalStyles = document.createElement("style");
11
+ globalStyles.textContent = `
12
+ :root {
13
+ /* Colores VIP - Dorado y Plateado */
14
+ --vip-gold: #d4af37;
15
+ --vip-gold-light: #f4e4c1;
16
+ --vip-silver: #c0c0c0;
17
+ --vip-platinum: #e8e8e8;
18
+
19
+ /* Glassmorphism */
20
+ --glass-blur: 25px;
21
+ --glass-opacity: 0.25;
22
+ --glass-border: 1px solid rgba(255, 255, 255, 0.18);
23
+
24
+ /* Colores modernos */
25
+ --primary-gradient: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
26
+ --secondary-gradient: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
27
+ --accent-gold: #d4af37;
28
+ --dark-bg: #0a0e27;
29
+ --darker-bg: #05070a;
30
+ }
31
+
32
+ * {
33
+ box-sizing: border-box;
34
+ }
35
+
36
+ html, body {
37
+ margin: 0;
38
+ padding: 0;
39
+ overflow-x: hidden;
40
+ }
41
+
42
+ body {
43
+ background: linear-gradient(135deg, #0a0e27 0%, #1a1f3a 50%, #0d1626 100%);
44
+ background-attachment: fixed;
45
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
46
+ color: #f0f0f0;
47
+ }
48
+
49
+ /* Animación de fondo animado */
50
+ @keyframes gradientShift {
51
+ 0% { background-position: 0% 50%; }
52
+ 50% { background-position: 100% 50%; }
53
+ 100% { background-position: 0% 50%; }
54
+ }
55
+
56
+ /* Efecto glow VIP */
57
+ @keyframes vipGlow {
58
+ 0%, 100% { box-shadow: 0 0 20px rgba(212, 175, 55, 0.5), inset 0 0 20px rgba(212, 175, 55, 0.1); }
59
+ 50% { box-shadow: 0 0 40px rgba(212, 175, 55, 0.8), inset 0 0 30px rgba(212, 175, 55, 0.2); }
60
+ }
61
+
62
+ /* Efecto liquid */
63
+ @keyframes liquidFlow {
64
+ 0%, 100% { transform: translateY(0px) rotate(0deg); }
65
+ 50% { transform: translateY(-4px) rotate(1deg); }
66
+ }
67
+
68
+ .v-app {
69
+ background: transparent !important;
70
+ }
71
+ `;
72
+ document.head.appendChild(globalStyles);
73
+
74
  const vuetify = createVuetify({
75
+ components,
76
+ directives,
77
+ theme: {
78
+ defaultTheme: 'vsDark',
 
79
  themes: {
80
  vsLight: {
81
  dark: false,
82
  colors: {
83
  background: '#f6f7fb',
84
  surface: '#ffffff',
85
+ primary: '#667eea',
86
+ secondary: '#d4af37',
87
  },
88
  },
89
  vsDark: {
90
  dark: true,
91
  colors: {
92
+ background: '#0a0e27',
93
+ surface: 'rgba(30, 41, 82, 0.4)',
94
+ primary: '#667eea',
95
+ secondary: '#d4af37',
96
+ accent: '#764ba2',
97
+ error: '#f5576c',
98
+ warning: '#d4af37',
99
+ info: '#667eea',
100
+ success: '#11bc9d',
101
  },
102
  },
103
+ },
104
+ },
105
+ icons: {
106
+ defaultSet: "mdi",
107
+ },
108
  });
109
 
110
  createApp(App).use(vuetify).mount("#root");
data/download_movielens_large.py ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import shutil
5
+ from pathlib import Path
6
+ from tempfile import NamedTemporaryFile
7
+ from urllib.request import urlretrieve
8
+ from zipfile import ZipFile
9
+
10
+ MOVIELENS_LARGE_URL = "https://files.grouplens.org/datasets/movielens/ml-latest.zip"
11
+
12
+
13
+ def download_zip(url: str, destination: Path) -> None:
14
+ destination.parent.mkdir(parents=True, exist_ok=True)
15
+ print(f"Descargando: {url}")
16
+ urlretrieve(url, destination)
17
+ print(f"Archivo descargado en: {destination}")
18
+
19
+
20
+ def extract_csv_files(zip_path: Path, output_dir: Path) -> list[Path]:
21
+ extracted_csvs: list[Path] = []
22
+
23
+ with ZipFile(zip_path, "r") as archive:
24
+ csv_members = [m for m in archive.namelist() if m.lower().endswith(".csv")]
25
+ if not csv_members:
26
+ raise RuntimeError("El ZIP no contiene archivos CSV.")
27
+
28
+ output_dir.mkdir(parents=True, exist_ok=True)
29
+
30
+ for member in csv_members:
31
+ filename = Path(member).name
32
+ target_path = output_dir / filename
33
+
34
+ with archive.open(member) as src, open(target_path, "wb") as dst:
35
+ shutil.copyfileobj(src, dst)
36
+
37
+ extracted_csvs.append(target_path)
38
+
39
+ return extracted_csvs
40
+
41
+
42
+ def main() -> None:
43
+ parser = argparse.ArgumentParser(
44
+ description="Descarga y extrae los CSV de MovieLens (version grande: ml-latest)."
45
+ )
46
+ parser.add_argument(
47
+ "--url",
48
+ default=MOVIELENS_LARGE_URL,
49
+ help="URL del dataset ZIP de MovieLens.",
50
+ )
51
+ parser.add_argument(
52
+ "--output",
53
+ default=str(Path(__file__).resolve().parent / "ml-latest"),
54
+ help="Directorio donde se guardaran los CSV extraidos.",
55
+ )
56
+ parser.add_argument(
57
+ "--force",
58
+ action="store_true",
59
+ help="Si se indica, elimina los CSV existentes y vuelve a descargarlos.",
60
+ )
61
+
62
+ args = parser.parse_args()
63
+ output_dir = Path(args.output).resolve()
64
+
65
+ if args.force and output_dir.exists():
66
+ for csv_file in output_dir.glob("*.csv"):
67
+ csv_file.unlink()
68
+
69
+ existing_csvs = list(output_dir.glob("*.csv")) if output_dir.exists() else []
70
+ if existing_csvs and not args.force:
71
+ print(f"Ya existen {len(existing_csvs)} CSV en: {output_dir}")
72
+ print("Usa --force para volver a descargarlos.")
73
+ return
74
+
75
+ with NamedTemporaryFile(suffix=".zip", delete=False) as temp_file:
76
+ temp_zip = Path(temp_file.name)
77
+
78
+ try:
79
+ download_zip(args.url, temp_zip)
80
+ extracted = extract_csv_files(temp_zip, output_dir)
81
+ print(f"CSV extraidos en: {output_dir}")
82
+ print(f"Total de archivos: {len(extracted)}")
83
+ finally:
84
+ temp_zip.unlink(missing_ok=True)
85
+
86
+
87
+ if __name__ == "__main__":
88
+ main()