Spaces:
Sleeping
Sleeping
Upload 14 files
Browse files- Dockerfile +12 -0
- app.py +546 -0
- database.py +48 -0
- index.html +559 -0
- models/fp32/config.json +50 -0
- models/fp32/model.safetensors +3 -0
- models/onnx_model_fp32/config.json +46 -0
- models/onnx_model_fp32/model.onnx +3 -0
- models/onnx_model_int8/config.json +46 -0
- models/onnx_model_int8/model.onnx +3 -0
- models/onnx_model_int8/ort_config.json +33 -0
- requirements.txt +9 -0
- tokenizer/tokenizer.json +0 -0
- tokenizer/tokenizer_config.json +16 -0
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="오늘 하루는 어땠나요? 솔직하게 적어주세요. 예: 오늘 왠지 공허하고 비가 오는 느낌이었어..."></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 |
+
}
|