iagofp commited on
Commit
f2bead0
·
1 Parent(s): 7e66729

Modularizacion de Backend

Browse files
.vscode/settings.json ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "files.exclude": {
3
+ "**/__pycache__": true,
4
+ "**/.pytest_cache": true,
5
+ "**/.mypy_cache": true,
6
+ "**/.ruff_cache": true,
7
+ "**/.ipynb_checkpoints": true
8
+ },
9
+ "search.exclude": {
10
+ "**/__pycache__": true,
11
+ "**/.pytest_cache": true,
12
+ "**/.mypy_cache": true,
13
+ "**/.ruff_cache": true,
14
+ "**/.ipynb_checkpoints": true
15
+ }
16
+ }
backend/app_factory.py ADDED
@@ -0,0 +1,241 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime, timezone
2
+
3
+ from flask import Flask, jsonify, request
4
+ from flask_cors import CORS
5
+
6
+ from config import POSITIVE_EMOTIONS
7
+ from db import init_history_db
8
+ from repositories.history_repository import (
9
+ create_recommendation_cycle,
10
+ delete_user_history,
11
+ get_history_items,
12
+ get_last_emotion_event,
13
+ get_last_viewed_between,
14
+ get_recommendation_cycle,
15
+ get_transition_items,
16
+ insert_view_history,
17
+ save_emotion_event,
18
+ save_post_recommendation_state,
19
+ )
20
+ from services.chatbot_service import generate_chatbot_text
21
+ from services.emotion_service import analyze_text, create_emotion_classifier, mapeo_emocion_valencia
22
+ from services.recommender_service import cargar_dataset_movies, recommend_movies
23
+
24
+
25
+ def create_app() -> Flask:
26
+ # Crea la aplicacion Flask y habilita CORS para peticiones del frontend.
27
+ app = Flask(__name__)
28
+ CORS(app)
29
+
30
+ # Inicializacion unica de modelo, dataset y esquema de base de datos.
31
+ print("Cargando modelo...")
32
+ clf = create_emotion_classifier()
33
+
34
+ movies_df, global_rating_mean = cargar_dataset_movies()
35
+ init_history_db()
36
+ print(
37
+ f"Listo. Dataset de recomendaciones: {len(movies_df)} peliculas "
38
+ f"(rating global medio={global_rating_mean:.3f})"
39
+ )
40
+
41
+ @app.route("/analizar", methods=["POST"])
42
+ def analizar():
43
+ # Flujo principal: clasificar emocion y devolver recomendaciones.
44
+ texto = request.json.get("texto", "")
45
+ user_id = str(request.json.get("user_id", "")).strip()
46
+ previous_event = get_last_emotion_event(user_id)
47
+ analyzed_at = datetime.now(timezone.utc).isoformat()
48
+
49
+ resultado, dominant_es, dominant_valence = analyze_text(clf, texto)
50
+ recomendaciones = recommend_movies(
51
+ emotion_es=dominant_es,
52
+ user_id=user_id,
53
+ limit=12,
54
+ movies_df=movies_df,
55
+ global_rating_mean=global_rating_mean,
56
+ )
57
+ recommendation_mode = "diferente" if dominant_es in POSITIVE_EMOTIONS else "similar"
58
+
59
+ cycle_id = create_recommendation_cycle(
60
+ user_id=user_id,
61
+ pre_text=texto,
62
+ pre_emotion=dominant_es,
63
+ pre_valence=dominant_valence,
64
+ recommendation_mode=recommendation_mode,
65
+ created_at=analyzed_at,
66
+ )
67
+
68
+ save_emotion_event(user_id=user_id, text=texto, emotion=dominant_es, analyzed_at=analyzed_at)
69
+
70
+ # Si existe evento anterior, se busca pelicula puente entre ambos estados.
71
+ transition_movie = None
72
+ if previous_event:
73
+ transition_movie = get_last_viewed_between(
74
+ user_id=user_id,
75
+ start_iso=previous_event.get("analyzed_at", ""),
76
+ end_iso=analyzed_at,
77
+ )
78
+
79
+ chatbot_text, chatbot_source = generate_chatbot_text(
80
+ dominant_emotion=dominant_es,
81
+ recommendation_mode=recommendation_mode,
82
+ recommendations=recomendaciones,
83
+ previous_event=previous_event,
84
+ transition_movie=transition_movie,
85
+ )
86
+
87
+ return jsonify(
88
+ {
89
+ "emociones": resultado,
90
+ "emocion_dominante": dominant_es,
91
+ "valencia_dominante": dominant_valence,
92
+ "emocion_anterior": previous_event.get("emotion") if previous_event else None,
93
+ "modo_recomendacion": recommendation_mode,
94
+ "ciclo_recomendacion_id": cycle_id,
95
+ "chatbot_texto": chatbot_text,
96
+ "chatbot_fuente": chatbot_source,
97
+ "pelicula_transicion": transition_movie,
98
+ "recomendaciones": recomendaciones,
99
+ }
100
+ )
101
+
102
+ @app.route("/recomendacion/seguimiento", methods=["POST"])
103
+ def seguimiento_recomendacion():
104
+ # Captura estado post-visionado para medir cambio emocional.
105
+ payload = request.json or {}
106
+ user_id = str(payload.get("user_id", "")).strip()
107
+ post_text = str(payload.get("texto_post", "")).strip()
108
+ movie_id = str(payload.get("movie_id", "")).strip()
109
+ movie_title = str(payload.get("title", "")).strip()
110
+
111
+ try:
112
+ cycle_id = int(payload.get("ciclo_recomendacion_id", 0))
113
+ except (TypeError, ValueError):
114
+ cycle_id = 0
115
+
116
+ if not user_id or not cycle_id or not post_text:
117
+ return jsonify({"error": "user_id, ciclo_recomendacion_id y texto_post son obligatorios"}), 400
118
+
119
+ cycle = get_recommendation_cycle(cycle_id=cycle_id, user_id=user_id)
120
+ if not cycle:
121
+ return jsonify({"error": "ciclo de recomendacion no encontrado"}), 404
122
+
123
+ analyzed_at = datetime.now(timezone.utc).isoformat()
124
+ result_post, post_emotion, post_valence = analyze_text(clf, post_text)
125
+
126
+ save_emotion_event(user_id=user_id, text=post_text, emotion=post_emotion, analyzed_at=analyzed_at)
127
+ save_post_recommendation_state(
128
+ cycle_id=cycle_id,
129
+ user_id=user_id,
130
+ post_text=post_text,
131
+ post_emotion=post_emotion,
132
+ post_valence=post_valence,
133
+ post_analyzed_at=analyzed_at,
134
+ movie_id=movie_id,
135
+ movie_title=movie_title,
136
+ )
137
+
138
+ return jsonify(
139
+ {
140
+ "ciclo_recomendacion_id": cycle_id,
141
+ "movie_id": movie_id,
142
+ "title": movie_title,
143
+ "pre_emotion": cycle.get("pre_emotion"),
144
+ "pre_valence": cycle.get("pre_valence"),
145
+ "post_emotion": post_emotion,
146
+ "post_valence": post_valence,
147
+ "cambio_emocional": cycle.get("pre_emotion") != post_emotion,
148
+ "cambio_valencia": cycle.get("pre_valence") != post_valence,
149
+ "emociones_post": result_post,
150
+ }
151
+ )
152
+
153
+ @app.route("/historial/visto", methods=["POST"])
154
+ def guardar_visto():
155
+ # Guarda una pelicula vista junto a su valoracion del usuario.
156
+ payload = request.json or {}
157
+ user_id = str(payload.get("user_id", "")).strip()
158
+ movie_id = str(payload.get("movie_id", "")).strip()
159
+
160
+ if not user_id or not movie_id:
161
+ return jsonify({"error": "user_id y movie_id son obligatorios"}), 400
162
+
163
+ viewed_at = datetime.now(timezone.utc).isoformat()
164
+ title = str(payload.get("title", "")).strip()
165
+ emotion = str(payload.get("emotion", "")).strip()
166
+ session_text = str(payload.get("session_text", "")).strip()
167
+ user_rating_raw = payload.get("user_rating")
168
+ user_rating = None
169
+ if user_rating_raw is not None and str(user_rating_raw).strip() != "":
170
+ try:
171
+ user_rating = float(user_rating_raw)
172
+ except (TypeError, ValueError):
173
+ return jsonify({"error": "user_rating debe ser numerica entre 1 y 5"}), 400
174
+ if user_rating < 1 or user_rating > 5:
175
+ return jsonify({"error": "user_rating debe estar entre 1 y 5"}), 400
176
+
177
+ inserted_id = insert_view_history(
178
+ user_id=user_id,
179
+ movie_id=movie_id,
180
+ title=title,
181
+ emotion=emotion,
182
+ user_rating=user_rating,
183
+ session_text=session_text,
184
+ viewed_at=viewed_at,
185
+ )
186
+
187
+ return jsonify(
188
+ {
189
+ "id": inserted_id,
190
+ "user_id": user_id,
191
+ "movie_id": movie_id,
192
+ "title": title,
193
+ "emotion": emotion,
194
+ "user_rating": user_rating,
195
+ "session_text": session_text,
196
+ "viewed_at": viewed_at,
197
+ }
198
+ ), 201
199
+
200
+ @app.route("/historial", methods=["GET", "DELETE"])
201
+ def obtener_historial():
202
+ # GET lista historial; DELETE borra historial del usuario.
203
+ if request.method == "DELETE":
204
+ payload = request.json or {}
205
+ user_id = str(payload.get("user_id", "") or request.args.get("user_id", "")).strip()
206
+ if not user_id:
207
+ return jsonify({"error": "user_id es obligatorio"}), 400
208
+
209
+ deleted = delete_user_history(user_id)
210
+ return jsonify({"ok": True, "user_id": user_id, "deleted": deleted})
211
+
212
+ user_id = str(request.args.get("user_id", "")).strip()
213
+ if not user_id:
214
+ return jsonify({"error": "user_id es obligatorio"}), 400
215
+
216
+ try:
217
+ limit = int(request.args.get("limit", 30))
218
+ except ValueError:
219
+ limit = 30
220
+
221
+ limit = max(1, min(limit, 200))
222
+ historial = get_history_items(user_id=user_id, limit=limit)
223
+ return jsonify({"items": historial, "count": len(historial)})
224
+
225
+ @app.route("/historial/transiciones", methods=["GET"])
226
+ def obtener_transiciones():
227
+ # Devuelve peliculas asociadas a cambios de emocion entre eventos.
228
+ user_id = str(request.args.get("user_id", "")).strip()
229
+ if not user_id:
230
+ return jsonify({"error": "user_id es obligatorio"}), 400
231
+
232
+ try:
233
+ limit = int(request.args.get("limit", 20))
234
+ except ValueError:
235
+ limit = 20
236
+
237
+ limit = max(1, min(limit, 100))
238
+ items = get_transition_items(user_id=user_id, limit=limit)
239
+ return jsonify({"items": items, "count": len(items)})
240
+
241
+ return app
backend/config.py ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pathlib import Path
2
+
3
+ # El directorio raiz del proyecto es /ValorSentimental.
4
+ ROOT_DIR = Path(__file__).resolve().parent.parent
5
+ # La base de datos de historial se guarda en /ValorSentimental/backend/history.db.
6
+ HISTORY_DB_PATH = ROOT_DIR / "backend" / "history.db"
7
+
8
+ # Se mapean las 6 emociones de Eckman y neutral, para traducir la salida del modelo.
9
+ EMOTION_MAP = {
10
+ "joy": "alegria",
11
+ "sadness": "tristeza",
12
+ "anger": "ira",
13
+ "fear": "miedo",
14
+ "disgust": "asco",
15
+ "surprise": "sorpresa",
16
+ "others": "neutral",
17
+ }
18
+
19
+ # Se indican emociones positivas y negativas manualmente.
20
+ POSITIVE_EMOTIONS = {"alegria", "sorpresa", "neutral"}
21
+ NEGATIVE_EMOTIONS = {"tristeza", "ira", "miedo", "asco"}
22
+
23
+ # Nombre del modelo de Ollama que se utilizara para generar texto del chatbot.
24
+ TEXT_MODEL_NAME = "llama3.2"
25
+ OLLAMA_URL = "http://localhost:11434/api/generate"
26
+ # Valoracion minima para considerar que al usuario le gusto la pelicula.
27
+ LIKE_THRESHOLD = 4.0
28
+ # Prior de suavizado para score global (evita sesgo por pocas valoraciones).
29
+ GLOBAL_PRIOR_COUNT = 50.0
backend/db.py ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sqlite3
2
+
3
+ from config import HISTORY_DB_PATH
4
+
5
+
6
+ def get_db_connection() -> sqlite3.Connection:
7
+ # Cada conexion usa sqlite3.Row para acceder por nombre de columna.
8
+ conn = sqlite3.connect(HISTORY_DB_PATH)
9
+ conn.row_factory = sqlite3.Row
10
+ return conn
11
+
12
+
13
+ def init_history_db() -> None:
14
+ with get_db_connection() as conn:
15
+ # Historial de visionado con rating opcional por usuario.
16
+ conn.execute(
17
+ """
18
+ CREATE TABLE IF NOT EXISTS view_history (
19
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
20
+ user_id TEXT NOT NULL,
21
+ movie_id TEXT NOT NULL,
22
+ title TEXT,
23
+ emotion TEXT,
24
+ user_rating REAL,
25
+ session_text TEXT,
26
+ viewed_at TEXT NOT NULL
27
+ )
28
+ """
29
+ )
30
+ # Compatibilidad con BDs existentes creadas sin columna de valoracion.
31
+ columns = conn.execute("PRAGMA table_info(view_history)").fetchall()
32
+ column_names = {row[1] for row in columns}
33
+ if "user_rating" not in column_names:
34
+ conn.execute("ALTER TABLE view_history ADD COLUMN user_rating REAL")
35
+
36
+ # Indices para lecturas frecuentes por usuario + fecha.
37
+ conn.execute(
38
+ """
39
+ CREATE INDEX IF NOT EXISTS idx_view_history_user_viewed_at
40
+ ON view_history (user_id, viewed_at DESC)
41
+ """
42
+ )
43
+ conn.execute(
44
+ """
45
+ CREATE TABLE IF NOT EXISTS emotion_events (
46
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
47
+ user_id TEXT NOT NULL,
48
+ text TEXT,
49
+ emotion TEXT NOT NULL,
50
+ analyzed_at TEXT NOT NULL
51
+ )
52
+ """
53
+ )
54
+ conn.execute(
55
+ """
56
+ CREATE INDEX IF NOT EXISTS idx_emotion_events_user_analyzed_at
57
+ ON emotion_events (user_id, analyzed_at DESC)
58
+ """
59
+ )
60
+ conn.execute(
61
+ """
62
+ CREATE TABLE IF NOT EXISTS recommendation_cycles (
63
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
64
+ user_id TEXT NOT NULL,
65
+ pre_text TEXT,
66
+ pre_emotion TEXT NOT NULL,
67
+ pre_valence TEXT NOT NULL,
68
+ recommendation_mode TEXT NOT NULL,
69
+ created_at TEXT NOT NULL,
70
+ selected_movie_id TEXT,
71
+ selected_movie_title TEXT,
72
+ post_text TEXT,
73
+ post_emotion TEXT,
74
+ post_valence TEXT,
75
+ post_analyzed_at TEXT
76
+ )
77
+ """
78
+ )
79
+ conn.execute(
80
+ """
81
+ CREATE INDEX IF NOT EXISTS idx_recommendation_cycles_user_created_at
82
+ ON recommendation_cycles (user_id, created_at DESC)
83
+ """
84
+ )
85
+ conn.commit()
backend/history.db CHANGED
Binary files a/backend/history.db and b/backend/history.db differ
 
