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

DAO pattern

Browse files
backend/app_factory.py CHANGED
@@ -6,6 +6,7 @@ Las rutas son delegadores delgados: validan la entrada, llaman al servicio corre
6
  import dataclasses
7
  from datetime import datetime, timezone
8
 
 
9
  from flask import Flask, jsonify, request
10
  from flask_cors import CORS
11
 
@@ -26,11 +27,15 @@ from repositories.history_repository import (
26
  obtener_relacion_pelicula_emocion,
27
  añadir_pelicula_a_historial,
28
  )
 
29
  from services.analysis_service import AnalysisService
30
  from services.emotion_service import analizar_texto, crear_clasificador_emociones
31
  from services.recommender_service import cargar_dataset_movies
32
 
33
 
 
 
 
34
  def create_app() -> Flask:
35
  app = Flask(__name__)
36
  CORS(app)
@@ -243,6 +248,33 @@ def create_app() -> Flask:
243
  historial = obtener_peliculas_del_historial(user_id=user_id, limit=limit)
244
  return jsonify({"items": historial, "count": len(historial)})
245
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
246
  @app.route("/historial/transiciones", methods=["GET"])
247
  def obtener_transiciones():
248
  user_id = str(request.args.get("user_id", "")).strip()
 
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
 
 
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)
 
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()
backend/config.py CHANGED
@@ -1,10 +1,16 @@
1
  """
2
- Este archivo contiene rutas, constantes y configuracion como modelos pre-cargados
3
- que se utilizan en varias partes del backend.
4
  """
5
 
 
6
  from pathlib import Path
7
 
 
 
 
 
 
8
  # El directorio raiz del proyecto es /ValorSentimental.
9
  ROOT_DIR = Path(__file__).resolve().parent.parent
10
  # La base de datos de historial se guarda en /ValorSentimental/backend/history.db.
@@ -32,3 +38,7 @@ OLLAMA_URL = "http://localhost:11434/api/generate"
32
  LIKE_THRESHOLD = 4.0
33
  # Prior de suavizado para score global (evita sesgo por pocas valoraciones).
34
  GLOBAL_PRIOR_COUNT = 50.0
 
 
 
 
 
1
  """
2
+ Este archivo contiene rutas, constantes y configuracion como modelos pre-cargados
3
+ que se utilizan en varias partes del backend.
4
  """
5
 
6
+ import os
7
  from pathlib import Path
8
 
9
+ from dotenv import load_dotenv
10
+
11
+ # Load environment variables from .env file
12
+ load_dotenv(Path(__file__).parent / ".env")
13
+
14
  # El directorio raiz del proyecto es /ValorSentimental.
15
  ROOT_DIR = Path(__file__).resolve().parent.parent
16
  # La base de datos de historial se guarda en /ValorSentimental/backend/history.db.
 
38
  LIKE_THRESHOLD = 4.0
39
  # Prior de suavizado para score global (evita sesgo por pocas valoraciones).
40
  GLOBAL_PRIOR_COUNT = 50.0
41
+
42
+ # OMDB API key — set in environment variable OMDB_API_KEY.
43
+ # Free key at https://www.omdbapi.com/apikey.aspx (1000 requests/day)
44
+ OMDB_API_KEY = os.getenv("OMDB_API_KEY", "")
backend/dao/emocion_dao.py ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ from db import obtener_conexion_bd
2
+
3
+ class EmocionDAO:
4
+ def __init__(self):
5
+ self.obtener_conexion = obtener_conexion_bd()
6
+
7
+ def añadir(self, user_id: str, texto: str, emocion: str, tiempo: str):
8
+ con = self.
backend/dao/historial_dao.py ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from db import obtener_conexion_bd
2
+ from models import PeliculaVista, Emocion
3
+
4
+ class HistorialDAO:
5
+ def __init__(self):
6
+ self.obtener_conexion = obtener_conexion_bd()
7
+
8
+ def añadir_pelicula(self, user_id: str, titulo: str, emocion: str, rating: float, texto: str, tiempo: str):
9
+ con = self.obtener_conexion()
10
+ con.execute(
11
+ "INSERT INTO historial_peliculas () "
12
+ )
13
+ con.comit()
14
+
15
+ def obtener_por_usuario(self, user_id: str):
16
+ con = self.obtener_conexion()
17
+ peliculas = con.execute(
18
+ "SELECT from historial_peliculas WHERE user_id = ?",
19
+ (user_id),
20
+ ).fetchall()
21
+
22
+ return [
23
+ PeliculaVista(
24
+ id = peli["id"], user_id = ["user_id"], movie_id = peli["movie_id"], titulo = peli["title"], emocion = peli["emotion"], valoracion = peli["user_rating"], texto = peli["session_text"], tiempo = peli["viewed_at"],
25
+ ) for peli in peliculas
26
+ ]
backend/dao/usuario_dao.py ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from db import obtener_conexion_bd
2
+ from models import Usuario
3
+ import uuid
4
+ import datetime
5
+
6
+ class UsuarioDao:
7
+ def __init__(self):
8
+ self.obtener_conexion = obtener_conexion_bd()
9
+
10
+ # Crea usuario
11
+ def registrar(self, nombre: str, contraseña: str):
12
+ user_id = str(uuid.uuid4())
13
+ token = str(uuid.uuid4())
14
+
15
+ try:
16
+ con = self.obtener_conexion()
17
+ usuario = con.execute(
18
+ """INSERT INTO usuarios (id, username , password_hash, session_token, created_at)
19
+ VALUES (?, ?, ?, ?, ?, ?)""",
20
+ (user_id, nombre, hashear(contraseña), token, datetime.now().isoformat()),
21
+ )
22
+ con.commit()
23
+
24
+ return Usuario(user_id, nombre, token)
25
+ except Exception:
26
+ return None
27
+
28
+ # Inicia sesion
29
+ def login(self, nombre: str, contraseña: str):
30
+ con = self.obtener_conexion()
31
+ usuario = con.execute(
32
+ "SELECT id, username, password_hash FROM usuarios WHERE username = ?",
33
+ (nombre),
34
+ ).fetchone()
35
+ if not usuario or not comprobar_contraseña(usuario["password_hash", contraseña]):
36
+ return None
37
+ token = str(uuid.uiid4())
38
+ con = self.obtener_conexion()
39
+ con.execute("UPDATE Usuarios SET session_token = ? where id = ?",
40
+ (token, usuario["id"]))
41
+ con.commit()
42
+ return Usuario(id = usuario["id"], nombre = usuario["username"], token = token)
43
+
44
+ # Obtener por username
45
+ def obtener_por_nombre(self, nombre: str):
46
+ con = self.obtener_conexion()
47
+ usuario = con.execute(
48
+ "SELECT id, username FROM usuarios WHERE username = ?",
49
+ (nombre),
50
+ ).fetchone()
51
+ if not usuario: return None
52
+
53
+ return Usuario(usuario["id"], usuario["name"], usuario["token"])
54
+
55
+ # Obtener por token
56
+ def obtener_por_token(self, token:str):
57
+ con = self.obtener_conexion()
58
+ usuario = con.execute(
59
+ "SELECT id, username FROM usuarios WHERE token = ?",
60
+ (token),
61
+ ).fetchone()
62
+ if not usuario: return None
63
+
64
+ return Usuario(usuario["id"], usuario["name"], usuario["token"])
65
+
66
+ def actualizar_token(self, user_id: str, token: str):
67
+ con = self.obtener_conexion()
68
+ con.execute(
69
+ "UPDATE Usuarios set session_token = ? where user_id = ?",
70
+ (token, user_id),
71
+ )
72
+ con.comit()
73
+ return Usuario()
74
+
75
+ def actualizar_contraseña(self, user_id: str, contraseña_hash: str):
76
+ con = self.obtener_conexion()
77
+ con.execute(
78
+ "UPDATE Usuarios set password = ? where user_id = ?",
79
+ (contraseña_hash, user_id)
80
+ )
81
+ con.comit()
82
+ return Usuario()
83
+
84
+ def eliminar(self, user_id: str):
85
+ con = self.obtener_conexion()
86
+ con.execute(
87
+ "DELETE from Usuarios where user_id = ?",
88
+ (user_id),
89
+ )
90
+ con.commit()
91
+ return
92
+
93
+
94
+
backend/models.py CHANGED
@@ -1,40 +1,45 @@
1
  from collections import Counter
