Spaces:
Sleeping
Sleeping
DAO pattern
Browse files- backend/app_factory.py +32 -0
- backend/config.py +12 -2
- backend/dao/emocion_dao.py +8 -0
- backend/dao/historial_dao.py +26 -0
- backend/dao/usuario_dao.py +94 -0
- backend/models.py +35 -30
- backend/services/recommender_service.py +35 -3
- chatbot/src/components/AppHero.vue +17 -1
- chatbot/src/components/MessageFeed.vue +192 -50
- chatbot/src/components/SidePanel.vue +44 -91
- chatbot/src/views/ChatView.vue +187 -8
- requirements.txt +2 -1
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
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 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
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
|
| 24 |
@dataclass
|
| 25 |
-
class
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 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
|
| 292 |
|
| 293 |
|
| 294 |
class EstrategiaV2(EstrategiaRecomendacion):
|
|
@@ -316,7 +348,7 @@ class EstrategiaV2(EstrategiaRecomendacion):
|
|
| 316 |
reverse=True,
|
| 317 |
)
|
| 318 |
|
| 319 |
-
return ranked
|
| 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
|
| 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:
|
|
|
|
| 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"
|
| 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="
|
| 123 |
<div
|
| 124 |
v-for="movie in msg.recommendations.slice(0, 6)"
|
| 125 |
:key="movie.movieId"
|
| 126 |
-
class="
|
|
|
|
| 127 |
>
|
| 128 |
-
<
|
| 129 |
-
|
| 130 |
-
<
|
| 131 |
-
|
| 132 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 133 |
</div>
|
| 134 |
</div>
|
| 135 |
-
<
|
| 136 |
-
|
| 137 |
-
:
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 214 |
-
const feedEl
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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:
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 477 |
|
| 478 |
-
.
|
| 479 |
display: flex;
|
| 480 |
-
|
| 481 |
-
|
| 482 |
-
|
| 483 |
-
padding: 9px 12px;
|
| 484 |
-
background: var(--vs-movie-bg);
|
| 485 |
border: 1px solid var(--vs-border);
|
| 486 |
-
|
| 487 |
-
transition:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 488 |
}
|
| 489 |
|
| 490 |
-
.
|
|
|
|
|
|
|
| 491 |
|
| 492 |
-
.
|
|
|
|
|
|
|
|
|
|
| 493 |
display: flex;
|
| 494 |
-
|
| 495 |
-
|
| 496 |
-
|
|
|
|
|
|
|
| 497 |
}
|
| 498 |
|
| 499 |
-
.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 500 |
|
| 501 |
-
.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 502 |
|
| 503 |
-
.
|
| 504 |
-
|
| 505 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 506 |
color: var(--vs-text);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 507 |
white-space: nowrap;
|
| 508 |
overflow: hidden;
|
| 509 |
text-overflow: ellipsis;
|
| 510 |
}
|
| 511 |
|
| 512 |
-
.
|
| 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 {
|
| 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 |
-
<
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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:
|
| 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
|