backend/repositories/__init__.py ADDED
File without changes
backend/repositories/history_repository.py ADDED
@@ -0,0 +1,270 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from db import get_db_connection
2
+
3
+
4
+ def delete_user_history(user_id: str) -> int:
5
+ with get_db_connection() as conn:
6
+ # Se ejecuta DELETE para borrar historial de visionado del usuario.
7
+ cur = conn.execute(
8
+ """
9
+ DELETE FROM view_history
10
+ WHERE user_id = ?
11
+ """,
12
+ (user_id,),
13
+ )
14
+ conn.commit()
15
+ return int(cur.rowcount or 0)
16
+
17
+
18
+ def get_user_history_rows(user_id: str, limit: int = 200) -> list[dict]:
19
+ with get_db_connection() as conn:
20
+ # Consulta base para perfilar recomendaciones con movie_id y user_rating.
21
+ rows = conn.execute(
22
+ """
23
+ SELECT movie_id, user_rating
24
+ FROM view_history
25
+ WHERE user_id = ?
26
+ ORDER BY viewed_at DESC
27
+ LIMIT ?
28
+ """,
29
+ (user_id, limit),
30
+ ).fetchall()
31
+ return [dict(row) for row in rows]
32
+
33
+
34
+ def save_emotion_event(user_id: str, text: str, emotion: str, analyzed_at: str) -> int | None:
35
+ if not user_id:
36
+ return None
37
+
38
+ with get_db_connection() as conn:
39
+ # Registro temporal de cada analisis emocional del usuario.
40
+ cur = conn.execute(
41
+ """
42
+ INSERT INTO emotion_events (user_id, text, emotion, analyzed_at)
43
+ VALUES (?, ?, ?, ?)
44
+ """,
45
+ (user_id, text, emotion, analyzed_at),
46
+ )
47
+ conn.commit()
48
+ return cur.lastrowid
49
+
50
+
51
+ def create_recommendation_cycle(
52
+ user_id: str,
53
+ pre_text: str,
54
+ pre_emotion: str,
55
+ pre_valence: str,
56
+ recommendation_mode: str,
57
+ created_at: str,
58
+ ) -> int | None:
59
+ if not user_id:
60
+ return None
61
+
62
+ with get_db_connection() as conn:
63
+ # Se guarda el estado previo a la recomendacion para seguimiento posterior.
64
+ cur = conn.execute(
65
+ """
66
+ INSERT INTO recommendation_cycles (
67
+ user_id,
68
+ pre_text,
69
+ pre_emotion,
70
+ pre_valence,
71
+ recommendation_mode,
72
+ created_at
73
+ )
74
+ VALUES (?, ?, ?, ?, ?, ?)
75
+ """,
76
+ (user_id, pre_text, pre_emotion, pre_valence, recommendation_mode, created_at),
77
+ )
78
+ conn.commit()
79
+ return cur.lastrowid
80
+
81
+
82
+ def get_recommendation_cycle(cycle_id: int, user_id: str) -> dict | None:
83
+ with get_db_connection() as conn:
84
+ row = conn.execute(
85
+ """
86
+ SELECT id, user_id, pre_text, pre_emotion, pre_valence, recommendation_mode,
87
+ created_at, selected_movie_id, selected_movie_title,
88
+ post_text, post_emotion, post_valence, post_analyzed_at
89
+ FROM recommendation_cycles
90
+ WHERE id = ? AND user_id = ?
91
+ LIMIT 1
92
+ """,
93
+ (cycle_id, user_id),
94
+ ).fetchone()
95
+ return dict(row) if row else None
96
+
97
+
98
+ def save_post_recommendation_state(
99
+ cycle_id: int,
100
+ user_id: str,
101
+ post_text: str,
102
+ post_emotion: str,
103
+ post_valence: str,
104
+ post_analyzed_at: str,
105
+ movie_id: str,
106
+ movie_title: str,
107
+ ) -> None:
108
+ with get_db_connection() as conn:
109
+ # Actualiza el ciclo con pelicula elegida y estado emocional posterior.
110
+ conn.execute(
111
+ """
112
+ UPDATE recommendation_cycles
113
+ SET selected_movie_id = ?,
114
+ selected_movie_title = ?,
115
+ post_text = ?,
116
+ post_emotion = ?,
117
+ post_valence = ?,
118
+ post_analyzed_at = ?
119
+ WHERE id = ? AND user_id = ?
120
+ """,
121
+ (movie_id, movie_title, post_text, post_emotion, post_valence, post_analyzed_at, cycle_id, user_id),
122
+ )
123
+ conn.commit()
124
+
125
+
126
+ def get_last_emotion_event(user_id: str) -> dict | None:
127
+ if not user_id:
128
+ return None
129
+
130
+ with get_db_connection() as conn:
131
+ row = conn.execute(
132
+ """
133
+ SELECT id, user_id, text, emotion, analyzed_at
134
+ FROM emotion_events
135
+ WHERE user_id = ?
136
+ ORDER BY analyzed_at DESC
137
+ LIMIT 1
138
+ """,
139
+ (user_id,),
140
+ ).fetchone()
141
+
142
+ return dict(row) if row else None
143
+
144
+
145
+ def get_last_viewed_between(user_id: str, start_iso: str, end_iso: str) -> dict | None:
146
+ with get_db_connection() as conn:
147
+ row = conn.execute(
148
+ """
149
+ SELECT movie_id, title, viewed_at
150
+ FROM view_history
151
+ WHERE user_id = ?
152
+ AND viewed_at > ?
153
+ AND viewed_at <= ?
154
+ ORDER BY viewed_at DESC
155
+ LIMIT 1
156
+ """,
157
+ (user_id, start_iso, end_iso),
158
+ ).fetchone()
159
+
160
+ return dict(row) if row else None
161
+
162
+
163
+ def insert_view_history(
164
+ user_id: str,
165
+ movie_id: str,
166
+ title: str,
167
+ emotion: str,
168
+ user_rating: float | None,
169
+ session_text: str,
170
+ viewed_at: str,
171
+ ) -> int:
172
+ with get_db_connection() as conn:
173
+ cur = conn.execute(
174
+ """
175
+ INSERT INTO view_history (user_id, movie_id, title, emotion, user_rating, session_text, viewed_at)
176
+ VALUES (?, ?, ?, ?, ?, ?, ?)
177
+ """,
178
+ (user_id, movie_id, title, emotion, user_rating, session_text, viewed_at),
179
+ )
180
+ conn.commit()
181
+ return int(cur.lastrowid)
182
+
183
+
184
+ def get_history_items(user_id: str, limit: int) -> list[dict]:
185
+ with get_db_connection() as conn:
186
+ rows = conn.execute(
187
+ """
188
+ SELECT id, user_id, movie_id, title, emotion, user_rating, session_text, viewed_at
189
+ FROM view_history
190
+ WHERE user_id = ?
191
+ ORDER BY viewed_at DESC
192
+ LIMIT ?
193
+ """,
194
+ (user_id, limit),
195
+ ).fetchall()
196
+ return [dict(row) for row in rows]
197
+
198
+
199
+ def get_transition_items(user_id: str, limit: int) -> list[dict]:
200
+ with get_db_connection() as conn:
201
+ # Se toman eventos y vistas en orden cronologico para detectar transiciones.
202
+ event_rows = conn.execute(
203
+ """
204
+ SELECT id, user_id, text, emotion, analyzed_at
205
+ FROM emotion_events
206
+ WHERE user_id = ?
207
+ ORDER BY analyzed_at ASC
208
+ LIMIT 500
209
+ """,
210
+ (user_id,),
211
+ ).fetchall()
212
+
213
+ view_rows = conn.execute(
214
+ """
215
+ SELECT movie_id, title, viewed_at
216
+ FROM view_history
217
+ WHERE user_id = ?
218
+ ORDER BY viewed_at ASC
219
+ LIMIT 1000
220
+ """,
221
+ (user_id,),
222
+ ).fetchall()
223
+
224
+ events = [dict(row) for row in event_rows]
225
+ views = [dict(row) for row in view_rows]
226
+
227
+ # (movie_id, title, emocion_origen, emocion_destino) -> conteo.
228
+ transition_counter: dict[tuple[str, str, str, str], int] = {}
229
+
230
+ for idx in range(1, len(events)):
231
+ prev_event = events[idx - 1]
232
+ curr_event = events[idx]
233
+ if prev_event.get("emotion") == curr_event.get("emotion"):
234
+ continue
235
+
236
+ start = prev_event.get("analyzed_at", "")
237
+ end = curr_event.get("analyzed_at", "")
238
+
239
+ matched_movie = None
240
+ for view in reversed(views):
241
+ viewed_at = view.get("viewed_at", "")
242
+ if start < viewed_at <= end:
243
+ matched_movie = view
244
+ break
245
+
246
+ if not matched_movie:
247
+ continue
248
+
249
+ key = (
250
+ str(matched_movie.get("movie_id", "")),
251
+ matched_movie.get("title") or "",
252
+ prev_event.get("emotion", ""),
253
+ curr_event.get("emotion", ""),
254
+ )
255
+ transition_counter[key] = transition_counter.get(key, 0) + 1
256
+
257
+ items = []
258
+ for (movie_id, title, from_emotion, to_emotion), count in transition_counter.items():
259
+ items.append(
260
+ {
261
+ "movie_id": movie_id,
262
+ "title": title,
263
+ "from_emotion": from_emotion,
264
+ "to_emotion": to_emotion,
265
+ "count": count,
266
+ }
267
+ )
268
+
269
+ items.sort(key=lambda x: x["count"], reverse=True)
270
+ return items[:limit]
backend/server.py CHANGED
@@ -1,766 +1,6 @@
1
- from pathlib import Path
2
- import csv
3
- import random
4
- import sqlite3
5
- from collections import Counter
6
- from datetime import datetime, timezone
7
-
8
- from flask import Flask, jsonify, request
9
- from flask_cors import CORS
10
- import requests as http_requests
11
- from transformers import pipeline
12
-
13
- app = Flask(__name__)
14
- CORS(app)
15
-
16
- ROOT_DIR = Path(__file__).resolve().parent.parent
17
- HISTORY_DB_PATH = ROOT_DIR / "backend" / "history.db"
18
-
19
- EMOTION_MAP = {
20
- "joy": "alegria",
21
- "sadness": "tristeza",
22
- "anger": "ira",
23
- "fear": "miedo",
24
- "disgust": "asco",
25
- "surprise": "sorpresa",
26
- "others": "neutral",
27
- }
28
-
29
- POSITIVE_EMOTIONS = {"alegria", "sorpresa", "neutral"}
30
- NEGATIVE_EMOTIONS = {"tristeza", "ira", "miedo", "asco"}
31
- TEXT_MODEL_NAME = "llama3.2"
32
- OLLAMA_URL = "http://localhost:11434/api/generate"
33
- LIKE_THRESHOLD = 4.0
34
-
35
-
36
- def emotion_to_valence(emotion: str) -> str:
37
- if emotion in POSITIVE_EMOTIONS:
38
- return "positivo"
39
- if emotion in NEGATIVE_EMOTIONS:
40
- return "negativo"
41
- return "neutro"
42
-
43
-
44
- def load_movies_dataset() -> list[dict]:
45
- candidates = [
46
- ROOT_DIR / "data" / "procesado" / "peliculas_100_emociones.csv",
47
- ROOT_DIR / "data" / "procesado" / "peliculas_conocidas.csv",
48
- ]
49
-
50
- for path in candidates:
51
- if path.exists():
52
- with open(path, "r", encoding="utf-8", newline="") as f:
53
- rows = list(csv.DictReader(f))
54
- for row in rows:
55
- if "estados_emocionales" not in row and "estado_emocional" in row:
56
- row["estados_emocionales"] = row.get("estado_emocional", "")
57
- return rows
58
-
59
- return []
60
-
61
-
62
- def _movie_genres(row: dict) -> set[str]:
63
- return {g.strip() for g in row.get("genres", "").split("|") if g.strip()}
64
-
65
-
66
- def _get_user_history_rows(user_id: str, limit: int = 200) -> list[dict]:
67
- with get_db_connection() as conn:
68
- rows = conn.execute(
69
- """
70
- SELECT movie_id, user_rating
71
- FROM view_history
72
- WHERE user_id = ?
73
- ORDER BY viewed_at DESC
74
- LIMIT ?
75
- """,
76
- (user_id, limit),
77
- ).fetchall()
78
- return [dict(row) for row in rows]
79
-
80
-
81
- def _build_history_profile(history_rows: list[dict]) -> tuple[set[str], Counter, Counter]:
82
- viewed_ids = {str(row.get("movie_id", "")).strip() for row in history_rows if row.get("movie_id")}
83
- viewed_genres_counter: Counter = Counter()
84
- liked_ids = {
85
- str(row.get("movie_id", "")).strip()
86
- for row in history_rows
87
- if row.get("movie_id") and (row.get("user_rating") is not None) and float(row.get("user_rating") or 0) >= LIKE_THRESHOLD
88
- }
89
- liked_genres_counter: Counter = Counter()
90
-
91
- if not viewed_ids:
92
- return viewed_ids, viewed_genres_counter, liked_genres_counter
93
-
94
- for movie in MOVIES_DF:
95
- movie_id = str(movie.get("movieId", "")).strip()
96
- if movie_id in viewed_ids:
97
- genres = _movie_genres(movie)
98
- viewed_genres_counter.update(genres)
99
- if movie_id in liked_ids:
100
- liked_genres_counter.update(genres)
101
-
102
- return viewed_ids, viewed_genres_counter, liked_genres_counter
103
-
104
-
105
- def _quality_key(row: dict) -> tuple[float, float]:
106
- return (
107
- float(row.get("rating_count", 0) or 0),
108
- float(row.get("rating_mean", 0) or 0),
109
- )
110
-
111
-
112
- def _similarity_to_history(row: dict, history_genres: set[str]) -> float:
113
- if not history_genres:
114
- return 0.0
115
-
116
- genres = _movie_genres(row)
117
- if not genres:
118
- return 0.0
119
-
120
- return len(genres & history_genres) / len(genres)
121
-
122
-
123
- def recommend_movies(emotion_es: str, user_id: str = "", limit: int = 12) -> list[dict]:
124
- if not MOVIES_DF:
125
- return []
126
-
127
- filtered = []
128
- for row in MOVIES_DF:
129
- parts = row.get("estados_emocionales", "").split("|")
130
- if emotion_es in parts:
131
- filtered.append(row)
132
-
133
- if not filtered:
134
- return []
135
-
136
- history_rows = _get_user_history_rows(user_id) if user_id else []
137
- viewed_ids, viewed_genres_counter, liked_genres_counter = _build_history_profile(history_rows)
138
- viewed_genres = set(viewed_genres_counter.keys())
139
- liked_genres = set(liked_genres_counter.keys())
140
- has_history = len(viewed_ids) > 0
141
-
142
- if not has_history:
143
- filtered.sort(key=_quality_key, reverse=True)
144
- pool_size = min(len(filtered), max(limit * 4, limit))
145
- candidate_pool = filtered[:pool_size]
146
-
147
- if len(candidate_pool) <= limit:
148
- random.shuffle(candidate_pool)
149
- return candidate_pool
150
-
151
- return random.sample(candidate_pool, k=limit)
152
-
153
- unseen_filtered = [
154
- row for row in filtered if str(row.get("movieId", "")).strip() not in viewed_ids
155
- ]
156
- candidates = unseen_filtered if unseen_filtered else filtered
157
-
158
- is_positive = emotion_es in POSITIVE_EMOTIONS
159
- # Negativo: parecido a peliculas valoradas positivamente; fallback a todo el historial si no hay valoraciones altas.
160
- reference_genres = viewed_genres if is_positive else (liked_genres if liked_genres else viewed_genres)
161
- ranked = sorted(
162
- candidates,
163
- key=lambda r: (_similarity_to_history(r, reference_genres), *_quality_key(r)),
164
- reverse=not is_positive,
165
- )
166
-
167
- return ranked[:limit]
168
-
169
-
170
- def save_emotion_event(user_id: str, text: str, emotion: str, analyzed_at: str) -> int | None:
171
- if not user_id:
172
- return None
173
-
174
- with get_db_connection() as conn:
175
- cur = conn.execute(
176
- """
177
- INSERT INTO emotion_events (user_id, text, emotion, analyzed_at)
178
- VALUES (?, ?, ?, ?)
179
- """,
180
- (user_id, text, emotion, analyzed_at),
181
- )
182
- conn.commit()
183
- return cur.lastrowid
184
-
185
-
186
- def create_recommendation_cycle(
187
- user_id: str,
188
- pre_text: str,
189
- pre_emotion: str,
190
- recommendation_mode: str,
191
- created_at: str,
192
- ) -> int | None:
193
- if not user_id:
194
- return None
195
-
196
- pre_valence = emotion_to_valence(pre_emotion)
197
- with get_db_connection() as conn:
198
- cur = conn.execute(
199
- """
200
- INSERT INTO recommendation_cycles (
201
- user_id,
202
- pre_text,
203
- pre_emotion,
204
- pre_valence,
205
- recommendation_mode,
206
- created_at
207
- )
208
- VALUES (?, ?, ?, ?, ?, ?)
209
- """,
210
- (user_id, pre_text, pre_emotion, pre_valence, recommendation_mode, created_at),
211
- )
212
- conn.commit()
213
- return cur.lastrowid
214
-
215
-
216
- def get_recommendation_cycle(cycle_id: int, user_id: str) -> dict | None:
217
- with get_db_connection() as conn:
218
- row = conn.execute(
219
- """
220
- SELECT id, user_id, pre_text, pre_emotion, pre_valence, recommendation_mode,
221
- created_at, selected_movie_id, selected_movie_title,
222
- post_text, post_emotion, post_valence, post_analyzed_at
223
- FROM recommendation_cycles
224
- WHERE id = ? AND user_id = ?
225
- LIMIT 1
226
- """,
227
- (cycle_id, user_id),
228
- ).fetchone()
229
- return dict(row) if row else None
230
-
231
-
232
- def save_post_recommendation_state(
233
- cycle_id: int,
234
- user_id: str,
235
- post_text: str,
236
- post_emotion: str,
237
- post_analyzed_at: str,
238
- movie_id: str,
239
- movie_title: str,
240
- ) -> None:
241
- post_valence = emotion_to_valence(post_emotion)
242
- with get_db_connection() as conn:
243
- conn.execute(
244
- """
245
- UPDATE recommendation_cycles
246
- SET selected_movie_id = ?,
247
- selected_movie_title = ?,
248
- post_text = ?,
249
- post_emotion = ?,
250
- post_valence = ?,
251
- post_analyzed_at = ?
252
- WHERE id = ? AND user_id = ?
253
- """,
254
- (movie_id, movie_title, post_text, post_emotion, post_valence, post_analyzed_at, cycle_id, user_id),
255
- )
256
- conn.commit()
257
-
258
-
259
- def get_last_emotion_event(user_id: str) -> dict | None:
260
- if not user_id:
261
- return None
262
-
263
- with get_db_connection() as conn:
264
- row = conn.execute(
265
- """
266
- SELECT id, user_id, text, emotion, analyzed_at
267
- FROM emotion_events
268
- WHERE user_id = ?
269
- ORDER BY analyzed_at DESC
270
- LIMIT 1
271
- """,
272
- (user_id,),
273
- ).fetchone()
274
-
275
- return dict(row) if row else None
276
-
277
-
278
- def get_last_viewed_between(user_id: str, start_iso: str, end_iso: str) -> dict | None:
279
- with get_db_connection() as conn:
280
- row = conn.execute(
281
- """
282
- SELECT movie_id, title, viewed_at
283
- FROM view_history
284
- WHERE user_id = ?
285
- AND viewed_at > ?
286
- AND viewed_at <= ?
287
- ORDER BY viewed_at DESC
288
- LIMIT 1
289
- """,
290
- (user_id, start_iso, end_iso),
291
- ).fetchone()
292
-
293
- return dict(row) if row else None
294
-
295
-
296
- def build_chatbot_response(
297
- dominant_emotion: str,
298
- recommendation_mode: str,
299
- recommendations: list[dict],
300
- previous_event: dict | None,
301
- transition_movie: dict | None,
302
- ) -> str:
303
- if recommendation_mode == "diferente":
304
- mode_text = "Como estas en un estado positivo, te propongo explorar peliculas distintas a tu historial."
305
- else:
306
- mode_text = "Como estas en un estado negativo, te propongo peliculas similares a tu historial para mantener una zona conocida."
307
-
308
- if recommendations:
309
- top_title = recommendations[0].get("title", "una pelicula")
310
- reco_text = f"Primera sugerencia: {top_title}."
311
- else:
312
- reco_text = "No encontre recomendaciones para ese estado emocional en este momento."
313
-
314
- if not previous_event:
315
- transition_text = "Este es tu primer punto de referencia emocional para analizar transiciones futuras."
316
- else:
317
- prev_emotion = previous_event.get("emotion", "neutral")
318
- if prev_emotion == dominant_emotion:
319
- transition_text = f"Tu estado se mantiene en {dominant_emotion}."
320
- else:
321
- transition_text = f"Detecto una transicion de {prev_emotion} a {dominant_emotion}."
322
-
323
- if transition_movie:
324
- movie_title = transition_movie.get("title") or transition_movie.get("movie_id", "pelicula marcada")
325
- transition_text += f" Ultima pelicula vista entre ambos estados: {movie_title}."
326
-
327
- return f"Estado actual: {dominant_emotion}. {mode_text} {reco_text} {transition_text}"
328
-
329
-
330
- def _build_text_generation_prompt(
331
- dominant_emotion: str,
332
- recommendation_mode: str,
333
- recommendations: list[dict],
334
- previous_event: dict | None,
335
- transition_movie: dict | None,
336
- ) -> str:
337
- top_titles = [str(row.get("title", "")).strip() for row in recommendations[:3] if row.get("title")]
338
- top_titles_text = ", ".join(top_titles) if top_titles else "sin recomendaciones"
339
- prev_emotion = previous_event.get("emotion") if previous_event else "ninguna"
340
- transition_movie_title = ""
341
- if transition_movie:
342
- transition_movie_title = str(transition_movie.get("title") or transition_movie.get("movie_id") or "").strip()
343
-
344
- return (
345
- "Eres un asistente de recomendaciones de peliculas. "
346
- "Redacta una respuesta breve en espanol (maximo 3 frases), clara y empatica. "
347
- "Incluye estado emocional actual, explicacion de por que el modo de recomendacion es ese, "
348
- "y menciona una pelicula sugerida si existe. "
349
- f"Estado actual: {dominant_emotion}. "
350
- f"Modo: {recommendation_mode}. "
351
- f"Emocion anterior: {prev_emotion}. "
352
- f"Peliculas sugeridas: {top_titles_text}. "
353
- f"Pelicula asociada a transicion: {transition_movie_title or 'ninguna'}."
354
- )
355
-
356
-
357
- def generate_chatbot_text(
358
- dominant_emotion: str,
359
- recommendation_mode: str,
360
- recommendations: list[dict],
361
- previous_event: dict | None,
362
- transition_movie: dict | None,
363
- ) -> tuple[str, str]:
364
- template_text = build_chatbot_response(
365
- dominant_emotion=dominant_emotion,
366
- recommendation_mode=recommendation_mode,
367
- recommendations=recommendations,
368
- previous_event=previous_event,
369
- transition_movie=transition_movie,
370
- )
371
-
372
- try:
373
- prompt = _build_text_generation_prompt(
374
- dominant_emotion=dominant_emotion,
375
- recommendation_mode=recommendation_mode,
376
- recommendations=recommendations,
377
- previous_event=previous_event,
378
- transition_movie=transition_movie,
379
- )
380
- payload = {
381
- "model": TEXT_MODEL_NAME,
382
- "prompt": prompt,
383
- "stream": False,
384
- "options": {
385
- "temperature": 0.7,
386
- "top_p": 0.9,
387
- "num_predict": 120,
388
- },
389
- }
390
- res = http_requests.post(OLLAMA_URL, json=payload, timeout=20)
391
- if res.ok:
392
- data = res.json()
393
- generated = str(data.get("response", "")).strip()
394
- if generated:
395
- return generated, "ollama"
396
-
397
- print(f"Aviso: Ollama devolvio HTTP {res.status_code}. Se usa plantilla.")
398
- except Exception as exc:
399
- print(f"Aviso: fallo generando texto con Ollama ({exc}). Se usa plantilla.")
400
-
401
- return template_text, "template-fallback"
402
-
403
-
404
- def get_db_connection() -> sqlite3.Connection:
405
- conn = sqlite3.connect(HISTORY_DB_PATH)
406
- conn.row_factory = sqlite3.Row
407
- return conn
408
-
409
-
410
- def init_history_db() -> None:
411
- with get_db_connection() as conn:
412
- conn.execute(
413
- """
414
- CREATE TABLE IF NOT EXISTS view_history (
415
- id INTEGER PRIMARY KEY AUTOINCREMENT,
416
- user_id TEXT NOT NULL,
417
- movie_id TEXT NOT NULL,
418
- title TEXT,
419
- emotion TEXT,
420
- user_rating REAL,
421
- session_text TEXT,
422
- viewed_at TEXT NOT NULL
423
- )
424
- """
425
- )
426
- # Compatibilidad con BDs existentes creadas sin columna de valoracion.
427
- columns = conn.execute("PRAGMA table_info(view_history)").fetchall()
428
- column_names = {row[1] for row in columns}
429
- if "user_rating" not in column_names:
430
- conn.execute("ALTER TABLE view_history ADD COLUMN user_rating REAL")
431
- conn.execute(
432
- """
433
- CREATE INDEX IF NOT EXISTS idx_view_history_user_viewed_at
434
- ON view_history (user_id, viewed_at DESC)
435
- """
436
- )
437
- conn.execute(
438
- """
439
- CREATE TABLE IF NOT EXISTS emotion_events (
440
- id INTEGER PRIMARY KEY AUTOINCREMENT,
441
- user_id TEXT NOT NULL,
442
- text TEXT,
443
- emotion TEXT NOT NULL,
444
- analyzed_at TEXT NOT NULL
445
- )
446
- """
447
- )
448
- conn.execute(
449
- """
450
- CREATE INDEX IF NOT EXISTS idx_emotion_events_user_analyzed_at
451
- ON emotion_events (user_id, analyzed_at DESC)
452
- """
453
- )
454
- conn.execute(
455
- """
456
- CREATE TABLE IF NOT EXISTS recommendation_cycles (
457
- id INTEGER PRIMARY KEY AUTOINCREMENT,
458
- user_id TEXT NOT NULL,
459
- pre_text TEXT,
460
- pre_emotion TEXT NOT NULL,
461
- pre_valence TEXT NOT NULL,
462
- recommendation_mode TEXT NOT NULL,
463
- created_at TEXT NOT NULL,
464
- selected_movie_id TEXT,
465
- selected_movie_title TEXT,
466
- post_text TEXT,
467
- post_emotion TEXT,
468
- post_valence TEXT,
469
- post_analyzed_at TEXT
470
- )
471
- """
472
- )
473
- conn.execute(
474
- """
475
- CREATE INDEX IF NOT EXISTS idx_recommendation_cycles_user_created_at
476
- ON recommendation_cycles (user_id, created_at DESC)
477
- """
478
- )
479
- conn.commit()
480
-
481
-
482
- print("Cargando modelo...")
483
- clf = pipeline(
484
- "text-classification",
485
- model="pysentimiento/robertuito-emotion-analysis",
486
- top_k=None,
487
- device=-1,
488
- )
489
-
490
- MOVIES_DF = load_movies_dataset()
491
- init_history_db()
492
- print(f"Listo. Dataset de recomendaciones: {len(MOVIES_DF)} peliculas")
493
-
494
-
495
- @app.route("/analizar", methods=["POST"])
496
- def analizar():
497
- texto = request.json.get("texto", "")
498
- user_id = str(request.json.get("user_id", "")).strip()
499
- previous_event = get_last_emotion_event(user_id)
500
- analyzed_at = datetime.now(timezone.utc).isoformat()
501
- resultado = sorted(clf(texto)[0], key=lambda x: x["score"], reverse=True)
502
-
503
- dominant_model = resultado[0]["label"] if resultado else "others"
504
- dominant_es = EMOTION_MAP.get(dominant_model, "neutral")
505
- dominant_valence = emotion_to_valence(dominant_es)
506
- recomendaciones = recommend_movies(dominant_es, user_id=user_id, limit=12)
507
- recommendation_mode = "diferente" if dominant_es in POSITIVE_EMOTIONS else "similar"
508
-
509
- cycle_id = create_recommendation_cycle(
510
- user_id=user_id,
511
- pre_text=texto,
512
- pre_emotion=dominant_es,
513
- recommendation_mode=recommendation_mode,
514
- created_at=analyzed_at,
515
- )
516
-
517
- save_emotion_event(user_id=user_id, text=texto, emotion=dominant_es, analyzed_at=analyzed_at)
518
-
519
- transition_movie = None
520
- if previous_event:
521
- transition_movie = get_last_viewed_between(
522
- user_id=user_id,
523
- start_iso=previous_event.get("analyzed_at", ""),
524
- end_iso=analyzed_at,
525
- )
526
-
527
- chatbot_text, chatbot_source = generate_chatbot_text(
528
- dominant_emotion=dominant_es,
529
- recommendation_mode=recommendation_mode,
530
- recommendations=recomendaciones,
531
- previous_event=previous_event,
532
- transition_movie=transition_movie,
533
- )
534
-
535
- return jsonify(
536
- {
537
- "emociones": resultado,
538
- "emocion_dominante": dominant_es,
539
- "valencia_dominante": dominant_valence,
540
- "emocion_anterior": previous_event.get("emotion") if previous_event else None,
541
- "modo_recomendacion": recommendation_mode,
542
- "ciclo_recomendacion_id": cycle_id,
543
- "chatbot_texto": chatbot_text,
544
- "chatbot_fuente": chatbot_source,
545
- "pelicula_transicion": transition_movie,
546
- "recomendaciones": recomendaciones,
547
- }
548
- )
549
-
550
-
551
- @app.route("/recomendacion/seguimiento", methods=["POST"])
552
- def seguimiento_recomendacion():
553
- payload = request.json or {}
554
- user_id = str(payload.get("user_id", "")).strip()
555
- post_text = str(payload.get("texto_post", "")).strip()
556
- movie_id = str(payload.get("movie_id", "")).strip()
557
- movie_title = str(payload.get("title", "")).strip()
558
-
559
- try:
560
- cycle_id = int(payload.get("ciclo_recomendacion_id", 0))
561
- except (TypeError, ValueError):
562
- cycle_id = 0
563
-
564
- if not user_id or not cycle_id or not post_text:
565
- return jsonify({"error": "user_id, ciclo_recomendacion_id y texto_post son obligatorios"}), 400
566
-
567
- cycle = get_recommendation_cycle(cycle_id=cycle_id, user_id=user_id)
568
- if not cycle:
569
- return jsonify({"error": "ciclo de recomendacion no encontrado"}), 404
570
-
571
- analyzed_at = datetime.now(timezone.utc).isoformat()
572
- result_post = sorted(clf(post_text)[0], key=lambda x: x["score"], reverse=True)
573
- post_model = result_post[0]["label"] if result_post else "others"
574
- post_emotion = EMOTION_MAP.get(post_model, "neutral")
575
- post_valence = emotion_to_valence(post_emotion)
576
-
577
- save_emotion_event(user_id=user_id, text=post_text, emotion=post_emotion, analyzed_at=analyzed_at)
578
- save_post_recommendation_state(
579
- cycle_id=cycle_id,
580
- user_id=user_id,
581
- post_text=post_text,
582
- post_emotion=post_emotion,
583
- post_analyzed_at=analyzed_at,
584
- movie_id=movie_id,
585
- movie_title=movie_title,
586
- )
587
-
588
- return jsonify(
589
- {
590
- "ciclo_recomendacion_id": cycle_id,
591
- "movie_id": movie_id,
592
- "title": movie_title,
593
- "pre_emotion": cycle.get("pre_emotion"),
594
- "pre_valence": cycle.get("pre_valence"),
595
- "post_emotion": post_emotion,
596
- "post_valence": post_valence,
597
- "cambio_emocional": cycle.get("pre_emotion") != post_emotion,
598
- "cambio_valencia": cycle.get("pre_valence") != post_valence,
599
- "emociones_post": result_post,
600
- }
601
- )
602
-
603
-
604
- @app.route("/historial/visto", methods=["POST"])
605
- def guardar_visto():
606
- payload = request.json or {}
607
- user_id = str(payload.get("user_id", "")).strip()
608
- movie_id = str(payload.get("movie_id", "")).strip()
609
-
610
- if not user_id or not movie_id:
611
- return jsonify({"error": "user_id y movie_id son obligatorios"}), 400
612
-
613
- viewed_at = datetime.now(timezone.utc).isoformat()
614
- title = str(payload.get("title", "")).strip()
615
- emotion = str(payload.get("emotion", "")).strip()
616
- session_text = str(payload.get("session_text", "")).strip()
617
- user_rating_raw = payload.get("user_rating")
618
- user_rating = None
619
- if user_rating_raw is not None and str(user_rating_raw).strip() != "":
620
- try:
621
- user_rating = float(user_rating_raw)
622
- except (TypeError, ValueError):
623
- return jsonify({"error": "user_rating debe ser numerica entre 1 y 5"}), 400
624
- if user_rating < 1 or user_rating > 5:
625
- return jsonify({"error": "user_rating debe estar entre 1 y 5"}), 400
626
-
627
- with get_db_connection() as conn:
628
- cur = conn.execute(
629
- """
630
- INSERT INTO view_history (user_id, movie_id, title, emotion, user_rating, session_text, viewed_at)
631
- VALUES (?, ?, ?, ?, ?, ?, ?)
632
- """,
633
- (user_id, movie_id, title, emotion, user_rating, session_text, viewed_at),
634
- )
635
- conn.commit()
636
- inserted_id = cur.lastrowid
637
-
638
- return jsonify(
639
- {
640
- "id": inserted_id,
641
- "user_id": user_id,
642
- "movie_id": movie_id,
643
- "title": title,
644
- "emotion": emotion,
645
- "user_rating": user_rating,
646
- "session_text": session_text,
647
- "viewed_at": viewed_at,
648
- }
649
- ), 201
650
-
651
-
652
- @app.route("/historial", methods=["GET"])
653
- def obtener_historial():
654
- user_id = str(request.args.get("user_id", "")).strip()
655
- if not user_id:
656
- return jsonify({"error": "user_id es obligatorio"}), 400
657
-
658
- try:
659
- limit = int(request.args.get("limit", 30))
660
- except ValueError:
661
- limit = 30
662
-
663
- limit = max(1, min(limit, 200))
664
-
665
- with get_db_connection() as conn:
666
- rows = conn.execute(
667
- """
668
- SELECT id, user_id, movie_id, title, emotion, user_rating, session_text, viewed_at
669
- FROM view_history
670
- WHERE user_id = ?
671
- ORDER BY viewed_at DESC
672
- LIMIT ?
673
- """,
674
- (user_id, limit),
675
- ).fetchall()
676
-
677
- historial = [dict(row) for row in rows]
678
- return jsonify({"items": historial, "count": len(historial)})
679
-
680
-
681
- @app.route("/historial/transiciones", methods=["GET"])
682
- def obtener_transiciones():
683
- user_id = str(request.args.get("user_id", "")).strip()
684
- if not user_id:
685
- return jsonify({"error": "user_id es obligatorio"}), 400
686
-
687
- try:
688
- limit = int(request.args.get("limit", 20))
689
- except ValueError:
690
- limit = 20
691
-
692
- limit = max(1, min(limit, 100))
693
-
694
- with get_db_connection() as conn:
695
- event_rows = conn.execute(
696
- """
697
- SELECT id, user_id, text, emotion, analyzed_at
698
- FROM emotion_events
699
- WHERE user_id = ?
700
- ORDER BY analyzed_at ASC
701
- LIMIT 500
702
- """,
703
- (user_id,),
704
- ).fetchall()
705
-
706
- view_rows = conn.execute(
707
- """
708
- SELECT movie_id, title, viewed_at
709
- FROM view_history
710
- WHERE user_id = ?
711
- ORDER BY viewed_at ASC
712
- LIMIT 1000
713
- """,
714
- (user_id,),
715
- ).fetchall()
716
-
717
- events = [dict(row) for row in event_rows]
718
- views = [dict(row) for row in view_rows]
719
-
720
- transition_counter: dict[tuple[str, str, str, str], int] = {}
721
-
722
- for idx in range(1, len(events)):
723
- prev_event = events[idx - 1]
724
- curr_event = events[idx]
725
- if prev_event.get("emotion") == curr_event.get("emotion"):
726
- continue
727
-
728
- start = prev_event.get("analyzed_at", "")
729
- end = curr_event.get("analyzed_at", "")
730
-
731
- matched_movie = None
732
- for view in reversed(views):
733
- viewed_at = view.get("viewed_at", "")
734
- if start < viewed_at <= end:
735
- matched_movie = view
736
- break
737
-
738
- if not matched_movie:
739
- continue
740
-
741
- key = (
742
- str(matched_movie.get("movie_id", "")),
743
- matched_movie.get("title") or "",
744
- prev_event.get("emotion", ""),
745
- curr_event.get("emotion", ""),
746
- )
747
- transition_counter[key] = transition_counter.get(key, 0) + 1
748
-
749
- items = []
750
- for (movie_id, title, from_emotion, to_emotion), count in transition_counter.items():
751
- items.append(
752
- {
753
- "movie_id": movie_id,
754
- "title": title,
755
- "from_emotion": from_emotion,
756
- "to_emotion": to_emotion,
757
- "count": count,
758
- }
759
- )
760
-
761
- items.sort(key=lambda x: x["count"], reverse=True)
762
- return jsonify({"items": items[:limit], "count": len(items)})
763
 
 
764
 