2
  from dataclasses import dataclass, field
 
3
 
4
 
5
  @dataclass
6
- class PerfilUsuario:
7
- peliculas_vistas: set[str]
8
- probabilidades_generos: dict[str, float]
9
- contador_generos_gustados: Counter
10
- medias_rating_por_genero: dict[str, float]
11
- zona_confort: set[str]
12
- ranking_generos: dict[str, int]
13
- tiene_historial: bool
14
-
15
 
16
  @dataclass
17
- class ContextoEmocional:
18
- emocion_es: str
19
- arousal_actual: float
20
- valencia_actual: float | str | None
21
- historico_arousal: list[float] = field(default_factory=list)
 
 
 
 
22
 
 
 
 
 
 
 
 
23
 
24
  @dataclass
25
- class ResultadoAnalisis:
26
- emociones: list[dict]
27
- emocion_dominante: str
28
- valencia_dominante: str
29
- valencia_continua: float
30
- arousal_actual: float
31
- estrategia: str
32
- debug_recomendacion: dict
33
- historico_arousal_size: int
34
- emocion_anterior: str | None
35
- modo_recomendacion: str
36
- ciclo_recomendacion_id: int | None
37
- chatbot_texto: str
38
- chatbot_fuente: str
39
- pelicula_transicion: dict | None
40
- recomendaciones: list[dict]
 
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/services/recommender_service.py CHANGED
@@ -52,8 +52,26 @@ def _cargar_estadisticas_ratings() -> tuple[dict[str, tuple[float, int]], float]
52
  return stats, global_mean
53
 
54
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55
  def cargar_dataset_movies() -> tuple[list[dict], float]:
56
  rating_stats, global_mean = _cargar_estadisticas_ratings()
 
57
  path = ROOT_DIR / "data" / "ml-latest" / "movies.csv"
58
 
59
  if path.exists():
@@ -65,6 +83,7 @@ def cargar_dataset_movies() -> tuple[list[dict], float]:
65
  mean, count = rating_stats.get(movie_id, (0.0, 0))
66
  row["rating_count"] = int(count)
67
  row["rating_mean"] = float(mean)
 
68
 
69
  return rows, global_mean
70
 
@@ -72,6 +91,9 @@ def cargar_dataset_movies() -> tuple[list[dict], float]:
72
  if fallback_path.exists():
73
  with open(fallback_path, "r", encoding="utf-8", newline="") as f:
74
  rows = list(csv.DictReader(f))
 
 
 
75
  total_w = sum(float(r.get("rating_mean", 0) or 0) * int(r.get("rating_count", 0) or 0) for r in rows)
76
  total_n = sum(int(r.get("rating_count", 0) or 0) for r in rows)
77
  fallback_mean = (total_w / total_n) if total_n else 3.5
@@ -257,6 +279,16 @@ def _recomendar_calidad_aleatoria(candidatas: list[dict], media_global: float, l
257
  return random.sample(pool, k=limit)
258
 
259
 
 
 
 
 
 
 
 
 
 
 
260
  # ---------------------------------------------------------------------------
261
  # Strategy pattern
262
  # ---------------------------------------------------------------------------
@@ -288,7 +320,7 @@ class EstrategiaV1(EstrategiaRecomendacion):
288
  base = inside if inside else peliculas_candidatas
289
 
290
  ranked = sorted(base, key=lambda r: _puntuacion_calidad_global(r, media_global_ratings), reverse=True)
291
- return ranked[:limit]
292
 
293
 
294
  class EstrategiaV2(EstrategiaRecomendacion):
@@ -316,7 +348,7 @@ class EstrategiaV2(EstrategiaRecomendacion):
316
  reverse=True,
317
  )
318
 
319
- return ranked[:limit]
320
 
321
 
322
  class EstrategiaV3(EstrategiaRecomendacion):
@@ -353,7 +385,7 @@ class EstrategiaV3(EstrategiaRecomendacion):
353
  ) * _puntuacion_calidad_global(peli, media_global_ratings),
354
  reverse=True,
355
  )
356
- return ranked[:limit]
357
 
358
 
