subiin7777 commited on
Commit
4c8bb93
·
verified ·
1 Parent(s): 3d828ca

Upload 14 files

Browse files
Dockerfile ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.12-slim
2
+
3
+ WORKDIR /code
4
+
5
+ COPY ./requirements.txt /code/requirements.txt
6
+ RUN pip install --no-cache-dir --upgrade -r /code/requirements.txt
7
+
8
+ ENV PORT=7860
9
+
10
+ COPY . .
11
+
12
+ CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
app.py ADDED
@@ -0,0 +1,546 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import FastAPI, HTTPException, Depends
2
+ from fastapi.responses import FileResponse
3
+ from fastapi.middleware.cors import CORSMiddleware
4
+ from sqlalchemy.orm import Session
5
+ from database import get_db, User, ListeningHistory, SessionLocal, Base, engine
6
+ import json
7
+ from pydantic import BaseModel
8
+ import httpx
9
+ import os
10
+ import asyncio
11
+ from dotenv import load_dotenv
12
+ import uvicorn
13
+ import torch
14
+ import torch.nn.functional as F
15
+ from transformers import AutoTokenizer, AutoModelForSequenceClassification
16
+ import random
17
+
18
+ TOP_TAGS_CACHE = []
19
+
20
+ USE_QUANTIZED_MODEL = False
21
+
22
+ load_dotenv()
23
+
24
+ LASTFM_API_KEY = os.getenv("LASTFM_API_KEY")
25
+ if not LASTFM_API_KEY:
26
+ raise ValueError("Missing LASTFM_API_KEY! Please check your .env file.")
27
+
28
+ TOKENIZER_PATH = "./tokenizer"
29
+
30
+ import torch
31
+ import torch.nn.functional as F
32
+ from transformers import AutoTokenizer
33
+
34
+ if USE_QUANTIZED_MODEL:
35
+ from optimum.onnxruntime import ORTModelForSequenceClassification
36
+ MODEL_PATH = "models/onnx_model_int8"
37
+ print("Using Quantized Model")
38
+ else:
39
+ from transformers import AutoModelForSequenceClassification
40
+ MODEL_PATH = "./models/fp32"
41
+ print("Using UnQuantized Model")
42
+
43
+ # 1. Define Data Structures
44
+ class OnboardRequest(BaseModel):
45
+ user_id: str
46
+ favorite_artists: list[str] = []
47
+ favorite_genres: list[str] = []
48
+ default_tags: list[str] = []
49
+
50
+ class HistoryRequest(BaseModel):
51
+ user_id: str
52
+ track_title: str
53
+ track_artist: str
54
+ track_url: str
55
+ duration: int
56
+ emotion_state: str
57
+ action: str
58
+
59
+ class DiaryRequest(BaseModel):
60
+ user_id: str
61
+ text: str
62
+ intent: str # "match" or "shift"
63
+
64
+ class Track(BaseModel):
65
+ title: str
66
+ artist: str
67
+ url: str
68
+
69
+ class RecommendationResponse(BaseModel):
70
+ emotion: str
71
+ confidence: float
72
+ intent: str
73
+ tracks: list[Track]
74
+
75
+ class TextRequest(BaseModel):
76
+ text: str
77
+
78
+ class SentimentResponse(BaseModel):
79
+ label: str
80
+ confidence: float
81
+ all_scores: dict
82
+
83
+ EMOTION_TAG_MAP = {
84
+ "LABEL_0": {
85
+ "name": "분노 (Anger)",
86
+ "match": ["angry", "heavy metal", "death metal", "hard rock", "punk", "screamo", "aggressive", "intense", "hardcore", "thrash metal", "grunge", "rap", "hip hop", "rage", "metalcore", "nu metal"],
87
+ "shift": ["chillout", "ambient", "calm", "healing", "acoustic", "lo-fi", "soothing", "meditation", "peaceful", "relax", "soft", "classical", "quiet", "smooth"]
88
+ },
89
+ "LABEL_1": {
90
+ "name": "슬픔 (Sadness)",
91
+ "match": ["sad", "melancholy", "sadcore", "cry", "ballad", "emotional", "soul", "indie", "depressive", "bittersweet", "lonely", "melancholic", "heartbreak", "slow", "tearjerker", "gloom", "depressing"],
92
+ "shift": ["happy", "sunshine", "upbeat", "indie pop", "dance", "pop", "fun", "energy", "energetic", "cheerful", "feel good", "party", "lively", "uplifting", "summer"]
93
+ },
94
+ "LABEL_2": {
95
+ "name": "불안 (Anxiety)",
96
+ "match": ["dark ambient", "sad", "anxious", "dark", "creepy", "atmospheric", "tense", "drone", "unsettling", "noise", "industrial", "chaotic", "intense", "experimental"],
97
+ "shift": ["comfort", "warm", "lo-fi", "acoustic", "healing", "piano", "chill", "soothing", "ambient", "relax", "calm", "easy listening", "meditation", "peaceful", "gentle"]
98
+ },
99
+ "LABEL_3": {
100
+ "name": "상처 (Hurt)",
101
+ "match": ["heartbreak", "emotional", "sad", "breakup", "ballad", "tearjerker", "longing", "nostalgic", "acoustic", "sadcore", "soul", "blues", "melancholy", "missing you", "sorrow"],
102
+ "shift": ["hopeful", "uplifting", "healing", "feel good", "acoustic", "sunshine", "warm", "comfort", "inspirational", "bright", "happy", "positive", "joy"]
103
+ },
104
+ "LABEL_4": {
105
+ "name": "당황 (Embarrassment)",
106
+ "match": ["indie", "alternative", "chaotic", "noise", "experimental", "quirky", "weird", "avant-garde", "raw", "eclectic", "fast", "punk", "ska"],
107
+ "shift": ["ambient", "focus", "lo-fi", "relax", "chillout", "calm", "smooth", "jazz", "easy listening", "slow", "soft", "gentle", "piano", "acoustic"]
108
+ },
109
+ "LABEL_5": {
110
+ "name": "기쁨 (Joy)",
111
+ "match": ["happy", "upbeat", "feel good", "pop", "dance", "fun", "summer", "cheerful", "energetic", "party", "sunshine", "lively", "exciting", "electronic", "disco", "funk"],
112
+ "shift": ["chill", "acoustic", "calm", "lo-fi", "relaxing", "smooth", "easy listening", "quiet", "soft", "ambient", "mellow", "sleep"]
113
+ }
114
+ }
115
+
116
+ CUSTOM_LABELS = {
117
+ "LABEL_0": "Anger",
118
+ "LABEL_1": "Sadness",
119
+ "LABEL_2": "Anxiety",
120
+ "LABEL_3": "Hurt",
121
+ "LABEL_4": "Embarrassment",
122
+ "LABEL_5": "Joy"
123
+ }
124
+
125
+ EMOTION_SPOTIFY_MAP = {
126
+ "LABEL_0": { # Anger
127
+ "match": {"min_energy": 0.7, "max_energy": 1.0, "min_valence": 0.0, "max_valence": 0.4},
128
+ "shift": {"min_energy": 0.0, "max_energy": 0.4, "min_valence": 0.6, "max_valence": 1.0}
129
+ },
130
+ "LABEL_1": { # Sadness
131
+ "match": {"min_energy": 0.0, "max_energy": 0.4, "min_valence": 0.0, "max_valence": 0.4},
132
+ "shift": {"min_energy": 0.6, "max_energy": 1.0, "min_valence": 0.7, "max_valence": 1.0}
133
+ },
134
+ "LABEL_2": { # Anxiety
135
+ "match": {"min_energy": 0.5, "max_energy": 0.8, "min_valence": 0.0, "max_valence": 0.4},
136
+ "shift": {"min_energy": 0.0, "max_energy": 0.4, "min_valence": 0.6, "max_valence": 1.0}
137
+ },
138
+ "LABEL_3": { # Hurt
139
+ "match": {"min_energy": 0.0, "max_energy": 0.5, "min_valence": 0.0, "max_valence": 0.4},
140
+ "shift": {"min_energy": 0.4, "max_energy": 0.7, "min_valence": 0.6, "max_valence": 1.0}
141
+ },
142
+ "LABEL_4": { # Embarrassment
143
+ "match": {"min_energy": 0.6, "max_energy": 1.0, "min_valence": 0.0, "max_valence": 0.5},
144
+ "shift": {"min_energy": 0.0, "max_energy": 0.5, "min_valence": 0.6, "max_valence": 1.0}
145
+ },
146
+ "LABEL_5": { # Joy
147
+ "match": {"min_energy": 0.6, "max_energy": 1.0, "min_valence": 0.7, "max_valence": 1.0},
148
+ "shift": {"min_energy": 0.0, "max_energy": 0.5, "min_valence": 0.5, "max_valence": 0.7}
149
+ }
150
+ }
151
+
152
+ # 2. Initialize FastAPI app
153
+ app = FastAPI(title="ELECTRA Sentiment Analysis API")
154
+
155
+ # Add CORS so the HTML page can communicate with this API
156
+ app.add_middleware(
157
+ CORSMiddleware,
158
+ allow_origins=["*"], # In production, change to your website's URL
159
+ allow_credentials=True,
160
+ allow_methods=["*"],
161
+ allow_headers=["*"],
162
+ )
163
+
164
+ # Check if GPU is available, otherwise use CPU
165
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
166
+ try:
167
+ print(f"Loading tokenizer from {TOKENIZER_PATH}...")
168
+ tokenizer = AutoTokenizer.from_pretrained(TOKENIZER_PATH)
169
+ print(f"Loading model from {MODEL_PATH}...")
170
+ if USE_QUANTIZED_MODEL:
171
+ model = ORTModelForSequenceClassification.from_pretrained(MODEL_PATH)
172
+ else:
173
+ model = AutoModelForSequenceClassification.from_pretrained(MODEL_PATH)
174
+ model.to(device)
175
+ model.eval() # Set model to evaluation mode
176
+ print("Model loaded successfully!")
177
+ except Exception as e:
178
+ print(f"Error loading model: {e}")
179
+ raise RuntimeError(f"Could not load model.") from e
180
+
181
+
182
+ async def fetch_lastfm_tracks(tag: str, limit: int = 50):
183
+ print(f"🔍 [DEBUG] Searching Last.fm top tracks for tag: '{tag}' (limit={limit})")
184
+ url = "https://ws.audioscrobbler.com/2.0/"
185
+ params = {
186
+ "method": "tag.gettoptracks",
187
+ "tag": tag,
188
+ "api_key": LASTFM_API_KEY,
189
+ "format": "json",
190
+ "limit": limit
191
+ }
192
+ async with httpx.AsyncClient() as client:
193
+ try:
194
+ res = await client.get(url, params=params)
195
+ if res.status_code == 200:
196
+ data = res.json()
197
+ return data.get("tracks", {}).get("track", [])
198
+ except Exception as e:
199
+ print(f"Error fetching Last.fm tracks: {e}")
200
+ return []
201
+
202
+ async def fetch_lastfm_artist_tracks(artist: str, limit: int = 50):
203
+ print(f"🔍 [DEBUG] Searching Last.fm top tracks for artist: '{artist}' (limit={limit})")
204
+ url = "https://ws.audioscrobbler.com/2.0/"
205
+ params = {
206
+ "method": "artist.gettoptracks",
207
+ "artist": artist,
208
+ "api_key": LASTFM_API_KEY,
209
+ "format": "json",
210
+ "limit": limit
211
+ }
212
+ async with httpx.AsyncClient() as client:
213
+ try:
214
+ res = await client.get(url, params=params)
215
+ if res.status_code == 200:
216
+ data = res.json()
217
+ return data.get("toptracks", {}).get("track", [])
218
+ except Exception as e:
219
+ print(f"Error fetching Last.fm artist tracks: {e}")
220
+ return []
221
+
222
+ async def fetch_lastfm_similar_tracks(artist: str, track: str, limit: int = 50):
223
+ print(f"🔍 [DEBUG] Searching Last.fm for tracks similar to: '{artist} - {track}' (limit={limit})")
224
+ url = "https://ws.audioscrobbler.com/2.0/"
225
+ params = {
226
+ "method": "track.getsimilar",
227
+ "artist": artist,
228
+ "track": track,
229
+ "api_key": LASTFM_API_KEY,
230
+ "format": "json",
231
+ "limit": limit
232
+ }
233
+ async with httpx.AsyncClient() as client:
234
+ try:
235
+ res = await client.get(url, params=params)
236
+ if res.status_code == 200:
237
+ data = res.json()
238
+ return data.get("similartracks", {}).get("track", [])
239
+ except Exception as e:
240
+ print(f"Error fetching Last.fm similar tracks: {e}")
241
+ return []
242
+
243
+ @app.on_event("startup")
244
+ async def startup_event():
245
+ global TOP_TAGS_CACHE
246
+ url = "https://ws.audioscrobbler.com/2.0/"
247
+ params = {
248
+ "method": "chart.gettoptags",
249
+ "api_key": LASTFM_API_KEY,
250
+ "format": "json",
251
+ "limit": 1000
252
+ }
253
+ headers = {"User-Agent": "DiaryMusicRecommender/1.0"}
254
+ async with httpx.AsyncClient(timeout=10.0) as client:
255
+ try:
256
+ response = await client.get(url, params=params, headers=headers)
257
+ response.raise_for_status()
258
+ data = response.json()
259
+ if "tags" in data and "tag" in data["tags"]:
260
+ TOP_TAGS_CACHE = [t["name"].lower() for t in data["tags"]["tag"]]
261
+ print(f"Loaded {len(TOP_TAGS_CACHE)} top tags for autocomplete.")
262
+ except Exception as e:
263
+ print(f"Error loading top tags: {e}")
264
+
265
+ @app.get("/api/tags/autocomplete")
266
+ async def autocomplete_tags(q: str = ""):
267
+ if not q:
268
+ return {"tags": []}
269
+
270
+ q_lower = q.lower()
271
+ matches = [t for t in TOP_TAGS_CACHE if q_lower in t]
272
+ matches.sort(key=lambda x: (not x.startswith(q_lower), x))
273
+ return {"tags": matches[:10]}
274
+
275
+ @app.get("/api/artists/autocomplete")
276
+ async def autocomplete_artists(q: str = ""):
277
+ if not q:
278
+ return {"artists": []}
279
+
280
+ url = "https://ws.audioscrobbler.com/2.0/"
281
+ params = {
282
+ "method": "artist.search",
283
+ "artist": q,
284
+ "api_key": LASTFM_API_KEY,
285
+ "format": "json",
286
+ "limit": 10
287
+ }
288
+ async with httpx.AsyncClient() as client:
289
+ try:
290
+ res = await client.get(url, params=params)
291
+ if res.status_code == 200:
292
+ data = res.json()
293
+ matches = [a["name"] for a in data.get("results", {}).get("artistmatches", {}).get("artist", [])]
294
+ return {"artists": matches}
295
+ except Exception as e:
296
+ print(f"Error searching Last.fm artists: {e}")
297
+ return {"artists": []}
298
+
299
+ @app.post("/api/users/onboard")
300
+ def onboard_user(request: OnboardRequest, db: Session = Depends(get_db)):
301
+ user = db.query(User).filter(User.user_id == request.user_id).first()
302
+ if not user:
303
+ user = User(user_id=request.user_id)
304
+ db.add(user)
305
+
306
+ user.set_preferences(request.favorite_artists, request.favorite_genres, request.default_tags)
307
+ db.commit()
308
+ return {"message": "User onboarded successfully."}
309
+
310
+ @app.post("/api/users/history")
311
+ def save_history(request: HistoryRequest, db: Session = Depends(get_db)):
312
+ new_history = ListeningHistory(
313
+ user_id=request.user_id, track_title=request.track_title, track_artist=request.track_artist,
314
+ track_url=request.track_url, duration=request.duration, emotion_state=request.emotion_state, action=request.action
315
+ )
316
+ db.add(new_history)
317
+ db.commit()
318
+ return {"message": "History saved successfully"}
319
+
320
+ @app.post("/recommend", response_model=RecommendationResponse)
321
+ async def recommend_music(request: DiaryRequest, db: Session = Depends(get_db)):
322
+ if not request.text.strip():
323
+ raise HTTPException(status_code=400, detail="Diary entry cannot be empty.")
324
+
325
+ # 1. Look up user's default tags
326
+ user = db.query(User).filter(User.user_id == request.user_id).first()
327
+ default_tags = json.loads(user.default_tags_json) if user and user.default_tags_json else []
328
+ # 2. Sentiment Analysis
329
+ inputs = tokenizer(request.text, return_tensors="pt", truncation=True, max_length=512, padding=True)
330
+
331
+ if not USE_QUANTIZED_MODEL:
332
+ inputs = inputs.to(device)
333
+
334
+ with torch.no_grad():
335
+ outputs = model(**inputs)
336
+
337
+ probabilities = F.softmax(outputs.logits, dim=-1)[0]
338
+ predicted_class_id = torch.argmax(probabilities).item()
339
+ confidence = probabilities[predicted_class_id].item()
340
+ raw_label = model.config.id2label[predicted_class_id]
341
+
342
+ emotion_data = EMOTION_TAG_MAP.get(raw_label, EMOTION_TAG_MAP["LABEL_0"])
343
+ emotion_name = emotion_data["name"]
344
+
345
+ intent_tags = emotion_data["match"] if request.intent == "match" else emotion_data["shift"]
346
+
347
+ # Shuffle tags to ensure variety
348
+ random.shuffle(intent_tags)
349
+
350
+ tracks = []
351
+
352
+ fav_artists = json.loads(user.favorite_artists_json) if user and user.favorite_artists_json else []
353
+ fav_genres = json.loads(user.favorite_genres_json) if user and user.favorite_genres_json else []
354
+
355
+ # 1. Randomly inject 1-2 favorite tracks into normal suggestions
356
+ if fav_artists or fav_genres:
357
+ fav_pool = []
358
+ if fav_artists:
359
+ artist = random.choice(fav_artists)
360
+ fav_pool.extend(await fetch_lastfm_artist_tracks(artist, limit=50))
361
+ if fav_genres:
362
+ genre = random.choice(fav_genres)
363
+ fav_pool.extend(await fetch_lastfm_tracks(genre, limit=50))
364
+
365
+ if fav_pool:
366
+ random.shuffle(fav_pool)
367
+ num_to_inject = random.randint(1, 2)
368
+ for t in fav_pool[:num_to_inject]:
369
+ if t.get("url"):
370
+ artist_name = t["artist"]["name"] if isinstance(t.get("artist"), dict) else t.get("artist", "Unknown")
371
+ tracks.append(Track(
372
+ title=t["name"],
373
+ artist=artist_name,
374
+ url=t["url"]
375
+ ))
376
+
377
+ # 3. INTERSECTION LOGIC with Last.fm
378
+ base_tracks = []
379
+ if default_tags:
380
+ user_base_tag = random.choice(default_tags)
381
+ base_tracks = await fetch_lastfm_tracks(user_base_tag, limit=100)
382
+ base_urls = {t["url"] for t in base_tracks if "url" in t}
383
+
384
+ # Loop through ALL intent tags until we reach 5 tracks
385
+ for emotion_tag in intent_tags:
386
+ if len(tracks) >= 5:
387
+ break
388
+
389
+ print(f"\n🔍 [DEBUG] Attempting to find intersection for tag '{user_base_tag}' AND emotion '{emotion_tag}'")
390
+ emotion_tracks = await fetch_lastfm_tracks(emotion_tag, limit=100)
391
+
392
+ intersection = [t for t in emotion_tracks if t.get("url") in base_urls]
393
+ if intersection:
394
+ random.shuffle(intersection)
395
+ for t in intersection:
396
+ existing_urls = [existing.url for existing in tracks]
397
+ if t["url"] not in existing_urls:
398
+ artist_name = t["artist"]["name"] if isinstance(t.get("artist"), dict) else t.get("artist", "Unknown")
399
+ tracks.append(Track(
400
+ title=t["name"],
401
+ artist=artist_name,
402
+ url=t["url"]
403
+ ))
404
+ if len(tracks) >= 5:
405
+ break
406
+
407
+ # 4. SIMILAR TRACKS LOGIC
408
+ # If we found at least 1 track from intersections (or fav injection), use it as a seed to find similar tracks!
409
+ if 0 < len(tracks) < 5:
410
+ print(f"🔍 [DEBUG] Found {len(tracks)} tracks. Using them as seeds to find similar tracks.")
411
+ similar_pool = []
412
+ for seed_track in tracks:
413
+ similar_pool.extend(await fetch_lastfm_similar_tracks(seed_track.artist, seed_track.title, limit=20))
414
+
415
+ if similar_pool:
416
+ random.shuffle(similar_pool)
417
+ for t in similar_pool:
418
+ existing_urls = [existing.url for existing in tracks]
419
+ if t.get("url") and t["url"] not in existing_urls:
420
+ artist_name = t["artist"]["name"] if isinstance(t.get("artist"), dict) else t.get("artist", "Unknown")
421
+ tracks.append(Track(
422
+ title=t["name"],
423
+ artist=artist_name,
424
+ url=t["url"]
425
+ ))
426
+ if len(tracks) >= 5:
427
+ break
428
+
429
+ # 5. FALLBACK LOGIC: If we tried all tags and similar tracks and still don't have 5 tracks
430
+ if len(tracks) < 5:
431
+ print(f"⚠️ [DEBUG] Still only have {len(tracks)} tracks. Falling back to fav artists/genres/default tags.")
432
+ fallback_pool = []
433
+
434
+ if fav_artists:
435
+ artist = random.choice(fav_artists)
436
+ fallback_pool.extend(await fetch_lastfm_artist_tracks(artist, limit=50))
437
+ if fav_genres:
438
+ genre = random.choice(fav_genres)
439
+ fallback_pool.extend(await fetch_lastfm_tracks(genre, limit=50))
440
+
441
+ # Prioritize default tag over feeling
442
+ if base_tracks:
443
+ fallback_pool.extend(base_tracks)
444
+ else:
445
+ # If user has no default tags at all, fallback to a random feeling
446
+ fallback_pool.extend(await fetch_lastfm_tracks(random.choice(intent_tags), limit=50))
447
+
448
+ if fallback_pool:
449
+ random.shuffle(fallback_pool)
450
+ for t in fallback_pool:
451
+ existing_urls = [existing.url for existing in tracks]
452
+ if t.get("url") and t["url"] not in existing_urls:
453
+ artist_name = t["artist"]["name"] if isinstance(t.get("artist"), dict) else t.get("artist", "Unknown")
454
+ tracks.append(Track(
455
+ title=t["name"],
456
+ artist=artist_name,
457
+ url=t["url"]
458
+ ))
459
+ if len(tracks) >= 5:
460
+ break
461
+ # 감정 자동 저장
462
+ diary_record = ListeningHistory(
463
+ user_id=request.user_id,
464
+ track_title="",
465
+ track_artist="",
466
+ track_url="",
467
+ duration=0,
468
+ emotion_state=emotion_name,
469
+ action="diary"
470
+ )
471
+ db.add(diary_record)
472
+ db.commit()
473
+
474
+ return RecommendationResponse(
475
+ emotion=emotion_name,
476
+ confidence=confidence,
477
+ intent=request.intent,
478
+ tracks=tracks
479
+ )
480
+
481
+ @app.post("/analyze", response_model=SentimentResponse)
482
+ async def analyze_sentiment(request: TextRequest):
483
+ if not request.text.strip():
484
+ raise HTTPException(status_code=400, detail="Text cannot be empty.")
485
+ try:
486
+ inputs = tokenizer(request.text, return_tensors="pt", truncation=True, max_length=512, padding=True)
487
+ if not USE_QUANTIZED_MODEL:
488
+ inputs = inputs.to(device)
489
+
490
+ with torch.no_grad():
491
+ outputs = model(**inputs)
492
+
493
+ logits = outputs.logits
494
+ probabilities = F.softmax(logits, dim=-1)[0]
495
+ predicted_class_id = torch.argmax(probabilities).item()
496
+ confidence = probabilities[predicted_class_id].item()
497
+
498
+ raw_label = model.config.id2label[predicted_class_id]
499
+ label = CUSTOM_LABELS.get(raw_label, raw_label)
500
+ all_scores = {
501
+ CUSTOM_LABELS.get(model.config.id2label[i], model.config.id2label[i]): float(prob)
502
+ for i, prob in enumerate(probabilities)
503
+ }
504
+ return SentimentResponse(label=label, confidence=confidence, all_scores=all_scores)
505
+ except Exception as e:
506
+ raise HTTPException(status_code=500, detail=str(e))
507
+
508
+
509
+ @app.get("/")
510
+ def serve_webpage():
511
+ return FileResponse("index.html")
512
+
513
+ @app.get("/api/users/history/stats")
514
+ def get_history_stats(user_id: str, db: Session = Depends(get_db)):
515
+ history = db.query(ListeningHistory).filter(
516
+ ListeningHistory.user_id == user_id,
517
+ ListeningHistory.action == "diary" # liked → diary로 변경
518
+ ).all()
519
+ counts = {}
520
+ for h in history:
521
+ counts[h.emotion_state] = counts.get(h.emotion_state, 0) + 1
522
+ return {"emotion_counts": counts}
523
+
524
+ @app.get("/health")
525
+ def read_root():
526
+ mode = "Quantized ONNX" if USE_QUANTIZED_MODEL else "Standard PyTorch"
527
+ return {"status": "Model API is running", "mode": mode, "model_path": MODEL_PATH}
528
+
529
+ @app.get("/api/users/favorites")
530
+ def get_favorite_tracks(user_id: str, db: Session = Depends(get_db)):
531
+ # 내가 하트(liked) 누른 곡들만 타임스탬프 최신순으로 가져오기
532
+ favorites = db.query(ListeningHistory).filter(
533
+ ListeningHistory.user_id == user_id,
534
+ ListeningHistory.action == "liked"
535
+ ).order_by(ListeningHistory.timestamp.desc()).all()
536
+
537
+ # 프론트엔드가 쓰기 편하게 리스트 형태로 정제해서 반환
538
+ track_list = []
539
+ for f in favorites:
540
+ track_list.append({
541
+ "title": f.track_title,
542
+ "artist": f.track_artist,
543
+ "url": f.track_url,
544
+ "emotion": f.emotion_state
545
+ })
546
+ return {"favorites": track_list}
database.py ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from sqlalchemy import create_engine, Column, String, Integer, DateTime
2
+ from sqlalchemy.ext.declarative import declarative_base
3
+ from sqlalchemy.orm import sessionmaker
4
+ import datetime
5
+ import json
6
+
7
+ SQLALCHEMY_DATABASE_URL = "sqlite:///./app_database.db"
8
+
9
+ engine = create_engine(
10
+ SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False}
11
+ )
12
+ SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
13
+ Base = declarative_base()
14
+
15
+ class User(Base):
16
+ __tablename__ = "users"
17
+
18
+ user_id = Column(String, primary_key=True, index=True)
19
+ favorite_artists_json = Column(String, default="[]")
20
+ favorite_genres_json = Column(String, default="[]")
21
+ default_tags_json = Column(String, default="[]") # ["k-pop", "korean"]
22
+
23
+ def set_preferences(self, artists, genres, tags):
24
+ self.favorite_artists_json = json.dumps(artists)
25
+ self.favorite_genres_json = json.dumps(genres)
26
+ self.default_tags_json = json.dumps(tags)
27
+
28
+ class ListeningHistory(Base):
29
+ __tablename__ = "history"
30
+
31
+ id = Column(Integer, primary_key=True, index=True, autoincrement=True)
32
+ user_id = Column(String, index=True)
33
+ track_title = Column(String)
34
+ track_artist = Column(String)
35
+ track_url = Column(String)
36
+ duration = Column(Integer, default=0)
37
+ emotion_state = Column(String) # "Sadness"
38
+ action = Column(String) # "liked", "completed", "skipped"
39
+ timestamp = Column(DateTime, default=datetime.datetime.utcnow)
40
+
41
+ Base.metadata.create_all(bind=engine)
42
+
43
+ def get_db():
44
+ db = SessionLocal()
45
+ try:
46
+ yield db
47
+ finally:
48
+ db.close()
index.html ADDED
@@ -0,0 +1,559 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="ko">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>AI 감성 일기 & 음악 추천</title>
7
+ <style>
8
+ * { box-sizing: border-box; margin: 0; padding: 0; }
9
+ body {
10
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
11
+ background: #FDF8F3;
12
+ min-height: 100vh;
13
+ padding: 2rem 1rem;
14
+ color: #2C2C2A;
15
+ }
16
+ .container { max-width: 600px; margin: 0 auto; }
17
+ .app-header { text-align: center; margin-bottom: 1.5rem; }
18
+ .app-header h1 { font-size: 1.6rem; font-weight: 600; color: #2C2C2A; letter-spacing: -0.5px; }
19
+ .app-header p { font-size: 0.9rem; color: #888780; margin-top: 6px; }
20
+
21
+ /* 탭 */
22
+ .tab-bar {
23
+ display: flex;
24
+ background: #FFFFFF;
25
+ border-radius: 16px;
26
+ border: 1px solid #F0EBE3;
27
+ padding: 6px;
28
+ margin-bottom: 1.25rem;
29
+ gap: 4px;
30
+ }
31
+ .tab-btn {
32
+ flex: 1;
33
+ padding: 10px;
34
+ border: none;
35
+ background: none;
36
+ border-radius: 12px;
37
+ font-size: 0.88rem;
38
+ font-weight: 600;
39
+ color: #888780;
40
+ cursor: pointer;
41
+ transition: all 0.18s;
42
+ }
43
+ .tab-btn.active {
44
+ background: #FDF4E7;
45
+ color: #D4845A;
46
+ }
47
+ .tab-btn:hover:not(.active) { background: #F5F0EA; }
48
+ .tab-btn:disabled { opacity: 0.4; cursor: not-allowed; pointer-events: none; }
49
+
50
+ .card {
51
+ background: #FFFFFF;
52
+ border-radius: 20px;
53
+ border: 1px solid #F0EBE3;
54
+ padding: 1.5rem;
55
+ margin-bottom: 1.25rem;
56
+ box-shadow: 0 2px 16px rgba(180,160,130,0.07);
57
+ }
58
+ .card h2 { font-size: 1.1rem; font-weight: 600; margin-bottom: 1rem; color: #2C2C2A; }
59
+
60
+ label.field-label {
61
+ display: block;
62
+ font-size: 0.82rem;
63
+ font-weight: 600;
64
+ color: #888780;
65
+ text-transform: uppercase;
66
+ letter-spacing: 0.5px;
67
+ margin-bottom: 6px;
68
+ margin-top: 1rem;
69
+ }
70
+ input[type="text"], textarea {
71
+ width: 100%;
72
+ padding: 12px 14px;
73
+ border-radius: 12px;
74
+ border: 1.5px solid #EDE8E0;
75
+ font-size: 0.95rem;
76
+ background: #FDFAF7;
77
+ color: #2C2C2A;
78
+ transition: border 0.2s;
79
+ font-family: inherit;
80
+ }
81
+ input[type="text"]:focus, textarea:focus {
82
+ outline: none;
83
+ border-color: #D4A96A;
84
+ background: #fff;
85
+ }
86
+ textarea { height: 130px; resize: vertical; line-height: 1.6; }
87
+
88
+ .intent-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 0.5rem; }
89
+ .intent-card {
90
+ border: 2px solid #EDE8E0;
91
+ border-radius: 14px;
92
+ padding: 14px 12px;
93
+ cursor: pointer;
94
+ transition: all 0.18s;
95
+ background: #FDFAF7;
96
+ text-align: center;
97
+ }
98
+ .intent-card:hover { border-color: #D4A96A; background: #FDF4E7; }
99
+ .intent-card.selected { border-color: #D4A96A; background: #FDF4E7; }
100
+ .intent-card .intent-icon { font-size: 1.6rem; margin-bottom: 6px; }
101
+ .intent-card .intent-title { font-size: 0.88rem; font-weight: 600; color: #2C2C2A; }
102
+ .intent-card .intent-sub { font-size: 0.75rem; color: #888780; margin-top: 3px; }
103
+
104
+ .btn-primary {
105
+ width: 100%;
106
+ padding: 14px;
107
+ border-radius: 14px;
108
+ border: none;
109
+ background: linear-gradient(135deg, #E8A96A 0%, #D4845A 100%);
110
+ color: #fff;
111
+ font-size: 1rem;
112
+ font-weight: 600;
113
+ cursor: pointer;
114
+ margin-top: 1.25rem;
115
+ transition: opacity 0.18s, transform 0.12s;
116
+ }
117
+ .btn-primary:hover { opacity: 0.92; transform: translateY(-1px); }
118
+ .btn-primary:active { transform: scale(0.98); }
119
+
120
+ .btn-secondary {
121
+ width: 100%;
122
+ padding: 12px;
123
+ border-radius: 14px;
124
+ border: 1.5px solid #EDE8E0;
125
+ background: #FDFAF7;
126
+ color: #888780;
127
+ font-size: 0.9rem;
128
+ font-weight: 500;
129
+ cursor: pointer;
130
+ margin-top: 10px;
131
+ transition: all 0.18s;
132
+ }
133
+ .btn-secondary:hover { border-color: #D4A96A; color: #D4845A; }
134
+
135
+ .pill-container { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; }
136
+ .pill {
137
+ background: #FDF4E7;
138
+ color: #A06030;
139
+ padding: 6px 12px;
140
+ border-radius: 20px;
141
+ font-size: 0.8rem;
142
+ font-weight: 600;
143
+ display: flex;
144
+ align-items: center;
145
+ gap: 6px;
146
+ border: 1px solid #F0D9B8;
147
+ }
148
+ .pill .remove { cursor: pointer; color: #C09070; }
149
+ .pill .remove:hover { color: #A03020; }
150
+
151
+ .autocomplete-wrapper { position: relative; }
152
+ .suggestions {
153
+ position: absolute; top: 100%; left: 0; right: 0;
154
+ background: white; border: 1px solid #EDE8E0; border-top: none;
155
+ z-index: 99; max-height: 150px; overflow-y: auto;
156
+ border-radius: 0 0 12px 12px;
157
+ box-shadow: 0 8px 20px rgba(0,0,0,0.08);
158
+ }
159
+ .suggestions div { padding: 10px 14px; cursor: pointer; font-size: 0.88rem; border-bottom: 1px solid #F5F0EA; }
160
+ .suggestions div:hover { background: #FDF4E7; color: #D4845A; font-weight: 600; }
161
+
162
+ .hidden { display: none !important; }
163
+
164
+ .emotion-banner {
165
+ border-radius: 16px;
166
+ padding: 1.25rem;
167
+ text-align: center;
168
+ margin-bottom: 1.25rem;
169
+ }
170
+ .emotion-banner .emotion-emoji { font-size: 3rem; display: block; margin-bottom: 8px; }
171
+ .emotion-banner .emotion-label { font-size: 1.2rem; font-weight: 700; }
172
+ .emotion-banner .emotion-conf { font-size: 0.85rem; margin-top: 4px; opacity: 0.75; }
173
+
174
+ .theme-anger { background: #FDEAEA; color: #8B2020; }
175
+ .theme-sadness { background: #EAF0FD; color: #1A3A7A; }
176
+ .theme-anxiety { background: #F0EAFD; color: #5A1A8B; }
177
+ .theme-hurt { background: #FDEAF4; color: #8B1A55; }
178
+ .theme-embarrassment { background: #FDFAEA; color: #7A6A10; }
179
+ .theme-joy { background: #EAFDE8; color: #1A7A30; }
180
+ .theme-default { background: #FDF4E7; color: #8B5A20; }
181
+
182
+ .track-card {
183
+ background: #FDFAF7;
184
+ border: 1.5px solid #EDE8E0;
185
+ border-radius: 14px;
186
+ padding: 14px 16px;
187
+ margin-bottom: 10px;
188
+ display: flex;
189
+ align-items: center;
190
+ gap: 14px;
191
+ transition: border-color 0.18s, transform 0.15s;
192
+ }
193
+ .track-card:hover { border-color: #D4A96A; transform: translateY(-1px); }
194
+ .track-num { font-size: 1rem; font-weight: 700; color: #D4A96A; min-width: 24px; text-align: center; }
195
+ .track-info { flex: 1; min-width: 0; }
196
+ .track-info a {
197
+ color: #2C2C2A; text-decoration: none; font-weight: 600; font-size: 0.95rem;
198
+ display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
199
+ }
200
+ .track-info a:hover { color: #D4845A; }
201
+ .track-info .artist { font-size: 0.82rem; color: #888780; margin-top: 3px; }
202
+
203
+ .like-btn {
204
+ background: none;
205
+ border: 1.5px solid #EDE8E0;
206
+ border-radius: 50%;
207
+ width: 36px; height: 36px;
208
+ cursor: pointer; font-size: 1rem;
209
+ display: flex; align-items: center; justify-content: center;
210
+ transition: all 0.18s; flex-shrink: 0;
211
+ }
212
+ .like-btn:hover { background: #FDF4E7; border-color: #D4A96A; transform: scale(1.1); }
213
+ .like-btn.liked { background: #FDF4E7; border-color: #D4A96A; color: #E05555; }
214
+
215
+ .loading-area { text-align: center; padding: 2rem; color: #888780; font-size: 0.9rem; }
216
+ @keyframes dots { 0% { content: '.'; } 33% { content: '..'; } 66% { content: '...'; } }
217
+ .loading-dots::after { content: ''; animation: dots 1.5s infinite; }
218
+
219
+ .help-text { font-size: 0.78rem; color: #A8A39A; margin-top: 4px; margin-bottom: 2px; }
220
+
221
+ /* 히스토리 그래프 */
222
+ .history-bar-wrap { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; }
223
+ .history-bar-label { font-size: 0.85rem; width: 100px; color: #555; flex-shrink: 0; }
224
+ .history-bar-track { flex: 1; background: #F0EBE3; border-radius: 8px; height: 24px; overflow: hidden; }
225
+ .history-bar-fill {
226
+ height: 100%; border-radius: 8px;
227
+ display: flex; align-items: center; padding-left: 10px;
228
+ font-size: 0.75rem; font-weight: 700; color: white;
229
+ transition: width 0.6s ease; min-width: 36px;
230
+ }
231
+ .history-count { font-size: 0.82rem; color: #888780; min-width: 28px; text-align: right; }
232
+
233
+ .empty-state { text-align: center; padding: 2rem; color: #A8A39A; font-size: 0.9rem; }
234
+ .empty-state .empty-icon { font-size: 2.5rem; display: block; margin-bottom: 10px; }
235
+ </style>
236
+ </head>
237
+ <body>
238
+ <div class="container">
239
+
240
+ <div class="app-header">
241
+ <h1>🎵 감정 일기 음악 추천</h1>
242
+ <p>오늘의 감정을 적으면, 딱 맞는 음악을 찾아드려요</p>
243
+ </div>
244
+
245
+ <div id="onboardingSection">
246
+ <div class="card">
247
+ <h2>👋 처음 오셨군요!</h2>
248
+ <p style="font-size:0.88rem; color:#888780;">취향을 알려주시면 더 정확한 추천을 해드릴게요.</p>
249
+
250
+ <label class="field-label">사용자 ID</label>
251
+ <input type="text" id="userId" placeholder="예: user_123" value="user_123">
252
+
253
+ <label class="field-label">🎤 좋아하는 아티스트</label>
254
+ <p class="help-text">쉼표(,)로 구분 (예: IU, Radiohead, 뉴진스)</p>
255
+ <input type="text" id="favArtists" placeholder="아티스트 입력">
256
+
257
+ <label class="field-label">🏷️ 좋아하는 장르</label>
258
+ <p class="help-text">영어로 좋아하는 장르를 입력해주세요! (예: k-pop, lo-fi)</p>
259
+ <div class="autocomplete-wrapper">
260
+ <input type="text" id="tagInput" placeholder=" 장르 검색..." onkeyup="searchTags()" autocomplete="off">
261
+ <div id="tagSuggestions" class="suggestions"></div>
262
+ </div>
263
+ <div id="selectedTags" class="pill-container"></div>
264
+
265
+ <button class="btn-primary" onclick="saveProfile()">프로필 저장하고 시작하기 →</button>
266
+ </div>
267
+ </div>
268
+
269
+ <div id="mainSection" class="hidden">
270
+
271
+ <div class="tab-bar">
272
+ <button class="tab-btn active" id="tab-diary" onclick="switchTab('diary')">📝 일기 쓰기</button>
273
+ <button class="tab-btn" id="tab-result" onclick="switchTab('result')" disabled>🎶 추천 결과</button>
274
+ <button class="tab-btn" id="tab-history" onclick="switchTab('history')">📊 감정 기록</button>
275
+ <button class="tab-btn" id="tab-fav" onclick="switchTab('fav')">💖 좋아요 한 곡</button>
276
+ </div>
277
+
278
+ <div id="tabDiary">
279
+ <div class="card">
280
+ <h2>📝 오늘의 일기</h2>
281
+ <textarea id="diaryText" placeholder="오늘 하루는 어땠나요? 솔직하게 적어주세요.&#10;&#10;예: 오늘 왠지 공허하고 비가 오는 느낌이었어..."></textarea>
282
+
283
+ <label class="field-label" style="margin-top:1.25rem;">🎧 음악 방향</label>
284
+ <div class="intent-grid">
285
+ <div class="intent-card selected" id="intentMatch" onclick="selectIntent('match')">
286
+ <div class="intent-icon">🌊</div>
287
+ <div class="intent-title">감정에 빠지기</div>
288
+ <div class="intent-sub">지금 기분 그대로</div>
289
+ </div>
290
+ <div class="intent-card" id="intentShift" onclick="selectIntent('shift')">
291
+ <div class="intent-icon">🌤️</div>
292
+ <div class="intent-title">기분 전환</div>
293
+ <div class="intent-sub">다른 감정으로</div>
294
+ </div>
295
+ </div>
296
+
297
+ <button class="btn-primary" onclick="getRecommendation()">🎵 음악 추천받기</button>
298
+
299
+ <div id="loading" class="hidden">
300
+ <div class="loading-area">감정을 분석하고 있어요<span class="loading-dots"></span></div>
301
+ </div>
302
+ </div>
303
+ </div>
304
+
305
+ <div id="tabResult" class="hidden">
306
+ <div id="emotionBanner" class="emotion-banner theme-default">
307
+ <span class="emotion-emoji" id="emotionEmoji">🎵</span>
308
+ <div class="emotion-label" id="emotionLabel">-</div>
309
+ <div class="emotion-conf" id="emotionConf"></div>
310
+ </div>
311
+ <div class="card">
312
+ <h2 style="margin-bottom:0.75rem;">🎶 추천 플레이리스트</h2>
313
+ <div id="trackList"></div>
314
+ </div>
315
+ <button class="btn-secondary" onclick="resetDiary()">✏️ 새로운 일기 쓰기</button>
316
+ </div>
317
+
318
+ <div id="tabHistory" class="hidden">
319
+ <div class="card">
320
+ <h2>📊 내 감정 기록</h2>
321
+ <p style="font-size:0.82rem; color:#888780; margin-bottom:1.25rem;">일기를 쓸 때마다 감정이 자동으로 기록돼요</p>
322
+ <div id="historyChart"></div>
323
+ </div>
324
+ </div>
325
+
326
+ <div id="tabFav" class="hidden">
327
+ <div class="card">
328
+ <h2>💖 내가 하트 누른 곡들</h2>
329
+ <p style="font-size:0.82rem; color:#888780; margin-bottom:1.25rem;">그동안 하트를 눌러 보관한 음악들이에요</p>
330
+ <div id="favTrackList"></div>
331
+ </div>
332
+ </div>
333
+
334
+ </div>
335
+ </div>
336
+
337
+ <script>
338
+ let currentUser = "guest";
339
+ let selectedIntent = "match";
340
+ let selectedTagsArray = [];
341
+ let searchTimeout = null;
342
+
343
+ const EMOTION_THEMES = {
344
+ "분노 (Anger)": { theme: "theme-anger", emoji: "🔥", color: "#E05555" },
345
+ "슬픔 (Sadness)": { theme: "theme-sadness", emoji: "💧", color: "#5577DD" },
346
+ "불안 (Anxiety)": { theme: "theme-anxiety", emoji: "😰", color: "#8855CC" },
347
+ "상처 (Hurt)": { theme: "theme-hurt", emoji: "💔", color: "#CC5588" },
348
+ "당황 (Embarrassment)": { theme: "theme-embarrassment", emoji: "😳", color: "#BBAA22" },
349
+ "기쁨 (Joy)": { theme: "theme-joy", emoji: "✨", color: "#33AA55" },
350
+ };
351
+
352
+ // switchTab 함수 수정 완료 ('fav' 인식)
353
+ function switchTab(tab) {
354
+ ['diary','result','history','fav'].forEach(t => {
355
+ document.getElementById(`tab-${t}`).classList.toggle('active', t === tab);
356
+ document.getElementById(`tab${t.charAt(0).toUpperCase()+t.slice(1)}`).classList.toggle('hidden', t !== tab);
357
+ });
358
+ if (tab === 'history') loadHistoryChart();
359
+ if (tab === 'fav') loadFavoriteTracks();
360
+ }
361
+
362
+ function selectIntent(intent) {
363
+ selectedIntent = intent;
364
+ document.getElementById('intentMatch').classList.toggle('selected', intent === 'match');
365
+ document.getElementById('intentShift').classList.toggle('selected', intent === 'shift');
366
+ }
367
+
368
+ async function searchTags() {
369
+ clearTimeout(searchTimeout);
370
+ const q = document.getElementById('tagInput').value;
371
+ if (q.length < 1) { document.getElementById('tagSuggestions').innerHTML = ""; return; }
372
+ searchTimeout = setTimeout(async () => {
373
+ try {
374
+ const res = await fetch(`/api/tags/autocomplete?q=${q}`);
375
+ const data = await res.json();
376
+ const container = document.getElementById('tagSuggestions');
377
+ container.innerHTML = "";
378
+ (data.tags || []).forEach(t => {
379
+ const div = document.createElement('div');
380
+ div.innerText = t;
381
+ div.onclick = () => addTag(t);
382
+ container.appendChild(div);
383
+ });
384
+ } catch(e) {}
385
+ }, 300);
386
+ }
387
+
388
+ function addTag(tag) {
389
+ if (!selectedTagsArray.includes(tag)) { selectedTagsArray.push(tag); renderTags(); }
390
+ document.getElementById('tagInput').value = '';
391
+ document.getElementById('tagSuggestions').innerHTML = '';
392
+ }
393
+
394
+ function renderTags() {
395
+ document.getElementById('selectedTags').innerHTML = selectedTagsArray.map(t =>
396
+ `<div class="pill">${t} <span class="remove" onclick="removeTag('${t}')">✕</span></div>`
397
+ ).join('');
398
+ }
399
+
400
+ function removeTag(tag) {
401
+ selectedTagsArray = selectedTagsArray.filter(t => t !== tag);
402
+ renderTags();
403
+ }
404
+
405
+ async function saveProfile() {
406
+ const userId = document.getElementById('userId').value;
407
+ if (!userId) { alert("사용자 ID를 입력해주세요!"); return; }
408
+ const artists = document.getElementById('favArtists').value.split(',').map(s=>s.trim()).filter(s=>s);
409
+
410
+ try {
411
+ await fetch('/api/users/onboard', {
412
+ method: 'POST', headers: {'Content-Type':'application/json'},
413
+ body: JSON.stringify({ user_id: userId, favorite_artists: artists, favorite_genres: [], default_tags: selectedTagsArray })
414
+ });
415
+ currentUser = userId;
416
+ document.getElementById('onboardingSection').classList.add('hidden');
417
+ document.getElementById('mainSection').classList.remove('hidden');
418
+ } catch(e) { alert("서버 연결에 실패했습니다."); }
419
+ }
420
+
421
+ async function getRecommendation() {
422
+ const text = document.getElementById('diaryText').value;
423
+ if (!text.trim()) { alert("일기를 먼저 작성해주세요!"); return; }
424
+
425
+ document.getElementById('loading').classList.remove('hidden');
426
+
427
+ try {
428
+ const res = await fetch('/recommend', {
429
+ method: 'POST', headers: {'Content-Type':'application/json'},
430
+ body: JSON.stringify({ user_id: currentUser, text, intent: selectedIntent })
431
+ });
432
+ const data = await res.json();
433
+ document.getElementById('loading').classList.add('hidden');
434
+
435
+ const emotionInfo = EMOTION_THEMES[data.emotion] || { theme: 'theme-default', emoji: '🎵', color: '#D4A96A' };
436
+ const banner = document.getElementById('emotionBanner');
437
+ banner.className = `emotion-banner ${emotionInfo.theme}`;
438
+ document.getElementById('emotionEmoji').innerText = emotionInfo.emoji;
439
+ document.getElementById('emotionLabel').innerText = data.emotion;
440
+ document.getElementById('emotionConf').innerText = `신뢰도 ${(data.confidence * 100).toFixed(1)}%`;
441
+
442
+ const trackList = document.getElementById('trackList');
443
+ trackList.innerHTML = "";
444
+ data.tracks.forEach((track, i) => {
445
+ const escTitle = track.title.replace(/'/g,"\\'");
446
+ const escArtist = track.artist.replace(/'/g,"\\'");
447
+ const escUrl = track.url.replace(/'/g,"\\'");
448
+ const escEmotion = data.emotion.replace(/'/g,"\\'");
449
+ trackList.innerHTML += `
450
+ <div class="track-card">
451
+ <div class="track-num">${i+1}</div>
452
+ <div class="track-info">
453
+ <a href="${track.url}" target="_blank">${track.title}</a>
454
+ <div class="artist">🎤 ${track.artist}</div>
455
+ </div>
456
+ <button class="like-btn" id="like-${i}" onclick="likeTrack('${escTitle}','${escArtist}','${escUrl}','${escEmotion}',${i})" title="좋아요">♡</button>
457
+ </div>`;
458
+ });
459
+
460
+ // 추천이 완료되면 추천 결과 탭 disabled 해제하고 전환
461
+ document.getElementById('tab-result').disabled = false;
462
+ switchTab('result');
463
+
464
+ } catch(e) {
465
+ document.getElementById('loading').classList.add('hidden');
466
+ alert("오류가 발생했습니다. 백엔드가 실행 중인지 확인해주세요.");
467
+ }
468
+ }
469
+
470
+ async function loadHistoryChart() {
471
+ const chart = document.getElementById('historyChart');
472
+ chart.innerHTML = '<div class="loading-area">불러오는 중<span class="loading-dots"></span></div>';
473
+ try {
474
+ const res = await fetch(`/api/users/history/stats?user_id=${currentUser}`);
475
+ if (!res.ok) throw new Error();
476
+ const data = await res.json();
477
+ const counts = data.emotion_counts || {};
478
+
479
+ if (Object.keys(counts).length === 0) {
480
+ chart.innerHTML = `<div class="empty-state"><span class="empty-icon">📝</span>아직 기록이 없어요!<br>일기를 쓰면 감정이 자동으로 기록돼요</div>`;
481
+ return;
482
+ }
483
+
484
+ const max = Math.max(...Object.values(counts));
485
+ const emotionMeta = {
486
+ "분노 (Anger)": { emoji: "🔥", color: "#E05555" },
487
+ "슬픔 (Sadness)": { emoji: "💧", color: "#5577DD" },
488
+ "불안 (Anxiety)": { emoji: "😰", color: "#8855CC" },
489
+ "상처 (Hurt)": { emoji: "💔", color: "#CC5588" },
490
+ "당황 (Embarrassment)": { emoji: "😳", color: "#BBAA22" },
491
+ "기쁨 (Joy)": { emoji: "✨", color: "#33AA55" },
492
+ };
493
+
494
+ chart.innerHTML = Object.entries(counts).sort((a,b) => b[1]-a[1]).map(([emotion, count]) => {
495
+ const meta = emotionMeta[emotion] || { emoji: "🎵", color: "#D4A96A" };
496
+ const pct = Math.round((count / max) * 100);
497
+ return `
498
+ <div class="history-bar-wrap">
499
+ <div class="history-bar-label">${meta.emoji} ${emotion.split(' ')[0]}</div>
500
+ <div class="history-bar-track">
501
+ <div class="history-bar-fill" style="width:${pct}%; background:${meta.color};">${count}번</div>
502
+ </div>
503
+ <div class="history-count">${count}</div>
504
+ </div>`;
505
+ }).join('');
506
+ } catch(e) {
507
+ chart.innerHTML = `<div class="empty-state"><span class="empty-icon">😅</span>불러오기 실패. 백엔드 확인해주세요.</div>`;
508
+ }
509
+ }
510
+
511
+ function resetDiary() {
512
+ document.getElementById('diaryText').value = '';
513
+ switchTab('diary');
514
+ }
515
+
516
+ async function likeTrack(title, artist, url, emotion, idx) {
517
+ const btn = document.getElementById(`like-${idx}`);
518
+ btn.classList.add('liked');
519
+ btn.innerText = '♥';
520
+ try {
521
+ await fetch('/api/users/history', {
522
+ method: 'POST', headers: {'Content-Type':'application/json'},
523
+ body: JSON.stringify({ user_id: currentUser, track_title: title, track_artist: artist, track_url: url, duration: 0, emotion_state: emotion, action: "liked" })
524
+ });
525
+ } catch(e) { console.error("좋아요 저장 실패", e); }
526
+ }
527
+
528
+ // 하트 누른 곡들을 리스트로 그려주는 함수 추가 완료
529
+ async function loadFavoriteTracks() {
530
+ const container = document.getElementById('favTrackList');
531
+ container.innerHTML = '<div class="loading-area">불러오는 중<span class="loading-dots"></span></div>';
532
+
533
+ try {
534
+ const res = await fetch(`/api/users/favorites?user_id=${currentUser}`);
535
+ if (!res.ok) throw new Error();
536
+ const data = await res.json();
537
+ const favs = data.favorites || [];
538
+
539
+ if (favs.length === 0) {
540
+ container.innerHTML = `<div class="empty-state"><span class="empty-icon">🎵</span>아직 하트를 누른 곡이 없어요!<br>추천 결과에서 마음에 드는 곡에 하트를 눌러보세요.</div>`;
541
+ return;
542
+ }
543
+
544
+ container.innerHTML = favs.map((track, i) => `
545
+ <div class="track-card">
546
+ <div class="track-num" style="color: #E8A96A;">💖</div>
547
+ <div class="track-info">
548
+ <a href="${track.url}" target="_blank">${track.title}</a>
549
+ <div class="artist">🎤 ${track.artist} <span style="font-size:0.75rem; color:#D4845A; margin-left:6px;">(${track.emotion.split(' ')[0]})</span></div>
550
+ </div>
551
+ </div>
552
+ `).join('');
553
+ } catch(e) {
554
+ container.innerHTML = `<div class="empty-state"><span class="empty-icon">😅</span>불러오기 실패. 백엔드를 확인해주세요.</div>`;
555
+ }
556
+ }
557
+ </script>
558
+ </body>
559
+ </html>
models/fp32/config.json ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "add_cross_attention": false,
3
+ "architectures": [
4
+ "ElectraForSequenceClassification"
5
+ ],
6
+ "attention_probs_dropout_prob": 0.1,
7
+ "bos_token_id": null,
8
+ "classifier_dropout": null,
9
+ "dtype": "float32",
10
+ "embedding_size": 768,
11
+ "eos_token_id": null,
12
+ "hidden_act": "gelu",
13
+ "hidden_dropout_prob": 0.1,
14
+ "hidden_size": 768,
15
+ "id2label": {
16
+ "0": "LABEL_0",
17
+ "1": "LABEL_1",
18
+ "2": "LABEL_2",
19
+ "3": "LABEL_3",
20
+ "4": "LABEL_4",
21
+ "5": "LABEL_5"
22
+ },
23
+ "initializer_range": 0.02,
24
+ "intermediate_size": 3072,
25
+ "is_decoder": false,
26
+ "label2id": {
27
+ "LABEL_0": 0,
28
+ "LABEL_1": 1,
29
+ "LABEL_2": 2,
30
+ "LABEL_3": 3,
31
+ "LABEL_4": 4,
32
+ "LABEL_5": 5
33
+ },
34
+ "layer_norm_eps": 1e-12,
35
+ "max_position_embeddings": 512,
36
+ "model_type": "electra",
37
+ "num_attention_heads": 12,
38
+ "num_hidden_layers": 12,
39
+ "pad_token_id": 3,
40
+ "summary_activation": "gelu",
41
+ "summary_last_dropout": 0.1,
42
+ "summary_type": "first",
43
+ "summary_use_proj": true,
44
+ "tie_word_embeddings": true,
45
+ "tokenizer_class": "PreTrainedTokenizerFast",
46
+ "transformers_version": "5.0.0",
47
+ "type_vocab_size": 2,
48
+ "use_cache": true,
49
+ "vocab_size": 30000
50
+ }
models/fp32/model.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:50eec0c758b53ff9a2b9ab289b45cd4426ae03f16e4553111ba6cde7aec0a4f5
3
+ size 436367936
models/onnx_model_fp32/config.json ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "architectures": [
3
+ "ElectraForSequenceClassification"
4
+ ],
5
+ "attention_probs_dropout_prob": 0.1,
6
+ "classifier_dropout": null,
7
+ "dtype": "float32",
8
+ "embedding_size": 768,
9
+ "hidden_act": "gelu",
10
+ "hidden_dropout_prob": 0.1,
11
+ "hidden_size": 768,
12
+ "id2label": {
13
+ "0": "LABEL_0",
14
+ "1": "LABEL_1",
15
+ "2": "LABEL_2",
16
+ "3": "LABEL_3",
17
+ "4": "LABEL_4",
18
+ "5": "LABEL_5"
19
+ },
20
+ "initializer_range": 0.02,
21
+ "intermediate_size": 3072,
22
+ "label2id": {
23
+ "LABEL_0": 0,
24
+ "LABEL_1": 1,
25
+ "LABEL_2": 2,
26
+ "LABEL_3": 3,
27
+ "LABEL_4": 4,
28
+ "LABEL_5": 5
29
+ },
30
+ "layer_norm_eps": 1e-12,
31
+ "max_position_embeddings": 512,
32
+ "model_type": "electra",
33
+ "num_attention_heads": 12,
34
+ "num_hidden_layers": 12,
35
+ "pad_token_id": 3,
36
+ "position_embedding_type": "absolute",
37
+ "summary_activation": "gelu",
38
+ "summary_last_dropout": 0.1,
39
+ "summary_type": "first",
40
+ "summary_use_proj": true,
41
+ "tokenizer_class": "PreTrainedTokenizerFast",
42
+ "transformers_version": "4.57.6",
43
+ "type_vocab_size": 2,
44
+ "use_cache": true,
45
+ "vocab_size": 30000
46
+ }
models/onnx_model_fp32/model.onnx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:e30f7ee46f33df92a8bde3adc05af9876457b2a7ac78fc777b05bd438fac60c3
3
+ size 436544001
models/onnx_model_int8/config.json ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "architectures": [
3
+ "ElectraForSequenceClassification"
4
+ ],
5
+ "attention_probs_dropout_prob": 0.1,
6
+ "classifier_dropout": null,
7
+ "dtype": "float32",
8
+ "embedding_size": 768,
9
+ "hidden_act": "gelu",
10
+ "hidden_dropout_prob": 0.1,
11
+ "hidden_size": 768,
12
+ "id2label": {
13
+ "0": "LABEL_0",
14
+ "1": "LABEL_1",
15
+ "2": "LABEL_2",
16
+ "3": "LABEL_3",
17
+ "4": "LABEL_4",
18
+ "5": "LABEL_5"
19
+ },
20
+ "initializer_range": 0.02,
21
+ "intermediate_size": 3072,
22
+ "label2id": {
23
+ "LABEL_0": 0,
24
+ "LABEL_1": 1,
25
+ "LABEL_2": 2,
26
+ "LABEL_3": 3,
27
+ "LABEL_4": 4,
28
+ "LABEL_5": 5
29
+ },
30
+ "layer_norm_eps": 1e-12,
31
+ "max_position_embeddings": 512,
32
+ "model_type": "electra",
33
+ "num_attention_heads": 12,
34
+ "num_hidden_layers": 12,
35
+ "pad_token_id": 3,
36
+ "position_embedding_type": "absolute",
37
+ "summary_activation": "gelu",
38
+ "summary_last_dropout": 0.1,
39
+ "summary_type": "first",
40
+ "summary_use_proj": true,
41
+ "tokenizer_class": "PreTrainedTokenizerFast",
42
+ "transformers_version": "4.57.6",
43
+ "type_vocab_size": 2,
44
+ "use_cache": true,
45
+ "vocab_size": 30000
46
+ }
models/onnx_model_int8/model.onnx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:c4b5047ec02ccf96140d28d34414f5506d3f5f69870bfa6d1efbc166d192036a
3
+ size 109873286
models/onnx_model_int8/ort_config.json ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "one_external_file": true,
3
+ "opset": null,
4
+ "optimization": {},
5
+ "quantization": {
6
+ "activations_dtype": "QUInt8",
7
+ "activations_symmetric": false,
8
+ "format": "QOperator",
9
+ "is_static": false,
10
+ "mode": "IntegerOps",
11
+ "nodes_to_exclude": [],
12
+ "nodes_to_quantize": [],
13
+ "operators_to_quantize": [
14
+ "Conv",
15
+ "MatMul",
16
+ "Attention",
17
+ "LSTM",
18
+ "Gather",
19
+ "Transpose",
20
+ "EmbedLayerNormalization"
21
+ ],
22
+ "per_channel": false,
23
+ "qdq_add_pair_to_weight": false,
24
+ "qdq_dedicated_pair": false,
25
+ "qdq_op_type_per_channel_support_to_axis": {
26
+ "MatMul": 1
27
+ },
28
+ "reduce_range": false,
29
+ "weights_dtype": "QUInt8",
30
+ "weights_symmetric": true
31
+ },
32
+ "use_external_data_format": false
33
+ }
requirements.txt ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ transformers
2
+ torch
3
+ optimum[onnxruntime]
4
+ fastapi
5
+ uvicorn
6
+ pydantic
7
+ python-dotenv
8
+ httpx
9
+ sqlalchemy
tokenizer/tokenizer.json ADDED
The diff for this file is too large to render. See raw diff
 
tokenizer/tokenizer_config.json ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "backend": "tokenizers",
3
+ "cls_token": "[CLS]",
4
+ "do_basic_tokenize": true,
5
+ "do_lower_case": false,
6
+ "is_local": false,
7
+ "mask_token": "[MASK]",
8
+ "model_max_length": 512,
9
+ "never_split": null,
10
+ "pad_token": "[PAD]",
11
+ "sep_token": "[SEP]",
12
+ "strip_accents": null,
13
+ "tokenize_chinese_chars": true,
14
+ "tokenizer_class": "PreTrainedTokenizerFast",
15
+ "unk_token": "[UNK]"
16
+ }