765
  if __name__ == "__main__":
766
  app.run(port=5000)
 
1
+ from app_factory import create_app
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
 
3
+ app = create_app()
4
 
5
  if __name__ == "__main__":
6
  app.run(port=5000)
backend/services/__init__.py ADDED
File without changes
backend/services/chatbot_service.py ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import requests as http_requests
2
+
3
+ from config import OLLAMA_URL, TEXT_MODEL_NAME
4
+
5
+
6
+ def build_chatbot_response(
7
+ dominant_emotion: str,
8
+ recommendation_mode: str,
9
+ recommendations: list[dict],
10
+ previous_event: dict | None,
11
+ transition_movie: dict | None,
12
+ ) -> str:
13
+ if recommendation_mode == "diferente":
14
+ mode_text = "Como estas en un estado positivo, te propongo explorar peliculas distintas a tu historial."
15
+ else:
16
+ mode_text = "Como estas en un estado negativo, te propongo peliculas similares a tu historial para mantener una zona conocida."
17
+
18
+ if recommendations:
19
+ top_title = recommendations[0].get("title", "una pelicula")
20
+ reco_text = f"Primera sugerencia: {top_title}."
21
+ else:
22
+ reco_text = "No encontre recomendaciones para ese estado emocional en este momento."
23
+
24
+ if not previous_event:
25
+ transition_text = "Este es tu primer punto de referencia emocional para analizar transiciones futuras."
26
+ else:
27
+ prev_emotion = previous_event.get("emotion", "neutral")
28
+ if prev_emotion == dominant_emotion:
29
+ transition_text = f"Tu estado se mantiene en {dominant_emotion}."
30
+ else:
31
+ transition_text = f"Detecto una transicion de {prev_emotion} a {dominant_emotion}."
32
+
33
+ if transition_movie:
34
+ movie_title = transition_movie.get("title") or transition_movie.get("movie_id", "pelicula marcada")
35
+ transition_text += f" Ultima pelicula vista entre ambos estados: {movie_title}."
36
+
37
+ return f"Estado actual: {dominant_emotion}. {mode_text} {reco_text} {transition_text}"
38
+
39
+
40
+ def _build_text_generation_prompt(
41
+ dominant_emotion: str,
42
+ recommendation_mode: str,
43
+ recommendations: list[dict],
44
+ previous_event: dict | None,
45
+ transition_movie: dict | None,
46
+ ) -> str:
47
+ top_titles = [str(row.get("title", "")).strip() for row in recommendations[:3] if row.get("title")]
48
+ top_titles_text = ", ".join(top_titles) if top_titles else "sin recomendaciones"
49
+ prev_emotion = previous_event.get("emotion") if previous_event else "ninguna"
50
+ transition_movie_title = ""
51
+ if transition_movie:
52
+ transition_movie_title = str(transition_movie.get("title") or transition_movie.get("movie_id") or "").strip()
53
+
54
+ return (
55
+ "Eres un asistente de recomendaciones de peliculas. "
56
+ "Redacta una respuesta breve en espanol (maximo 3 frases), clara y empatica. "
57
+ "Incluye estado emocional actual, explicacion de por que el modo de recomendacion es ese, "
58
+ "y menciona una pelicula sugerida si existe. "
59
+ f"Estado actual: {dominant_emotion}. "
60
+ f"Modo: {recommendation_mode}. "
61
+ f"Emocion anterior: {prev_emotion}. "
62
+ f"Peliculas sugeridas: {top_titles_text}. "
63
+ f"Pelicula asociada a transicion: {transition_movie_title or 'ninguna'}."
64
+ )
65
+
66
+
67
+ def generate_chatbot_text(
68
+ dominant_emotion: str,
69
+ recommendation_mode: str,
70
+ recommendations: list[dict],
71
+ previous_event: dict | None,
72
+ transition_movie: dict | None,
73
+ ) -> tuple[str, str]:
74
+ template_text = build_chatbot_response(
75
+ dominant_emotion=dominant_emotion,
76
+ recommendation_mode=recommendation_mode,
77
+ recommendations=recommendations,
78
+ previous_event=previous_event,
79
+ transition_movie=transition_movie,
80
+ )
81
+
82
+ try:
83
+ prompt = _build_text_generation_prompt(
84
+ dominant_emotion=dominant_emotion,
85
+ recommendation_mode=recommendation_mode,
86
+ recommendations=recommendations,
87
+ previous_event=previous_event,
88
+ transition_movie=transition_movie,
89
+ )
90
+ payload = {
91
+ "model": TEXT_MODEL_NAME,
92
+ "prompt": prompt,
93
+ "stream": False,
94
+ "options": {
95
+ "temperature": 0.7,
96
+ "top_p": 0.9,
97
+ "num_predict": 120,
98
+ },
99
+ }
100
+ res = http_requests.post(OLLAMA_URL, json=payload, timeout=20)
101
+ if res.ok:
102
+ data = res.json()
103
+ generated = str(data.get("response", "")).strip()
104
+ if generated:
105
+ return generated, "ollama"
106
+
107
+ print(f"Aviso: Ollama devolvio HTTP {res.status_code}. Se usa plantilla.")
108
+ except Exception as exc:
109
+ print(f"Aviso: fallo generando texto con Ollama ({exc}). Se usa plantilla.")
110
+
111
+ return template_text, "template-fallback"
backend/services/emotion_service.py ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from transformers import pipeline
2
+
3
+ from config import EMOTION_MAP, NEGATIVE_EMOTIONS, POSITIVE_EMOTIONS
4
+
5
+
6
+ def mapeo_emocion_valencia(emocion: str) -> str:
7
+ # Convierte una emocion puntual a valencia (positivo, negativo o neutro).
8
+ if emocion in POSITIVE_EMOTIONS:
9
+ return "positivo"
10
+ if emocion in NEGATIVE_EMOTIONS:
11
+ return "negativo"
12
+ return "neutro"
13
+
14
+
15
+ def create_emotion_classifier():
16
+ # Modelo local de clasificacion emocional en espanol.
17
+ return pipeline(
18
+ "text-classification",
19
+ model="pysentimiento/robertuito-emotion-analysis",
20
+ top_k=None,
21
+ device=-1,
22
+ )
23
+
24
+
25
+ def analyze_text(clf, texto: str) -> tuple[list[dict], str, str]:
26
+ # Ordena por score para devolver la emocion dominante en la primera posicion.
27
+ resultado = sorted(clf(texto)[0], key=lambda x: x["score"], reverse=True)
28
+ dominant_model = resultado[0]["label"] if resultado else "others"
29
+ dominant_es = EMOTION_MAP.get(dominant_model, "neutral")
30
+ dominant_valence = mapeo_emocion_valencia(dominant_es)
31
+ return resultado, dominant_es, dominant_valence
backend/services/recommender_service.py ADDED
@@ -0,0 +1,238 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import csv
2
+ import random
3
+ from collections import Counter
4
+
5
+ from config import GLOBAL_PRIOR_COUNT, LIKE_THRESHOLD, POSITIVE_EMOTIONS, ROOT_DIR
6
+ from repositories.history_repository import get_user_history_rows
7
+
8
+
9
+ def _cargar_estadisticas_ratings() -> tuple[dict[str, tuple[float, int]], float]:
10
+ # Carga ratings.csv para calcular media y conteo por pelicula,
11
+ # mas la media global del dataset.
12
+ ratings_path = ROOT_DIR / "data" / "ml-latest" / "ratings.csv"
13
+ if not ratings_path.exists():
14
+ return {}, 0.0
15
+
16
+ # Diccionario temporal para acumular sumas y conteos de ratings.
17
+ movie_sum_count: dict[str, list[float | int]] = {}
18
+ total_sum = 0.0
19
+ total_count = 0
20
+
21
+ with open(ratings_path, "r", encoding="utf-8", newline="") as f:
22
+ for row in csv.DictReader(f):
23
+ # Se obtiene movieId y rating por fila; si no son validos se omiten.
24
+ movie_id = str(row.get("movieId", "")).strip()
25
+ if not movie_id:
26
+ continue
27
+
28
+ try:
29
+ rating = float(row.get("rating", 0) or 0)
30
+ except (TypeError, ValueError):
31
+ continue
32
+
33
+ if movie_id not in movie_sum_count:
34
+ movie_sum_count[movie_id] = [0.0, 0]
35
+
36
+ movie_sum_count[movie_id][0] += rating
37
+ movie_sum_count[movie_id][1] += 1
38
+ total_sum += rating
39
+ total_count += 1
40
+
41
+ stats: dict[str, tuple[float, int]] = {}
42
+ for movie_id, (sum_rating, count_rating) in movie_sum_count.items():
43
+ mean = (sum_rating / count_rating) if count_rating else 0.0
44
+ stats[movie_id] = (mean, int(count_rating))
45
+
46
+ global_mean = (total_sum / total_count) if total_count else 0.0
47
+ return stats, global_mean
48
+
49
+
50
+ def cargar_dataset_movies() -> tuple[list[dict], float]:
51
+ # Enriquecemos movies.csv con rating_count y rating_mean de ratings.csv.
52
+ rating_stats, global_mean = _cargar_estadisticas_ratings()
53
+ candidates = [ROOT_DIR / "data" / "ml-latest" / "movies.csv"]
54
+
55
+ for path in candidates:
56
+ if path.exists():
57
+ with open(path, "r", encoding="utf-8", newline="") as f:
58
+ rows = list(csv.DictReader(f))
59
+
60
+ for row in rows:
61
+ movie_id = str(row.get("movieId", "")).strip()
62
+ mean, count = rating_stats.get(movie_id, (0.0, 0))
63
+ row["rating_count"] = int(count)
64
+ row["rating_mean"] = float(mean)
65
+
66
+ return rows, global_mean
67
+
68
+ return [], global_mean
69
+
70
+
71
+ def _obtener_generos_pelicula(row: dict) -> set[str]:
72
+ # Se separan generos por "|" y se limpian espacios.
73
+ return {g.strip() for g in row.get("genres", "").split("|") if g.strip()}
74
+
75
+
76
+ def _construir_perfil_usuario(
77
+ movies_df: list[dict],
78
+ history_rows: list[dict],
79
+ ) -> tuple[set[str], Counter, Counter, dict[str, float]]:
80
+ # Perfil del usuario: peliculas vistas, generos vistos/gustados y media por genero.
81
+ peliculas_vistas = {str(row.get("movie_id", "")).strip() for row in history_rows if row.get("movie_id")}
82
+ contador_generos_vistos: Counter = Counter()
83
+ peliculas_gustadas = {
84
+ str(row.get("movie_id", "")).strip()
85
+ for row in history_rows
86
+ if row.get("movie_id") and (row.get("user_rating") is not None) and float(row.get("user_rating") or 0) >= LIKE_THRESHOLD
87
+ }
88
+ contador_generos_gustados: Counter = Counter()
89
+ sumas_ratings_generos: dict[str, float] = {}
90
+ conteo_puntuaciones_por_genero: dict[str, int] = {}
91
+
92
+ # Si no hay historial, se devuelve perfil vacio.
93
+ if not peliculas_vistas:
94
+ return peliculas_vistas, contador_generos_vistos, contador_generos_gustados, {}
95
+
96
+ history_rating_by_movie: dict[str, float] = {}
97
+ for row in history_rows:
98
+ movie_id = str(row.get("movie_id", "")).strip()
99
+ if not movie_id:
100
+ continue
101
+ if row.get("user_rating") is None:
102
+ continue
103
+ try:
104
+ history_rating_by_movie[movie_id] = float(row.get("user_rating") or 0)
105
+ except (TypeError, ValueError):
106
+ continue
107
+
108
+ for movie in movies_df:
109
+ movie_id = str(movie.get("movieId", "")).strip()
110
+ if movie_id in peliculas_vistas:
111
+ genres = _obtener_generos_pelicula(movie)
112
+ contador_generos_vistos.update(genres)
113
+ if movie_id in peliculas_gustadas:
114
+ contador_generos_gustados.update(genres)
115
+
116
+ if movie_id in history_rating_by_movie:
117
+ rating = history_rating_by_movie[movie_id]
118
+ for genre in genres:
119
+ sumas_ratings_generos[genre] = sumas_ratings_generos.get(genre, 0.0) + rating
120
+ conteo_puntuaciones_por_genero[genre] = conteo_puntuaciones_por_genero.get(genre, 0) + 1
121
+
122
+ genre_rating_means = {
123
+ genre: (sumas_ratings_generos[genre] / conteo_puntuaciones_por_genero[genre])
124
+ for genre in sumas_ratings_generos
125
+ if conteo_puntuaciones_por_genero.get(genre, 0) > 0
126
+ }
127
+
128
+ return peliculas_vistas, contador_generos_vistos, contador_generos_gustados, genre_rating_means
129
+
130
+
131
+ def _global_quality_score(row: dict, global_rating_mean: float) -> float:
132
+ # Score global [0..1] con suavizado bayesiano para no sobrevalorar pocos votos.
133
+ count = float(row.get("rating_count", 0) or 0)
134
+ mean = float(row.get("rating_mean", 0) or 0)
135
+
136
+ if count <= 0:
137
+ smoothed = global_rating_mean
138
+ else:
139
+ smoothed = ((count * mean) + (GLOBAL_PRIOR_COUNT * global_rating_mean)) / (count + GLOBAL_PRIOR_COUNT)
140
+
141
+ return max(0.0, min(1.0, smoothed / 5.0))
142
+
143
+
144
+ def _similarity_to_history(row: dict, history_genres: set[str]) -> float:
145
+ if not history_genres:
146
+ return 0.0
147
+
148
+ genres = _obtener_generos_pelicula(row)
149
+ if not genres:
150
+ return 0.0
151
+
152
+ return len(genres & history_genres) / len(genres)
153
+
154
+
155
+ def _personal_preference_score(
156
+ row: dict,
157
+ genre_rating_means: dict[str, float],
158
+ reference_genres: set[str],
159
+ ) -> float:
160
+ # Prediccion personal [0..1] por medias de genero del usuario.
161
+ # Si faltan ratings propios, cae a similitud por generos.
162
+ genres = _obtener_generos_pelicula(row)
163
+ if not genres:
164
+ return 0.0
165
+
166
+ rated_values = [genre_rating_means[g] for g in genres if g in genre_rating_means]
167
+ if rated_values:
168
+ return max(0.0, min(1.0, (sum(rated_values) / len(rated_values)) / 5.0))
169
+
170
+ return _similarity_to_history(row, reference_genres)
171
+
172
+
173
+ def recommend_movies(
174
+ emotion_es: str,
175
+ user_id: str,
176
+ limit: int,
177
+ movies_df: list[dict],
178
+ global_rating_mean: float,
179
+ ) -> list[dict]:
180
+ # Motor principal de recomendacion con dos modos:
181
+ # - estado positivo: exploracion
182
+ # - estado negativo: zona conocida
183
+ if not movies_df:
184
+ return []
185
+
186
+ history_rows = get_user_history_rows(user_id) if user_id else []
187
+ peliculas_vistas, contador_generos_vistos, contador_generos_gustados, genre_rating_means = _construir_perfil_usuario(
188
+ movies_df,
189
+ history_rows,
190
+ )
191
+ viewed_genres = set(contador_generos_vistos.keys())
192
+ liked_genres = set(contador_generos_gustados.keys())
193
+ has_history = len(peliculas_vistas) > 0
194
+
195
+ unseen_movies = [row for row in movies_df if str(row.get("movieId", "")).strip() not in peliculas_vistas]
196
+ candidates = unseen_movies if unseen_movies else movies_df
197
+
198
+ if not candidates:
199
+ return []
200
+
201
+ # Fallback para usuarios sin historial: se prioriza calidad global.
202
+ if not has_history:
203
+ ranked = sorted(candidates, key=lambda r: _global_quality_score(r, global_rating_mean), reverse=True)
204
+ pool_size = min(len(ranked), max(limit * 4, limit))
205
+ candidate_pool = ranked[:pool_size]
206
+
207
+ if len(candidate_pool) <= limit:
208
+ random.shuffle(candidate_pool)
209
+ return candidate_pool
210
+
211
+ return random.sample(candidate_pool, k=limit)
212
+
213
+ is_positive = emotion_es in POSITIVE_EMOTIONS
214
+ if is_positive:
215
+ # Emocion positiva: mayor peso a novedad sin perder calidad.
216
+ ranked = sorted(
217
+ candidates,
218
+ key=lambda r: (
219
+ 0.25 * _personal_preference_score(r, genre_rating_means, viewed_genres)
220
+ + 0.30 * _global_quality_score(r, global_rating_mean)
221
+ + 0.45 * (1.0 - _similarity_to_history(r, viewed_genres))
222
+ ),
223
+ reverse=True,
224
+ )
225
+ else:
226
+ # Emocion negativa: mayor peso a preferencia personal + similitud.
227
+ reference_genres = liked_genres if liked_genres else viewed_genres
228
+ ranked = sorted(
229
+ candidates,
230
+ key=lambda r: (
231
+ 0.55 * _personal_preference_score(r, genre_rating_means, reference_genres)
232
+ + 0.30 * _global_quality_score(r, global_rating_mean)
233
+ + 0.15 * _similarity_to_history(r, reference_genres)
234
+ ),
235
+ reverse=True,
236
+ )
237
+
238
+ return ranked[:limit]
chatbot/dist/index.html CHANGED
@@ -4,8 +4,8 @@
4
  <meta charset="UTF-8" />
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
  <title>Analizador de Emociones</title>