359
  _ESTRATEGIAS: dict[str, EstrategiaRecomendacion] = {
 
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():
 
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
 
 
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
 
279
  return random.sample(pool, k=limit)
280
 
281
 
282
+ def _sample_from_ranked(ranked: list[dict], limit: int) -> list[dict]:
283
+ """Sample randomly from the top pool to avoid always returning identical results."""
284
+ pool_size = max(limit * 5, 30)
285
+ pool = ranked[:pool_size]
286
+ if len(pool) <= limit:
287
+ random.shuffle(pool)
288
+ return pool
289
+ return random.sample(pool, k=limit)
290
+
291
+
292
  # ---------------------------------------------------------------------------
293
  # Strategy pattern
294
  # ---------------------------------------------------------------------------
 
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):
 
348
  reverse=True,
349
  )
350
 
351
+ return _sample_from_ranked(ranked, limit)
352
 
353
 
354
  class EstrategiaV3(EstrategiaRecomendacion):
 
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] = {
chatbot/src/components/AppHero.vue CHANGED
@@ -12,6 +12,19 @@
12
  </div>
13
 
14
  <div class="hero-controls">
 
 
 
 
 
 
 
 
 
 
 
 
 
15
  <v-menu :close-on-content-click="true">
16
  <template #activator="{ props: menuProps }">
17
  <v-chip v-bind="menuProps" size="small" class="user-chip" style="cursor:pointer">
@@ -79,9 +92,12 @@ import { useRouter } from "vue-router";
79
  import { useTheme } from "vuetify";
80
 
81
  defineProps({
82
- username: { type: String, default: "" },
 
83
  });
84
 
 
 
85
  const router = useRouter();
86
  const theme = useTheme();
87
  const isDark = computed(() => theme.global.current.value.dark);
 
12
  </div>
13
 
14
  <div class="hero-controls">
15
+ <v-btn
16
+ v-if="historyCount > 0"
17
+ icon
18
+ size="small"
19
+ variant="text"
20
+ title="Historial de visionado"
21
+ @click="$emit('show-history')"
22
+ >
23
+ <v-badge :content="historyCount" color="amber-darken-1" :max="99">
24
+ <v-icon size="18">mdi-history</v-icon>
25
+ </v-badge>
26
+ </v-btn>
27
+
28
  <v-menu :close-on-content-click="true">
29
  <template #activator="{ props: menuProps }">
30
  <v-chip v-bind="menuProps" size="small" class="user-chip" style="cursor:pointer">
 
92
  import { useTheme } from "vuetify";
93
 
94
  defineProps({
95
+ username: { type: String, default: "" },
96
+ historyCount: { type: Number, default: 0 },
97
  });
98
 
99
+ defineEmits(["show-history"]);
100
+
101
  const router = useRouter();
102
  const theme = useTheme();
103
  const isDark = computed(() => theme.global.current.value.dark);
chatbot/src/components/MessageFeed.vue CHANGED
@@ -1,5 +1,5 @@
1
  <template>
2
- <v-col cols="12" md="8">
3
  <v-card class="chat-card" rounded="xl" elevation="0">
4
  <v-card-text ref="feedEl" class="feed-scroll">
5
 
@@ -119,31 +119,48 @@
119
  No hay recomendaciones disponibles.
120
  </v-alert>
121
 
122
- <div v-else class="movie-list">
123
  <div
124
  v-for="movie in msg.recommendations.slice(0, 6)"
125
  :key="movie.movieId"
126
- class="movie-item"
 
127
  >
128
- <div class="movie-info">
129
- <v-icon size="14" color="blue-grey" class="movie-icon">mdi-filmstrip</v-icon>
130
- <div class="movie-text">
131
- <div class="movie-title">{{ movie.title }}</div>
132
- <div class="movie-genres">{{ movie.genres }}</div>
 
 
 
 
 
 
 
 
 
 
133
  </div>
134
  </div>
135
- <v-btn
136
- size="x-small"
137
- :variant="viewedMovieIds.has(String(movie.movieId)) ? 'tonal' : 'flat'"
138
- :color="viewedMovieIds.has(String(movie.movieId)) ? 'success' : 'amber-darken-1'"
139
- :disabled="viewedMovieIds.has(String(movie.movieId))"
140
- rounded="lg"
141
- class="mark-btn"
142
- @click="$emit('mark-viewed', movie, msg.dominantEmotion, msg.text, msg.recommendationCycleId)"
143
- >
144
- <v-icon v-if="viewedMovieIds.has(String(movie.movieId))" size="12" class="mr-1">mdi-check</v-icon>
145
- {{ viewedMovieIds.has(String(movie.movieId)) ? 'Vista' : 'Marcar vista' }}
146
- </v-btn>
 
 
 
 
 
 
147
  </div>
148
  </div>
149
  </div>
@@ -194,7 +211,7 @@
194
  </template>
195
 
196
  <script setup>
197
- import { computed, nextTick, ref, watch } from "vue";
198
  import { useTheme } from "vuetify";
199
  import { dominantEmotionInfo, emotionInfo } from "../constants/emotions";
200
  import MessageComposer from "./MessageComposer.vue";
@@ -210,8 +227,34 @@ const props = defineProps({
210
 
211
  defineEmits(["mark-viewed", "update:input", "update:estrategia", "analyze", "quick-fill"]);
212
 
213
- const theme = useTheme();
214
- const feedEl = ref(null);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
215
 
216
  const barBgColor = computed(() =>
217
  theme.global.current.value.dark ? "rgba(255,255,255,0.07)" : "rgba(0,0,0,0.06)"
@@ -226,6 +269,46 @@ const examples = [
226
  const strategyMap = { v1: "Básica", v2: "Avanzada", v3: "Experimental" };
227
  function strategyLabel(key) { return strategyMap[key] || key; }
228
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
229
  async function scrollToBottom() {
230
  await nextTick();
231
  const el = feedEl.value?.$el;
@@ -247,7 +330,8 @@ watch(() => props.loading, val => { if (val) scrollToBottom(); });
247
  }
248
 
249
  .feed-scroll {
250
- max-height: 63vh;
 
251
  overflow-y: auto;
252
  padding: 20px;
253
  scroll-behavior: smooth;
@@ -462,7 +546,7 @@ watch(() => props.loading, val => { if (val) scrollToBottom(); });
462
  display: flex;
463
  align-items: center;
464
  gap: 4px;
465
- margin-bottom: 10px;
466
  }
467
 
468
  .reco-title {
@@ -473,45 +557,103 @@ watch(() => props.loading, val => { if (val) scrollToBottom(); });
473
 
474
  .reco-title strong { color: var(--vs-text); }
475
 
476
- .movie-list { display: flex; flex-direction: column; gap: 5px; }
 
 
 
 
 
477
 
478
- .movie-item {
479
  display: flex;
480
- align-items: center;
481
- justify-content: space-between;
482
- gap: 10px;
483
- padding: 9px 12px;
484
- background: var(--vs-movie-bg);
485
  border: 1px solid var(--vs-border);
486
- border-radius: 10px;
487
- transition: background 0.18s ease;
 
 
 
 
 
488
  }
489
 
490
- .movie-item:hover { background: var(--vs-movie-hover); }
 
 
491
 
492
- .movie-info {
 
 
 
493
  display: flex;
494
- align-items: flex-start;
495
- gap: 8px;
496
- min-width: 0;
 
 
497
  }
498
 
499
- .movie-icon { flex-shrink: 0; margin-top: 2px; }
 
 
 
 
 
 
 
500
 
501
- .movie-text { min-width: 0; }
 
 
 
 
 
 
 
502
 
503
- .movie-title {
504
- font-weight: 600;
505
- font-size: 0.88rem;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
506
  color: var(--vs-text);
 
 
 
 
 
 
 
 
 
 
507
  white-space: nowrap;
508
  overflow: hidden;
509
  text-overflow: ellipsis;
510
  }
511
 
512
- .movie-genres { font-size: 0.74rem; color: var(--vs-muted); margin-top: 1px; }
513
-
514
- .mark-btn { flex-shrink: 0; font-size: 0.72rem !important; }
515
 
516
  /* ── Loading ─────────────────────────────────── */
517
  .loading-row {
@@ -524,13 +666,13 @@ watch(() => props.loading, val => { if (val) scrollToBottom(); });
524
  }
525
 
526
  @media (max-width: 960px) {
527
- .feed-scroll { max-height: none; min-height: 320px; }
528
  .bar-row { grid-template-columns: 90px 1fr 36px; }
 
529
  }
530
 
531
  @media (max-width: 600px) {
532
- .movie-item { flex-direction: column; align-items: flex-start; gap: 6px; }
533
- .mark-btn { align-self: flex-end; }
534
  .bar-row { grid-template-columns: 80px 1fr 32px; }
 
535
  }
536
  </style>
 
1
  <template>
2
+ <v-col cols="12">
3
  <v-card class="chat-card" rounded="xl" elevation="0">
4
  <v-card-text ref="feedEl" class="feed-scroll">
5
 
 
119
  No hay recomendaciones disponibles.
120
  </v-alert>
121
 
122
+ <div v-else class="poster-grid">
123
  <div
124
  v-for="movie in msg.recommendations.slice(0, 6)"
125
  :key="movie.movieId"
126
+ class="poster-card"
127
+ :class="{ 'poster-card--viewed': viewedMovieIds.has(String(movie.movieId)) }"
128
  >
129
+ <!-- Poster thumbnail -->
130
+ <div class="poster-thumb" :style="posters[String(movie.imdb_id)] ? {} : posterStyle(movie.title)">
131
+ <img
132
+ v-if="posters[String(movie.imdb_id)]"
133
+ :src="posters[String(movie.imdb_id)]"
134
+ :alt="movie.title"
135
+ class="poster-real-img"
136
+ loading="lazy"
137
+ />
138
+ <template v-else>
139
+ <v-icon size="32" color="white" style="opacity: 0.55;">mdi-filmstrip</v-icon>
140
+ <div class="poster-initials">{{ movieInitials(movie.title) }}</div>
141
+ </template>
142
+ <div v-if="viewedMovieIds.has(String(movie.movieId))" class="poster-seen-badge">
143
+ <v-icon size="14" color="white">mdi-check</v-icon>
144
  </div>
145
  </div>
146
+ <!-- Poster info -->
147
+ <div class="poster-body">
148
+ <div class="poster-title" :title="movie.title">{{ movie.title }}</div>
149
+ <div class="poster-genres">{{ firstGenre(movie.genres) }}</div>
150
+ <v-btn
151
+ size="x-small"
152
+ :variant="viewedMovieIds.has(String(movie.movieId)) ? 'tonal' : 'flat'"
153
+ :color="viewedMovieIds.has(String(movie.movieId)) ? 'success' : 'amber-darken-1'"
154
+ :disabled="viewedMovieIds.has(String(movie.movieId))"
155
+ rounded="lg"
156
+ class="poster-btn mt-1"
157
+ block
158
+ @click="$emit('mark-viewed', movie, msg.dominantEmotion, msg.text, msg.recommendationCycleId)"
159
+ >
160
+ <v-icon v-if="viewedMovieIds.has(String(movie.movieId))" size="11" class="mr-1">mdi-check</v-icon>
161
+ {{ viewedMovieIds.has(String(movie.movieId)) ? 'Vista' : 'Marcar vista' }}
162
+ </v-btn>
163
+ </div>
164
  </div>
165
  </div>
166
  </div>
 
211
  </template>
212
 
213
  <script setup>
214
+ import { computed, nextTick, reactive, ref, watch } from "vue";
215
  import { useTheme } from "vuetify";
216
  import { dominantEmotionInfo, emotionInfo } from "../constants/emotions";
217
  import MessageComposer from "./MessageComposer.vue";
 
227
 
228
  defineEmits(["mark-viewed", "update:input", "update:estrategia", "analyze", "quick-fill"]);
229
 
230
+ const theme = useTheme();
231
+ const feedEl = ref(null);
232
+ const posters = reactive({}); // { [tmdb_id]: url | null }
233
+
234
+ async function loadPoster(imdbId) {
235
+ const key = String(imdbId);
236
+ if (!imdbId || key in posters) return;
237
+ posters[key] = null;
238
+ try {
239
+ const res = await fetch(`http://localhost:5000/poster/${key}`);
240
+ const data = await res.json();
241
+ posters[key] = data.poster_url || null;
242
+ } catch { /* keep null */ }
243
+ }
244
+
245
+ watch(
246
+ () => props.messages,
247
+ (msgs) => {
248
+ for (const msg of msgs) {
249
+ if (msg.type === "result") {
250
+ for (const movie of msg.recommendations ?? []) {
251
+ if (movie.imdb_id) loadPoster(movie.imdb_id);
252
+ }
253
+ }
254
+ }
255
+ },
256
+ { immediate: true, deep: true },
257
+ );
258
 
259
  const barBgColor = computed(() =>
260
  theme.global.current.value.dark ? "rgba(255,255,255,0.07)" : "rgba(0,0,0,0.06)"
 
269
  const strategyMap = { v1: "Básica", v2: "Avanzada", v3: "Experimental" };
270
  function strategyLabel(key) { return strategyMap[key] || key; }
271
 
272
+ function firstGenre(genres) {
273
+ if (!genres) return "";
274
+ return genres.split("|")[0];
275
+ }
276
+
277
+ function movieInitials(title) {
278
+ if (!title) return "?";
279
+ return title
280
+ .replace(/\s*\(\d{4}\)\s*$/, "")
281
+ .split(/\s+/)
282
+ .filter(w => w.length > 2)
283
+ .slice(0, 2)
284
+ .map(w => w[0].toUpperCase())
285
+ .join("") || title[0].toUpperCase();
286
+ }
287
+
288
+ const POSTER_GRADIENTS = [
289
+ ["#667eea", "#764ba2"],
290
+ ["#f59e0b", "#ef4444"],
291
+ ["#10b981", "#0d9488"],
292
+ ["#6366f1", "#8b5cf6"],
293
+ ["#ec4899", "#a855f7"],
294
+ ["#14b8a6", "#3b82f6"],
295
+ ["#f97316", "#dc2626"],
296
+ ["#3b82f6", "#6366f1"],
297
+ ["#84cc16", "#059669"],
298
+ ["#e11d48", "#9333ea"],
299
+ ];
300
+
301
+ function posterStyle(title) {
302
+ let hash = 0;
303
+ for (let i = 0; i < (title?.length ?? 0); i++) {
304
+ hash = (hash * 31 + title.charCodeAt(i)) | 0;
305
+ }
306
+ const [c1, c2] = POSTER_GRADIENTS[Math.abs(hash) % POSTER_GRADIENTS.length];
307
+ return {
308
+ background: `linear-gradient(145deg, ${c1}, ${c2})`,
309
+ };
310
+ }
311
+
312
  async function scrollToBottom() {
313
  await nextTick();
314
  const el = feedEl.value?.$el;
 
330
  }
331
 
332
  .feed-scroll {
333
+ height: calc(100vh - 360px);
334
+ min-height: 320px;
335
  overflow-y: auto;
336
  padding: 20px;
337
  scroll-behavior: smooth;
 
546
  display: flex;
547
  align-items: center;
548
  gap: 4px;
549
+ margin-bottom: 12px;
550
  }
551
 
552
  .reco-title {
 
557
 
558
  .reco-title strong { color: var(--vs-text); }
559
 
560
+ /* ── Poster grid ─────────────────────────────── */
561
+ .poster-grid {
562
+ display: grid;
563
+ grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
564
+ gap: 12px;
565
+ }
566
 
567
+ .poster-card {
568
  display: flex;
569
+ flex-direction: column;
570
+ border-radius: 12px;
571
+ overflow: hidden;
 
 
572
  border: 1px solid var(--vs-border);
573
+ background: var(--vs-movie-bg);
574
+ transition: transform 0.18s ease, box-shadow 0.18s ease;
575
+ }
576
+
577
+ .poster-card:hover {
578
+ transform: translateY(-3px);
579
+ box-shadow: 0 8px 24px rgba(0,0,0,0.15);
580
  }
581
 
582
+ .poster-card--viewed {
583
+ opacity: 0.65;
584
+ }
585
 
586
+ .poster-thumb {
587
+ position: relative;
588
+ width: 100%;
589
+ aspect-ratio: 2 / 3;
590
  display: flex;
591
+ flex-direction: column;
592
+ align-items: center;
593
+ justify-content: center;
594
+ gap: 6px;
595
+ overflow: hidden;
596
  }
597
 
598
+ .poster-real-img {
599
+ position: absolute;
600
+ inset: 0;
601
+ width: 100%;
602
+ height: 100%;
603
+ object-fit: cover;
604
+ border-radius: 0;
605
+ }
606
 
607
+ .poster-initials {
608
+ color: rgba(255,255,255,0.9);
609
+ font-weight: 800;
610
+ font-size: 1.4rem;
611
+ letter-spacing: 0.04em;
612
+ text-shadow: 0 1px 4px rgba(0,0,0,0.4);
613
+ font-family: "Fraunces", serif;
614
+ }
615
 
616
+ .poster-seen-badge {
617
+ position: absolute;
618
+ top: 6px;
619
+ right: 6px;
620
+ width: 22px;
621
+ height: 22px;
622
+ border-radius: 50%;
623
+ background: rgba(34,197,94,0.85);
624
+ display: flex;
625
+ align-items: center;
626
+ justify-content: center;
627
+ }
628
+
629
+ .poster-body {
630
+ padding: 8px 8px 10px;
631
+ display: flex;
632
+ flex-direction: column;
633
+ gap: 2px;
634
+ flex: 1;
635
+ }
636
+
637
+ .poster-title {
638
+ font-weight: 700;
639
+ font-size: 0.78rem;
640
  color: var(--vs-text);
641
+ line-height: 1.3;
642
+ display: -webkit-box;
643
+ -webkit-line-clamp: 2;
644
+ -webkit-box-orient: vertical;
645
+ overflow: hidden;
646
+ }
647
+
648
+ .poster-genres {
649
+ font-size: 0.68rem;
650
+ color: var(--vs-muted);
651
  white-space: nowrap;
652
  overflow: hidden;
653
  text-overflow: ellipsis;
654
  }
655
 
656
+ .poster-btn { font-size: 0.68rem !important; }
 
 
657
 
658
  /* ── Loading ─────────────────────────────────── */
659
  .loading-row {
 
666
  }
667
 
668
  @media (max-width: 960px) {
669
+ .feed-scroll { height: auto; min-height: 320px; }
670
  .bar-row { grid-template-columns: 90px 1fr 36px; }
671
+ .poster-grid { grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); }
672
  }
673
 
674
  @media (max-width: 600px) {
 
 
675
  .bar-row { grid-template-columns: 80px 1fr 32px; }
676
+ .poster-grid { grid-template-columns: repeat(3, 1fr); gap: 8px; }
677
  }
678
  </style>
chatbot/src/components/SidePanel.vue CHANGED
@@ -8,23 +8,35 @@
8
  <v-icon size="14" color="amber-darken-1" class="mr-1">mdi-filmstrip</v-icon>
9
  Visto recientemente
10
  </div>
11
- <v-btn
12
- size="x-small"
13
- variant="tonal"
14
- color="error"
15
- icon
16
- :disabled="!history.length || clearLoading"
17
- title="Borrar historial"
18
- @click="emit('clear-history')"
19
- >
20
- <v-icon size="15">mdi-delete-sweep-outline</v-icon>
21
- </v-btn>
 
 
 
 
 
 
 
 
 
 
 
 
22
  </div>
23
 
24
  <v-card-text class="pt-1 pb-3">
25
  <template v-if="history.length">
26
  <div
27
- v-for="item in history.slice(0, 8)"
28
  :key="item.id"
29
  class="history-item"
30
  >
@@ -39,6 +51,9 @@
39
  </div>
40
  </div>
41
  </div>
 
 
 
42
  </template>
43
  <div v-else class="empty-history">
44
  <v-icon size="26" color="blue-grey" class="mb-2">mdi-movie-open-outline</v-icon>
@@ -47,27 +62,6 @@
47
  </v-card-text>
48
  </v-card>
49
 
50
- <!-- How it works -->
51
- <v-card class="panel-card mt-3" rounded="xl" elevation="0">
52
- <div class="panel-header px-4 pt-4 pb-2">
53
- <div class="panel-label">
54
- <v-icon size="14" color="primary" class="mr-1">mdi-information-outline</v-icon>
55
- Cómo funciona
56
- </div>
57
- </div>
58
- <v-card-text class="pt-1 pb-3">
59
- <div v-for="item in howItems" :key="item.title" class="how-item">
60
- <div class="how-icon" :style="{ background: item.bg }">
61
- <v-icon size="16" :color="item.color">{{ item.icon }}</v-icon>
62
- </div>
63
- <div>
64
- <div class="how-title">{{ item.title }}</div>
65
- <div class="how-desc">{{ item.desc }}</div>
66
- </div>
67
- </div>
68
- </v-card-text>
69
- </v-card>
70
-
71
  </v-col>
72
  </template>
73
 
@@ -79,31 +73,7 @@ defineProps({
79
  clearLoading: { type: Boolean, default: false },
80
  });
81
 
82
- const emit = defineEmits(["clear-history"]);
83
-
84
- const howItems = [
85
- {
86
- icon: "mdi-emoticon-happy-outline",
87
- color: "amber-darken-1",
88
- bg: "rgba(245,158,11,0.1)",
89
- title: "Estado positivo",
90
- desc: "Te recomiendo películas nuevas que no hayas visto.",
91
- },
92
- {
93
- icon: "mdi-heart-outline",
94
- color: "primary",
95
- bg: "rgba(102,126,234,0.1)",
96
- title: "Estado negativo",
97
- desc: "Busco películas cercanas a lo que ya te gustó.",
98
- },
99
- {
100
- icon: "mdi-chart-bell-curve",
101
- color: "teal-darken-1",
102
- bg: "rgba(20,184,166,0.1)",
103
- title: "Seguimiento emocional",
104
- desc: "Compara cómo te sentiste antes y después de ver la película.",
105
- },
106
- ];
107
  </script>
108
 
109
  <style scoped>
@@ -121,6 +91,11 @@ const howItems = [
121
  justify-content: space-between;
122
  }
123
 
 
 
 
 
 
124
  .panel-label {
125
  font-size: 0.72rem;
126
  font-weight: 700;
@@ -170,6 +145,17 @@ const howItems = [
170
 
171
  .history-rating { color: #F59E0B; font-weight: 600; }
172
 
 
 
 
 
 
 
 
 
 
 
 
173
  .empty-history {
174
  display: flex;
175
  flex-direction: column;
@@ -185,37 +171,4 @@ const howItems = [
185
  max-width: 22ch;
186
  line-height: 1.5;
187
  }
188
-
189
- /* How it works */
190
- .how-item {
191
- display: flex;
192
- align-items: flex-start;
193
- gap: 12px;
194
- margin-bottom: 14px;
195
- }
196
-
197
- .how-item:last-child { margin-bottom: 0; }
198
-
199
- .how-icon {
200
- width: 32px;
201
- height: 32px;
202
- border-radius: 8px;
203
- display: flex;
204
- align-items: center;
205
- justify-content: center;
206
- flex-shrink: 0;
207
- }
208
-
209
- .how-title {
210
- font-weight: 600;
211
- font-size: 0.86rem;
212
- color: var(--vs-text);
213
- margin-bottom: 2px;
214
- }
215
-
216
- .how-desc {
217
- font-size: 0.78rem;
218
- color: var(--vs-muted);
219
- line-height: 1.45;
220
- }
221
  </style>
 
8
  <v-icon size="14" color="amber-darken-1" class="mr-1">mdi-filmstrip</v-icon>
9
  Visto recientemente
10
  </div>
11
+ <div class="header-actions">
12
+ <v-btn
13
+ v-if="history.length"
14
+ size="x-small"
15
+ variant="text"
16
+ color="primary"
17
+ class="mr-1"
18
+ @click="emit('show-all-history')"
19
+ >
20
+ Ver todo
21
+ </v-btn>
22
+ <v-btn
23
+ size="x-small"
24
+ variant="tonal"
25
+ color="error"
26
+ icon
27
+ :disabled="!history.length || clearLoading"
28
+ title="Borrar historial"
29
+ @click="emit('clear-history')"
30
+ >
31
+ <v-icon size="15">mdi-delete-sweep-outline</v-icon>
32
+ </v-btn>
33
+ </div>
34
  </div>
35
 
36
  <v-card-text class="pt-1 pb-3">
37
  <template v-if="history.length">
38
  <div
39
+ v-for="item in history.slice(0, 3)"
40
  :key="item.id"
41
  class="history-item"
42
  >
 
51
  </div>
52
  </div>
53
  </div>
54
+ <div v-if="history.length > 3" class="more-hint" @click="emit('show-all-history')">
55
+ +{{ history.length - 3 }} más
56
+ </div>
57
  </template>
58
  <div v-else class="empty-history">
59
  <v-icon size="26" color="blue-grey" class="mb-2">mdi-movie-open-outline</v-icon>
 
62
  </v-card-text>
63
  </v-card>
64
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
65
  </v-col>
66
  </template>
67
 
 
73
  clearLoading: { type: Boolean, default: false },
74
  });
75
 
76
+ const emit = defineEmits(["clear-history", "show-all-history"]);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
77
  </script>
78
 
79
  <style scoped>
 
91
  justify-content: space-between;
92
  }
93
 
94
+ .header-actions {
95
+ display: flex;
96
+ align-items: center;
97
+ }
98
+
99
  .panel-label {
100
  font-size: 0.72rem;
101
  font-weight: 700;
 
145
 
146
  .history-rating { color: #F59E0B; font-weight: 600; }
147
 
148
+ .more-hint {
149
+ font-size: 0.76rem;
150
+ color: var(--vs-muted);
151
+ text-align: center;
152
+ padding: 6px 0 2px;
153
+ cursor: pointer;
154
+ transition: color 0.15s;
155
+ }
156
+
157
+ .more-hint:hover { color: var(--vs-text); }
158
+
159
  .empty-history {
160
  display: flex;
161
  flex-direction: column;
 
171
  max-width: 22ch;
172
  line-height: 1.5;
173
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
174
  </style>
chatbot/src/views/ChatView.vue CHANGED
@@ -1,14 +1,41 @@
1
  <template>
2
  <v-main class="chat-main">
3
  <v-container fluid class="chat-container">
4
- <AppHero :username="username" />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
 
6
  <v-row align="start" class="mt-0">
7
- <SidePanel
8
- :history="history"
9
- :clear-loading="clearHistoryLoading"
10
- @clear-history="confirmClearDialog = true"
11
- />
12
  <MessageFeed
13
  :messages="messages"
14
  :loading="loading"
@@ -104,6 +131,49 @@
104
  </v-card>
105
  </v-dialog>
106
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
107
  <!-- Confirm Clear Dialog -->
108
  <v-dialog v-model="confirmClearDialog" max-width="380">
109
  <v-card class="dialog-card" rounded="xl" elevation="24">
@@ -149,7 +219,7 @@
149
  import { computed, onMounted, ref, watch } from "vue";
150
  import AppHero from "../components/AppHero.vue";
151
  import MessageFeed from "../components/MessageFeed.vue";
152
- import SidePanel from "../components/SidePanel.vue";
153
 
154
  const messages = ref([]);
155
  const input = ref("");
@@ -162,6 +232,7 @@ const followupByCycle = ref({});
162
  const clearHistoryLoading = ref(false);
163
 
164
  const pendingViewOp = ref(null);
 
165
  const ratingDialog = ref({ open: false, value: 3, movieTitle: "" });
166
  const postDialog = ref({ open: false, text: "", movieTitle: "", cycleId: null, movieId: null, movieTitleFull: "" });
167
  const confirmClearDialog = ref(false);
@@ -169,6 +240,10 @@ const snackbar = ref({ show: false, text: "", color: "success" });
169
 
170
  const viewedMovieIds = computed(() => new Set(history.value.map(i => String(i.movie_id))));
171
 
 
 
 
 
172
  function showSnack(text, color = "success") {
173
  snackbar.value = { show: true, text, color };
174
  }
@@ -320,7 +395,7 @@ async function confirmClearHistory() {
320
  }
321
 
322
  .chat-container {
323
- max-width: 1400px;
324
  margin: 0 auto;
325
  padding: 24px 20px 40px;
326
  position: relative;
@@ -352,4 +427,108 @@ async function confirmClearHistory() {
352
  pointer-events: none;
353
  z-index: 0;
354
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
355
  </style>
 
1
  <template>
2
  <v-main class="chat-main">
3
  <v-container fluid class="chat-container">
4
+ <AppHero
5
+ :username="username"
6
+ :history-count="history.length"
7
+ @show-history="fullHistoryDialog = true"
8
+ />
9
+
10
+ <!-- Recently viewed bar -->
11
+ <div v-if="history.length" class="recent-bar mb-3">
12
+ <span class="recent-label">
13
+ <v-icon size="13" class="mr-1">mdi-history</v-icon>
14
+ Visto recientemente
15
+ </span>
16
+ <div class="recent-chips">
17
+ <div
18
+ v-for="item in history.slice(0, 3)"
19
+ :key="item.id"
20
+ class="recent-chip"
21
+ >
22
+ <span class="recent-chip-emoji">{{ emotionEmoji(item.emotion) }}</span>
23
+ <span class="recent-chip-title">{{ item.title || item.movie_id }}</span>
24
+ <span v-if="item.user_rating != null" class="recent-chip-rating">{{ item.user_rating }}★</span>
25
+ </div>
26
+ </div>
27
+ <v-btn
28
+ size="x-small"
29
+ variant="text"
30
+ color="primary"
31
+ class="ml-2 flex-shrink-0"
32
+ @click="fullHistoryDialog = true"
33
+ >
34
+ Ver todo
35
+ </v-btn>
36
+ </div>
37
 
38
  <v-row align="start" class="mt-0">
 
 
 
 
 
39
  <MessageFeed
40
  :messages="messages"
41
  :loading="loading"
 
131
  </v-card>
132
  </v-dialog>
133
 
134
+ <!-- Full History Dialog -->
135
+ <v-dialog v-model="fullHistoryDialog" max-width="500" scrollable>
136
+ <v-card class="dialog-card" rounded="xl" elevation="24">
137
+ <v-card-title class="dialog-title pa-5 pb-3">
138
+ <v-icon color="amber-darken-1" size="20" class="mr-2">mdi-filmstrip</v-icon>
139
+ Historial completo
140
+ </v-card-title>
141
+ <v-card-text class="px-4 pb-2" style="max-height: 420px; overflow-y: auto;">
142
+ <template v-if="history.length">
143
+ <div
144
+ v-for="item in history"
145
+ :key="item.id"
146
+ class="fh-item"
147
+ >
148
+ <span class="fh-emoji">{{ emotionEmoji(item.emotion) }}</span>
149
+ <div class="fh-body">
150
+ <div class="fh-movie">{{ item.title || item.movie_id }}</div>
151
+ <div class="fh-meta">
152
+ <span>{{ item.emotion || "neutral" }}</span>
153
+ <span v-if="item.user_rating != null" class="fh-rating">{{ item.user_rating }}★</span>
154
+ </div>
155
+ </div>
156
+ </div>
157
+ </template>
158
+ <div v-else class="fh-empty">No hay películas en el historial.</div>
159
+ </v-card-text>
160
+ <v-card-actions class="px-5 pb-4 pt-2">
161
+ <v-btn
162
+ variant="tonal"
163
+ color="error"
164
+ size="small"
165
+ :disabled="!history.length || clearHistoryLoading"
166
+ @click="fullHistoryDialog = false; confirmClearDialog = true"
167
+ >
168
+ <v-icon size="16" class="mr-1">mdi-delete-sweep-outline</v-icon>
169
+ Borrar todo
170
+ </v-btn>
171
+ <v-spacer />
172
+ <v-btn variant="text" color="blue-grey" @click="fullHistoryDialog = false">Cerrar</v-btn>
173
+ </v-card-actions>
174
+ </v-card>
175
+ </v-dialog>
176
+
177
  <!-- Confirm Clear Dialog -->
178
  <v-dialog v-model="confirmClearDialog" max-width="380">
179
  <v-card class="dialog-card" rounded="xl" elevation="24">
 
219
  import { computed, onMounted, ref, watch } from "vue";
220
  import AppHero from "../components/AppHero.vue";
221
  import MessageFeed from "../components/MessageFeed.vue";
222
+ import { emotionInfoBySpanish } from "../constants/emotions";
223
 
224
  const messages = ref([]);
225
  const input = ref("");
 
232
  const clearHistoryLoading = ref(false);
233
 
234
  const pendingViewOp = ref(null);
235
+ const fullHistoryDialog = ref(false);
236
  const ratingDialog = ref({ open: false, value: 3, movieTitle: "" });
237
  const postDialog = ref({ open: false, text: "", movieTitle: "", cycleId: null, movieId: null, movieTitleFull: "" });
238
  const confirmClearDialog = ref(false);
 
240
 
241
  const viewedMovieIds = computed(() => new Set(history.value.map(i => String(i.movie_id))));
242
 
243
+ function emotionEmoji(emotion) {
244
+ return emotionInfoBySpanish(emotion)?.emoji ?? "🎬";
245
+ }
246
+
247
  function showSnack(text, color = "success") {
248
  snackbar.value = { show: true, text, color };
249
  }
 
395
  }
396
 
397
  .chat-container {
398
+ max-width: 1000px;
399
  margin: 0 auto;
400
  padding: 24px 20px 40px;
401
  position: relative;
 
427
  pointer-events: none;
428
  z-index: 0;
429
  }
430
+
431
+ /* Recently viewed bar */
432
+ .recent-bar {
433
+ display: flex;
434
+ align-items: center;
435
+ gap: 10px;
436
+ padding: 8px 14px;
437
+ background: var(--vs-panel);
438
+ border: 1px solid var(--vs-border);
439
+ border-radius: 12px;
440
+ overflow: hidden;
441
+ }
442
+
443
+ .recent-label {
444
+ font-size: 0.7rem;
445
+ font-weight: 700;
446
+ text-transform: uppercase;
447
+ letter-spacing: 0.07em;
448
+ color: var(--vs-muted);
449
+ display: flex;
450
+ align-items: center;
451
+ white-space: nowrap;
452
+ flex-shrink: 0;
453
+ }
454
+
455
+ .recent-chips {
456
+ display: flex;
457
+ gap: 6px;
458
+ overflow: hidden;
459
+ flex: 1;
460
+ }
461
+
462
+ .recent-chip {
463
+ display: flex;
464
+ align-items: center;
465
+ gap: 5px;
466
+ padding: 3px 10px;
467
+ background: var(--vs-movie-bg);
468
+ border: 1px solid var(--vs-border);
469
+ border-radius: 999px;
470
+ font-size: 0.75rem;
471
+ white-space: nowrap;
472
+ max-width: 200px;
473
+ overflow: hidden;
474
+ }
475
+
476
+ .recent-chip-emoji { flex-shrink: 0; font-size: 0.85rem; }
477
+
478
+ .recent-chip-title {
479
+ overflow: hidden;
480
+ text-overflow: ellipsis;
481
+ color: var(--vs-text);
482
+ font-weight: 500;
483
+ }
484
+
485
+ .recent-chip-rating {
486
+ color: #F59E0B;
487
+ font-weight: 600;
488
+ font-size: 0.7rem;
489
+ flex-shrink: 0;
490
+ }
491
+
492
+ /* Full history dialog */
493
+ .fh-item {
494
+ display: flex;
495
+ align-items: flex-start;
496
+ gap: 10px;
497
+ padding: 8px 10px;
498
+ border-radius: 10px;
499
+ margin-bottom: 4px;
500
+ background: var(--vs-movie-bg);
501
+ border: 1px solid var(--vs-border);
502
+ }
503
+
504
+ .fh-emoji { font-size: 1.1rem; line-height: 1.4; flex-shrink: 0; }
505
+
506
+ .fh-body { min-width: 0; }
507
+
508
+ .fh-movie {
509
+ color: var(--vs-text);
510
+ font-weight: 600;
511
+ font-size: 0.88rem;
512
+ white-space: nowrap;
513
+ overflow: hidden;
514
+ text-overflow: ellipsis;
515
+ }
516
+
517
+ .fh-meta {
518
+ display: flex;
519
+ align-items: center;
520
+ gap: 8px;
521
+ font-size: 0.76rem;
522
+ color: var(--vs-muted);
523
+ margin-top: 1px;
524
+ }
525
+
526
+ .fh-rating { color: #F59E0B; font-weight: 600; }
527
+
528
+ .fh-empty {
529
+ text-align: center;
530
+ padding: 30px 0;
531
+ font-size: 0.85rem;
532
+ color: var(--vs-muted);
533
+ }
534
  </style>
requirements.txt CHANGED
@@ -1,7 +1,8 @@
1
  # Backend (API)
2
- Flask==3.0.2
3
  flask-cors==4.0.0
4
  requests==2.31.0
 
5
 
6
  # Procesar lenguaje natural y modelos de lenguaje
7
  transformers==4.35.2
 
1
  # Backend (API)
2
+ Flask==3.0.2
3
  flask-cors==4.0.0
4
  requests==2.31.0
5
+ python-dotenv==1.0.0
6
 
7
  # Procesar lenguaje natural y modelos de lenguaje
8
  transformers==4.35.2