7
- <script type="module" crossorigin src="/assets/index-BxMsgJL0.js"></script>
8
- <link rel="stylesheet" crossorigin href="/assets/index-Dfdk3UzR.css">
9
  </head>
10
  <body>
11
  <div id="root"></div>
 
4
  <meta charset="UTF-8" />
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
  <title>Analizador de Emociones</title>
7
+ <script type="module" crossorigin src="/assets/index-CoyYSZRA.js"></script>
8
+ <link rel="stylesheet" crossorigin href="/assets/index-Dlg3yhkK.css">
9
  </head>
10
  <body>
11
  <div id="root"></div>
chatbot/src/App.vue CHANGED
@@ -5,7 +5,11 @@
5
  <AppHero :user-id-label="userIdLabel" />
6
 
7
  <v-row align="start">
8
- <SidePanel :history="history" />
 
 
 
 
9
 
10
  <MessageFeed
11
  :messages="messages"
@@ -35,6 +39,7 @@ const loading = ref(false);
35
  const userId = ref("");
36
  const history = ref([]);
37
  const followupByCycle = ref({});
 
38
 
39
  const viewedMovieIds = computed(() => new Set(history.value.map((item) => String(item.movie_id))));
40
  const userIdLabel = computed(() => (userId.value ? userId.value.slice(0, 8) : "anon"));
@@ -165,6 +170,34 @@ async function markAsViewed(movie, dominantEmotion, sourceText, recommendationCy
165
  // No bloquea la experiencia principal del chat si falla el historial.
166
  }
167
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
168
  </script>
169
 
170
  <style>
 
5
  <AppHero :user-id-label="userIdLabel" />
6
 
7
  <v-row align="start">
8
+ <SidePanel
9
+ :history="history"
10
+ :clear-loading="clearHistoryLoading"
11
+ @clear-history="clearHistory"
12
+ />
13
 
14
  <MessageFeed
15
  :messages="messages"
 
39
  const userId = ref("");
40
  const history = ref([]);
41
  const followupByCycle = ref({});
42
+ const clearHistoryLoading = ref(false);
43
 
44
  const viewedMovieIds = computed(() => new Set(history.value.map((item) => String(item.movie_id))));
45
  const userIdLabel = computed(() => (userId.value ? userId.value.slice(0, 8) : "anon"));
 
170
  // No bloquea la experiencia principal del chat si falla el historial.
171
  }
172
  }
173
+
174
+ async function clearHistory() {
175
+ if (!userId.value || clearHistoryLoading.value) return;
176
+
177
+ const confirmed = window.confirm("Se borrara tu historial de visionado. Quieres continuar?");
178
+ if (!confirmed) return;
179
+
180
+ clearHistoryLoading.value = true;
181
+ try {
182
+ const res = await fetch("http://localhost:5000/historial", {
183
+ method: "DELETE",
184
+ headers: { "Content-Type": "application/json" },
185
+ body: JSON.stringify({ user_id: userId.value }),
186
+ });
187
+
188
+ if (!res.ok) {
189
+ window.alert("No se pudo borrar el historial.");
190
+ return;
191
+ }
192
+
193
+ history.value = [];
194
+ window.alert("Historial borrado correctamente.");
195
+ } catch {
196
+ window.alert("No se pudo conectar con el backend para borrar el historial.");
197
+ } finally {
198
+ clearHistoryLoading.value = false;
199
+ }
200
+ }
201
  </script>
202
 
203
  <style>
chatbot/src/components/SidePanel.vue CHANGED
@@ -1,7 +1,19 @@
1
  <template>
2
  <v-col cols="12" md="4">
3
  <v-card class="panel-card" rounded="xl" elevation="0">
4
- <v-card-title class="panel-title">Tu historial reciente</v-card-title>
 
 
 
 
 
 
 
 
 
 
 
 
5
  <v-card-text>
6
  <v-list v-if="history.length" density="compact" bg-color="transparent">
7
  <v-list-item v-for="item in history.slice(0, 8)" :key="item.id" class="history-item">
@@ -46,7 +58,13 @@ defineProps({
46
  type: Array,
47
  default: () => [],
48
  },
 
 
 
 
49
  });
 
 
50
  </script>
51
 
52
  <style scoped>
@@ -61,6 +79,13 @@ defineProps({
61
  letter-spacing: 0.02em;
62
  }
63
 
 
 
 
 
 
 
 
64
  .history-item {
65
  border-radius: 10px;
66
  margin-bottom: 3px;
 
1
  <template>
2
  <v-col cols="12" md="4">
3
  <v-card class="panel-card" rounded="xl" elevation="0">
4
+ <v-card-title class="panel-title panel-title-row">
5
+ <span>Tu historial reciente</span>
6
+ <v-btn
7
+ size="small"
8
+ variant="tonal"
9
+ color="error"
10
+ prepend-icon="mdi-delete-sweep-outline"
11
+ :disabled="!history.length || clearLoading"
12
+ @click="emit('clear-history')"
13
+ >
14
+ Borrar
15
+ </v-btn>
16
+ </v-card-title>
17
  <v-card-text>
18
  <v-list v-if="history.length" density="compact" bg-color="transparent">
19
  <v-list-item v-for="item in history.slice(0, 8)" :key="item.id" class="history-item">
 
58
  type: Array,
59
  default: () => [],
60
  },
61
+ clearLoading: {
62
+ type: Boolean,
63
+ default: false,
64
+ },
65
  });
66
+
67
+ const emit = defineEmits(["clear-history"]);
68
  </script>
69
 
70
  <style scoped>
 
79
  letter-spacing: 0.02em;
80
  }
81
 
82
+ .panel-title-row {
83
+ display: flex;
84
+ align-items: center;
85
+ justify-content: space-between;
86
+ gap: 12px;
87
+ }
88
+
89
  .history-item {
90
  border-radius: 10px;
91
  margin-bottom: 3px;
requirements.txt CHANGED
@@ -1,26 +1,24 @@
1
  # Backend (API)
2
- Flask==3.0.2
3
  flask-cors==4.0.0
4
  requests==2.31.0
5
 
6
- # NLP and models
7
- transformers==4.35.2
8
  torch==2.0.1
9
  flair==0.12.2
10
  pysentimiento>=0.7.0
11
 
12
- # Notebook utilities
13
  deep-translator==1.11.4
14
  nltk==3.8.1
15
  textblob==0.17.1
16
  datasets==2.14.6
 
 
17
 
18
- # Data analysis and visualization
19
  pandas==2.1.4
20
  numpy==1.24.3
21
  matplotlib==3.8.3
22
  seaborn==0.13.1
23
-
24
- # Jupyter
25
- jupyter==1.0.0
26
- ipykernel==6.27.1
 
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
8
  torch==2.0.1
9
  flair==0.12.2
10
  pysentimiento>=0.7.0
11
 
12
+ # Usadas en los Jupyter Notebooks
13
  deep-translator==1.11.4
14
  nltk==3.8.1
15
  textblob==0.17.1
16
  datasets==2.14.6
17
+ jupyter==1.0.0
18
+ ipykernel==6.27.1
19
 
20
+ # Analisis de datos y visualización
21
  pandas==2.1.4
22
  numpy==1.24.3
23
  matplotlib==3.8.3
24
  seaborn==0.13.1