@hyperspell/openclaw-hyperspell 0.7.2 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -65
- package/client.ts +8 -2
- package/commands/setup.ts +1 -0
- package/config.ts +9 -3
- package/index.ts +0 -7
- package/openclaw.plugin.json +3 -0
- package/package.json +14 -7
- package/commands/wine.ts +0 -164
- package/lib/run-script.ts +0 -32
- package/sommeliagent/references/cross-domain-mappings.md +0 -63
- package/sommeliagent/scripts/auth.py +0 -222
- package/sommeliagent/scripts/history.py +0 -72
- package/sommeliagent/scripts/rate.py +0 -76
- package/sommeliagent/scripts/recommend.py +0 -777
- package/sommeliagent/scripts/test_recommend.py +0 -459
- package/sommeliagent/scripts/wine_db.py +0 -3224
- package/tools/sommelier.ts +0 -254
|
@@ -1,777 +0,0 @@
|
|
|
1
|
-
# /// script
|
|
2
|
-
# requires-python = ">=3.11"
|
|
3
|
-
# dependencies = ["httpx"]
|
|
4
|
-
# ///
|
|
5
|
-
"""
|
|
6
|
-
SommeliAgent — Wine recommendation engine.
|
|
7
|
-
|
|
8
|
-
Fetches Spotify listening data, maps music features to wine dimensions,
|
|
9
|
-
and outputs personalized recommendations with cross-domain explanations.
|
|
10
|
-
"""
|
|
11
|
-
|
|
12
|
-
import argparse
|
|
13
|
-
import json
|
|
14
|
-
import stat
|
|
15
|
-
import sys
|
|
16
|
-
import time
|
|
17
|
-
from dataclasses import dataclass, field, asdict
|
|
18
|
-
from pathlib import Path
|
|
19
|
-
|
|
20
|
-
import httpx
|
|
21
|
-
|
|
22
|
-
from wine_db import WineProfile, Wine, WINE_DB # noqa: E402
|
|
23
|
-
|
|
24
|
-
CONFIG_DIR = Path.home() / ".sommeliagent"
|
|
25
|
-
TOKEN_FILE = CONFIG_DIR / "token.json"
|
|
26
|
-
RATINGS_FILE = CONFIG_DIR / "ratings.json"
|
|
27
|
-
CACHE_FILE = CONFIG_DIR / "profile_cache.json"
|
|
28
|
-
SPOTIFY_API = "https://api.spotify.com/v1"
|
|
29
|
-
CACHE_TTL_SECONDS = 3600 # 1 hour
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
# ──────────────────────────────────────────────
|
|
33
|
-
# Types
|
|
34
|
-
# ──────────────────────────────────────────────
|
|
35
|
-
|
|
36
|
-
@dataclass
|
|
37
|
-
class MusicProfile:
|
|
38
|
-
avg_valence: float = 0.0
|
|
39
|
-
avg_energy: float = 0.0
|
|
40
|
-
avg_danceability: float = 0.0
|
|
41
|
-
avg_acousticness: float = 0.0
|
|
42
|
-
avg_tempo: float = 0.0
|
|
43
|
-
avg_complexity: float = 0.0
|
|
44
|
-
obscurity_score: float = 0.0
|
|
45
|
-
genre_distribution: dict[str, float] = field(default_factory=dict)
|
|
46
|
-
mood_label: str = ""
|
|
47
|
-
top_artists: list[str] = field(default_factory=list)
|
|
48
|
-
top_tracks: list[str] = field(default_factory=list)
|
|
49
|
-
has_audio_features: bool = True
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
@dataclass
|
|
53
|
-
class MusicWineConnection:
|
|
54
|
-
music_signal: str
|
|
55
|
-
wine_signal: str
|
|
56
|
-
explanation: str
|
|
57
|
-
strength: float
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
@dataclass
|
|
61
|
-
class Recommendation:
|
|
62
|
-
wine: Wine
|
|
63
|
-
score: float
|
|
64
|
-
reasoning: str
|
|
65
|
-
connections: list[MusicWineConnection]
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
# ──────────────────────────────────────────────
|
|
69
|
-
# Spotify API (with retry + caching)
|
|
70
|
-
# ──────────────────────────────────────────────
|
|
71
|
-
|
|
72
|
-
def get_access_token() -> str:
|
|
73
|
-
"""Get a valid Spotify access token. Delegates refresh to auth module."""
|
|
74
|
-
# Import auth module from same directory
|
|
75
|
-
sys.path.insert(0, str(Path(__file__).parent))
|
|
76
|
-
from auth import get_access_token as _refresh_token, load_token
|
|
77
|
-
|
|
78
|
-
token = _refresh_token()
|
|
79
|
-
if token:
|
|
80
|
-
return token
|
|
81
|
-
|
|
82
|
-
# Fallback: use stored access token directly (may be expired)
|
|
83
|
-
token_data = load_token()
|
|
84
|
-
if token_data and token_data.get("access_token"):
|
|
85
|
-
return token_data["access_token"]
|
|
86
|
-
|
|
87
|
-
print("ERROR: Not authenticated. Run the auth script first.", file=sys.stderr)
|
|
88
|
-
sys.exit(1)
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
def spotify_get(token: str, endpoint: str, params: dict | None = None, max_retries: int = 2) -> dict:
|
|
92
|
-
"""GET with retry on 429 (rate limit) and transient errors."""
|
|
93
|
-
for attempt in range(max_retries + 1):
|
|
94
|
-
resp = httpx.get(
|
|
95
|
-
f"{SPOTIFY_API}{endpoint}",
|
|
96
|
-
headers={"Authorization": f"Bearer {token}"},
|
|
97
|
-
params=params or {},
|
|
98
|
-
)
|
|
99
|
-
if resp.status_code == 401:
|
|
100
|
-
print("ERROR: Spotify token expired. Re-run auth script.", file=sys.stderr)
|
|
101
|
-
sys.exit(1)
|
|
102
|
-
if resp.status_code == 429:
|
|
103
|
-
retry_after = int(resp.headers.get("Retry-After", 2))
|
|
104
|
-
if attempt < max_retries:
|
|
105
|
-
print(f"Rate limited. Waiting {retry_after}s...", file=sys.stderr)
|
|
106
|
-
time.sleep(retry_after)
|
|
107
|
-
continue
|
|
108
|
-
if resp.status_code >= 500 and attempt < max_retries:
|
|
109
|
-
time.sleep(2 ** attempt)
|
|
110
|
-
continue
|
|
111
|
-
resp.raise_for_status()
|
|
112
|
-
return resp.json()
|
|
113
|
-
resp.raise_for_status()
|
|
114
|
-
return {} # unreachable
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
def load_cached_profile() -> MusicProfile | None:
|
|
118
|
-
if not CACHE_FILE.exists():
|
|
119
|
-
return None
|
|
120
|
-
try:
|
|
121
|
-
data = json.loads(CACHE_FILE.read_text())
|
|
122
|
-
if time.time() - data.get("_cached_at", 0) > CACHE_TTL_SECONDS:
|
|
123
|
-
return None
|
|
124
|
-
del data["_cached_at"]
|
|
125
|
-
return MusicProfile(**data)
|
|
126
|
-
except Exception:
|
|
127
|
-
return None
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
def save_cached_profile(profile: MusicProfile) -> None:
|
|
131
|
-
data = asdict(profile)
|
|
132
|
-
data["_cached_at"] = time.time()
|
|
133
|
-
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
134
|
-
tmp_file = CACHE_FILE.with_suffix(".tmp")
|
|
135
|
-
tmp_file.write_text(json.dumps(data, indent=2))
|
|
136
|
-
tmp_file.chmod(stat.S_IRUSR | stat.S_IWUSR)
|
|
137
|
-
tmp_file.replace(CACHE_FILE)
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
def build_music_profile(token: str) -> MusicProfile:
|
|
141
|
-
# Check cache first
|
|
142
|
-
cached = load_cached_profile()
|
|
143
|
-
if cached:
|
|
144
|
-
return cached
|
|
145
|
-
|
|
146
|
-
# Fetch top tracks and artists (always available)
|
|
147
|
-
top_tracks_data = spotify_get(token, "/me/top/tracks", {"time_range": "medium_term", "limit": 50})
|
|
148
|
-
top_artists_data = spotify_get(token, "/me/top/artists", {"time_range": "medium_term", "limit": 50})
|
|
149
|
-
|
|
150
|
-
tracks = top_tracks_data.get("items", [])
|
|
151
|
-
artists = top_artists_data.get("items", [])
|
|
152
|
-
|
|
153
|
-
if not tracks:
|
|
154
|
-
print("ERROR: No listening data found. Listen to more music on Spotify first!", file=sys.stderr)
|
|
155
|
-
sys.exit(1)
|
|
156
|
-
|
|
157
|
-
# Try audio features (may be deprecated/restricted)
|
|
158
|
-
features: list[dict] = []
|
|
159
|
-
track_ids = [t["id"] for t in tracks]
|
|
160
|
-
try:
|
|
161
|
-
features_data = spotify_get(token, "/audio-features", {"ids": ",".join(track_ids[:100])})
|
|
162
|
-
features = [f for f in features_data.get("audio_features", []) if f is not None]
|
|
163
|
-
except Exception as e:
|
|
164
|
-
print(f"Audio features unavailable (may be deprecated): {e}", file=sys.stderr)
|
|
165
|
-
print("Falling back to genre-based profiling.", file=sys.stderr)
|
|
166
|
-
|
|
167
|
-
profile = aggregate_profile(tracks, artists, features)
|
|
168
|
-
save_cached_profile(profile)
|
|
169
|
-
return profile
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
def aggregate_profile(tracks: list, artists: list, features: list) -> MusicProfile:
|
|
173
|
-
has_audio_features = len(features) > 0
|
|
174
|
-
n = len(features) or 1
|
|
175
|
-
|
|
176
|
-
if has_audio_features:
|
|
177
|
-
avg_valence = sum(f["valence"] for f in features) / n
|
|
178
|
-
avg_energy = sum(f["energy"] for f in features) / n
|
|
179
|
-
avg_danceability = sum(f["danceability"] for f in features) / n
|
|
180
|
-
avg_acousticness = sum(f["acousticness"] for f in features) / n
|
|
181
|
-
avg_tempo = sum(f["tempo"] for f in features) / n
|
|
182
|
-
|
|
183
|
-
avg_instrumentalness = sum(f["instrumentalness"] for f in features) / n
|
|
184
|
-
time_sigs = set(f["time_signature"] for f in features)
|
|
185
|
-
time_sig_variety = min(len(time_sigs) / 5, 1.0)
|
|
186
|
-
avg_complexity = avg_instrumentalness * 0.3 + time_sig_variety * 0.3 + (1 - avg_danceability) * 0.4
|
|
187
|
-
else:
|
|
188
|
-
# Fallback: estimate from genres
|
|
189
|
-
avg_valence, avg_energy, avg_danceability, avg_acousticness, avg_tempo, avg_complexity = (
|
|
190
|
-
estimate_features_from_genres(artists)
|
|
191
|
-
)
|
|
192
|
-
|
|
193
|
-
avg_popularity = sum(t.get("popularity", 50) for t in tracks) / (len(tracks) or 1)
|
|
194
|
-
obscurity_score = 1 - avg_popularity / 100
|
|
195
|
-
|
|
196
|
-
genre_counts: dict[str, int] = {}
|
|
197
|
-
for artist in artists:
|
|
198
|
-
for genre in artist.get("genres", []):
|
|
199
|
-
genre_counts[genre] = genre_counts.get(genre, 0) + 1
|
|
200
|
-
total_genres = sum(genre_counts.values()) or 1
|
|
201
|
-
genre_distribution = {g: c / total_genres for g, c in sorted(genre_counts.items(), key=lambda x: -x[1])}
|
|
202
|
-
|
|
203
|
-
mood_label = derive_mood(avg_valence, avg_energy)
|
|
204
|
-
|
|
205
|
-
top_artist_names = [a["name"] for a in artists[:10]]
|
|
206
|
-
top_track_names = [f"{t['name']} — {t['artists'][0]['name']}" for t in tracks[:10] if t.get('artists')]
|
|
207
|
-
|
|
208
|
-
return MusicProfile(
|
|
209
|
-
avg_valence=avg_valence,
|
|
210
|
-
avg_energy=avg_energy,
|
|
211
|
-
avg_danceability=avg_danceability,
|
|
212
|
-
avg_acousticness=avg_acousticness,
|
|
213
|
-
avg_tempo=avg_tempo,
|
|
214
|
-
avg_complexity=avg_complexity,
|
|
215
|
-
obscurity_score=obscurity_score,
|
|
216
|
-
genre_distribution=genre_distribution,
|
|
217
|
-
mood_label=mood_label,
|
|
218
|
-
top_artists=top_artist_names,
|
|
219
|
-
top_tracks=top_track_names,
|
|
220
|
-
has_audio_features=has_audio_features,
|
|
221
|
-
)
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
# ──────────────────────────────────────────────
|
|
225
|
-
# Genre-based feature estimation (fallback)
|
|
226
|
-
# ──────────────────────────────────────────────
|
|
227
|
-
|
|
228
|
-
# Genre archetypes: (valence, energy, danceability, acousticness, tempo_norm, complexity)
|
|
229
|
-
GENRE_ARCHETYPES: dict[str, tuple[float, float, float, float, float, float]] = {
|
|
230
|
-
# Electronic / Dance
|
|
231
|
-
"edm": (0.6, 0.8, 0.8, 0.1, 0.65, 0.2),
|
|
232
|
-
"house": (0.6, 0.7, 0.8, 0.1, 0.62, 0.3),
|
|
233
|
-
"techno": (0.3, 0.8, 0.7, 0.05, 0.65, 0.4),
|
|
234
|
-
"ambient": (0.3, 0.2, 0.2, 0.6, 0.40, 0.6),
|
|
235
|
-
"electronic": (0.5, 0.6, 0.6, 0.15, 0.60, 0.5),
|
|
236
|
-
"drum and bass": (0.4, 0.9, 0.6, 0.05, 0.85, 0.4),
|
|
237
|
-
"trip hop": (0.3, 0.4, 0.5, 0.3, 0.45, 0.6),
|
|
238
|
-
# Rock
|
|
239
|
-
"rock": (0.5, 0.7, 0.5, 0.3, 0.60, 0.4),
|
|
240
|
-
"indie rock": (0.4, 0.6, 0.5, 0.4, 0.55, 0.5),
|
|
241
|
-
"art rock": (0.3, 0.5, 0.4, 0.4, 0.50, 0.8),
|
|
242
|
-
"post-punk": (0.3, 0.6, 0.5, 0.3, 0.55, 0.6),
|
|
243
|
-
"shoegaze": (0.3, 0.5, 0.3, 0.3, 0.50, 0.6),
|
|
244
|
-
"post-rock": (0.3, 0.5, 0.2, 0.4, 0.50, 0.7),
|
|
245
|
-
"punk": (0.5, 0.9, 0.4, 0.3, 0.70, 0.2),
|
|
246
|
-
"metal": (0.3, 0.9, 0.3, 0.1, 0.70, 0.5),
|
|
247
|
-
"alternative": (0.4, 0.6, 0.5, 0.3, 0.55, 0.5),
|
|
248
|
-
"grunge": (0.3, 0.7, 0.4, 0.3, 0.55, 0.4),
|
|
249
|
-
"classic rock": (0.5, 0.7, 0.5, 0.3, 0.60, 0.4),
|
|
250
|
-
"progressive rock": (0.4, 0.6, 0.3, 0.3, 0.55, 0.9),
|
|
251
|
-
# Jazz / Classical / Complex
|
|
252
|
-
"jazz": (0.4, 0.4, 0.4, 0.7, 0.55, 0.9),
|
|
253
|
-
"classical": (0.4, 0.3, 0.2, 0.9, 0.50, 0.9),
|
|
254
|
-
"contemporary classical": (0.3, 0.3, 0.2, 0.8, 0.45, 0.9),
|
|
255
|
-
"experimental": (0.3, 0.5, 0.3, 0.4, 0.50, 0.9),
|
|
256
|
-
"avant-garde": (0.3, 0.5, 0.2, 0.4, 0.50, 0.9),
|
|
257
|
-
# Folk / Acoustic / Singer-Songwriter
|
|
258
|
-
"folk": (0.5, 0.3, 0.4, 0.8, 0.50, 0.4),
|
|
259
|
-
"singer-songwriter": (0.4, 0.3, 0.4, 0.7, 0.50, 0.4),
|
|
260
|
-
"acoustic": (0.5, 0.3, 0.4, 0.9, 0.50, 0.3),
|
|
261
|
-
"americana": (0.5, 0.4, 0.4, 0.7, 0.55, 0.4),
|
|
262
|
-
"country": (0.6, 0.5, 0.5, 0.6, 0.55, 0.3),
|
|
263
|
-
"bluegrass": (0.5, 0.5, 0.5, 0.9, 0.60, 0.5),
|
|
264
|
-
# Pop / R&B
|
|
265
|
-
"pop": (0.7, 0.6, 0.7, 0.2, 0.60, 0.2),
|
|
266
|
-
"indie pop": (0.6, 0.5, 0.6, 0.3, 0.55, 0.3),
|
|
267
|
-
"r&b": (0.5, 0.5, 0.7, 0.3, 0.55, 0.4),
|
|
268
|
-
"soul": (0.5, 0.5, 0.6, 0.5, 0.55, 0.5),
|
|
269
|
-
"funk": (0.7, 0.7, 0.8, 0.3, 0.55, 0.5),
|
|
270
|
-
"neo soul": (0.5, 0.4, 0.6, 0.4, 0.50, 0.6),
|
|
271
|
-
# Hip-Hop
|
|
272
|
-
"hip hop": (0.5, 0.6, 0.8, 0.1, 0.55, 0.3),
|
|
273
|
-
"rap": (0.5, 0.7, 0.7, 0.1, 0.55, 0.3),
|
|
274
|
-
"underground hip hop": (0.4, 0.5, 0.6, 0.2, 0.50, 0.5),
|
|
275
|
-
# World / Latin
|
|
276
|
-
"bossa nova": (0.6, 0.3, 0.6, 0.7, 0.55, 0.5),
|
|
277
|
-
"latin": (0.7, 0.7, 0.8, 0.3, 0.55, 0.3),
|
|
278
|
-
"afrobeat": (0.6, 0.7, 0.8, 0.3, 0.55, 0.6),
|
|
279
|
-
"reggae": (0.6, 0.5, 0.7, 0.4, 0.45, 0.3),
|
|
280
|
-
# Blues
|
|
281
|
-
"blues": (0.4, 0.5, 0.5, 0.6, 0.50, 0.5),
|
|
282
|
-
"delta blues": (0.3, 0.4, 0.4, 0.8, 0.50, 0.5),
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
# Default for unrecognized genres
|
|
286
|
-
_DEFAULT_ARCHETYPE = (0.5, 0.5, 0.5, 0.5, 0.55, 0.5)
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
def estimate_features_from_genres(
|
|
290
|
-
artists: list[dict],
|
|
291
|
-
) -> tuple[float, float, float, float, float, float]:
|
|
292
|
-
"""Estimate audio features from genre distribution when audio features API is unavailable."""
|
|
293
|
-
genre_weights: dict[str, float] = {}
|
|
294
|
-
for artist in artists:
|
|
295
|
-
for genre in artist.get("genres", []):
|
|
296
|
-
genre_weights[genre] = genre_weights.get(genre, 0) + 1
|
|
297
|
-
|
|
298
|
-
if not genre_weights:
|
|
299
|
-
v, e, d, a, t, c = _DEFAULT_ARCHETYPE
|
|
300
|
-
return (v, e, d, a, t * 200, c)
|
|
301
|
-
|
|
302
|
-
total = sum(genre_weights.values())
|
|
303
|
-
valence = energy = dance = acoustic = tempo = complexity = 0.0
|
|
304
|
-
|
|
305
|
-
for genre, count in genre_weights.items():
|
|
306
|
-
w = count / total
|
|
307
|
-
# Find best matching archetype (longest key match wins)
|
|
308
|
-
archetype = _DEFAULT_ARCHETYPE
|
|
309
|
-
best_len = 0
|
|
310
|
-
for key, vals in GENRE_ARCHETYPES.items():
|
|
311
|
-
if key in genre or genre in key:
|
|
312
|
-
if len(key) > best_len:
|
|
313
|
-
archetype = vals
|
|
314
|
-
best_len = len(key)
|
|
315
|
-
valence += archetype[0] * w
|
|
316
|
-
energy += archetype[1] * w
|
|
317
|
-
dance += archetype[2] * w
|
|
318
|
-
acoustic += archetype[3] * w
|
|
319
|
-
tempo += archetype[4] * w
|
|
320
|
-
complexity += archetype[5] * w
|
|
321
|
-
|
|
322
|
-
return valence, energy, dance, acoustic, tempo * 200, complexity
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
def derive_mood(valence: float, energy: float) -> str:
|
|
326
|
-
if valence > 0.6 and energy > 0.6:
|
|
327
|
-
return "euphoric"
|
|
328
|
-
if valence > 0.6 and energy < 0.4:
|
|
329
|
-
return "serene"
|
|
330
|
-
if valence < 0.4 and energy > 0.6:
|
|
331
|
-
return "intense"
|
|
332
|
-
if valence < 0.4 and energy < 0.4:
|
|
333
|
-
return "melancholic"
|
|
334
|
-
if valence > 0.5:
|
|
335
|
-
return "upbeat"
|
|
336
|
-
if energy > 0.5:
|
|
337
|
-
return "driven"
|
|
338
|
-
return "contemplative"
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
# ──────────────────────────────────────────────
|
|
342
|
-
# Cross-domain mapping
|
|
343
|
-
# ──────────────────────────────────────────────
|
|
344
|
-
|
|
345
|
-
def clamp01(n: float) -> float:
|
|
346
|
-
return max(0.0, min(1.0, n))
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
def weighted_avg(pairs: list[tuple[float, float]]) -> float:
|
|
350
|
-
total = sum(clamp01(v) * w for v, w in pairs)
|
|
351
|
-
weight_sum = sum(w for _, w in pairs)
|
|
352
|
-
return clamp01(total / weight_sum) if weight_sum else 0.0
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
def genre_affinity(genres: dict[str, float], keywords: list[str]) -> float:
|
|
356
|
-
"""How much of the genre distribution matches the given keywords (0-1).
|
|
357
|
-
Uses word-boundary-aware matching to avoid false positives like 'art' matching 'martial'."""
|
|
358
|
-
total = 0.0
|
|
359
|
-
for genre, weight in genres.items():
|
|
360
|
-
genre_tokens = genre.replace("-", " ").split()
|
|
361
|
-
genre_parts = set(genre_tokens)
|
|
362
|
-
for k in keywords:
|
|
363
|
-
k_tokens = k.replace("-", " ").split()
|
|
364
|
-
k_parts = set(k_tokens)
|
|
365
|
-
# Match if keyword parts are a subset of genre parts, or genre is substring of keyword
|
|
366
|
-
if k_parts <= genre_parts or genre in k:
|
|
367
|
-
total += weight
|
|
368
|
-
break
|
|
369
|
-
# Prefix matching: a keyword token is a prefix of a genre token or vice versa
|
|
370
|
-
if any(
|
|
371
|
-
gt.startswith(kt) or kt.startswith(gt)
|
|
372
|
-
for kt in k_tokens
|
|
373
|
-
for gt in genre_tokens
|
|
374
|
-
):
|
|
375
|
-
total += weight
|
|
376
|
-
break
|
|
377
|
-
return total
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
def music_to_wine_profile(m: MusicProfile) -> WineProfile:
|
|
381
|
-
# Genre signals (supplements audio features, serves as primary signal when unavailable)
|
|
382
|
-
g = m.genre_distribution
|
|
383
|
-
genre_dark = genre_affinity(g, ["metal", "punk", "goth", "dark", "doom", "black", "death", "grunge", "post-punk"])
|
|
384
|
-
genre_complex = genre_affinity(g, ["jazz", "classical", "prog", "experimental", "avant", "art rock", "contemporary"])
|
|
385
|
-
genre_acoustic = genre_affinity(g, ["folk", "acoustic", "singer-songwriter", "bluegrass", "americana", "chamber"])
|
|
386
|
-
genre_electronic = genre_affinity(g, ["edm", "house", "techno", "electronic", "synth", "drum and bass", "dubstep"])
|
|
387
|
-
genre_bright = genre_affinity(g, ["pop", "indie pop", "dance", "disco", "funk", "latin", "reggaeton", "k-pop"])
|
|
388
|
-
genre_earthy = genre_affinity(g, ["blues", "delta", "soul", "gospel", "country", "roots", "world", "afrobeat"])
|
|
389
|
-
|
|
390
|
-
# Normalize tempo to 0-1 (cap at 180 to handle DnB sensibly, floor at 60)
|
|
391
|
-
tempo_norm = clamp01((m.avg_tempo - 60) / 120) if m.avg_tempo > 0 else 0.5
|
|
392
|
-
|
|
393
|
-
return WineProfile(
|
|
394
|
-
body=weighted_avg([
|
|
395
|
-
(m.avg_energy, 0.4), (tempo_norm, 0.2), (1 - m.avg_acousticness, 0.15),
|
|
396
|
-
(genre_dark, 0.15), (genre_electronic, 0.1),
|
|
397
|
-
]),
|
|
398
|
-
sweetness=weighted_avg([
|
|
399
|
-
(m.avg_valence, 0.4), (m.avg_danceability, 0.2), (1 - m.avg_complexity, 0.1),
|
|
400
|
-
(genre_bright, 0.2), (1 - genre_dark, 0.1),
|
|
401
|
-
]),
|
|
402
|
-
tannin=weighted_avg([
|
|
403
|
-
(1 - m.avg_valence, 0.3), (m.avg_complexity, 0.2), (m.avg_energy, 0.2),
|
|
404
|
-
(genre_dark, 0.2), (genre_complex, 0.1),
|
|
405
|
-
]),
|
|
406
|
-
acidity=weighted_avg([
|
|
407
|
-
(m.avg_complexity, 0.3), (1 - m.avg_valence, 0.2), (m.obscurity_score, 0.2),
|
|
408
|
-
(genre_complex, 0.2), (genre_acoustic, 0.1),
|
|
409
|
-
]),
|
|
410
|
-
complexity=weighted_avg([
|
|
411
|
-
(m.avg_complexity, 0.3), (m.obscurity_score, 0.2), (1 - m.avg_danceability, 0.1),
|
|
412
|
-
(genre_complex, 0.3), (genre_earthy, 0.1),
|
|
413
|
-
]),
|
|
414
|
-
fruitiness=weighted_avg([
|
|
415
|
-
(m.avg_valence, 0.3), (m.avg_energy, 0.2), (1 - m.avg_complexity, 0.1),
|
|
416
|
-
(genre_bright, 0.25), (1 - genre_dark, 0.15),
|
|
417
|
-
]),
|
|
418
|
-
earthiness=weighted_avg([
|
|
419
|
-
(m.avg_acousticness, 0.3), (m.avg_complexity, 0.2), (m.obscurity_score, 0.2),
|
|
420
|
-
(genre_acoustic, 0.15), (genre_earthy, 0.15),
|
|
421
|
-
]),
|
|
422
|
-
spiciness=weighted_avg([
|
|
423
|
-
(m.avg_energy, 0.3), (1 - m.avg_valence, 0.2), (m.avg_complexity, 0.2),
|
|
424
|
-
(genre_dark, 0.15), (genre_electronic, 0.15),
|
|
425
|
-
]),
|
|
426
|
-
)
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
def score_wine_match(target: WineProfile, wine: Wine) -> float:
|
|
430
|
-
"""Score using squared differences to amplify spread between good and bad matches."""
|
|
431
|
-
weights = {
|
|
432
|
-
"body": 1.5, "sweetness": 1.2, "tannin": 1.0, "acidity": 1.0,
|
|
433
|
-
"complexity": 1.5, "fruitiness": 0.8, "earthiness": 0.8, "spiciness": 0.7,
|
|
434
|
-
}
|
|
435
|
-
total_weight = 0.0
|
|
436
|
-
weighted_sq_distance = 0.0
|
|
437
|
-
for dim, w in weights.items():
|
|
438
|
-
diff = getattr(target, dim) - getattr(wine.profile, dim)
|
|
439
|
-
weighted_sq_distance += (diff ** 2) * w
|
|
440
|
-
total_weight += w
|
|
441
|
-
# Squared distance max is 1.0 per dim, so normalize and convert to similarity
|
|
442
|
-
return 1 - (weighted_sq_distance / total_weight) ** 0.5
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
def normalize_scores(recs: list[tuple]) -> list[tuple]:
|
|
446
|
-
"""Rescale scores so best match = 95-99% and worst = proportionally lower."""
|
|
447
|
-
if len(recs) <= 1:
|
|
448
|
-
return recs
|
|
449
|
-
scores = [r[1] for r in recs]
|
|
450
|
-
lo, hi = min(scores), max(scores)
|
|
451
|
-
spread = hi - lo
|
|
452
|
-
if spread < 0.01:
|
|
453
|
-
return [(w, 0.90, c) for w, _, c in recs]
|
|
454
|
-
return [
|
|
455
|
-
(w, 0.60 + 0.38 * (s - lo) / spread, c)
|
|
456
|
-
for w, s, c in recs
|
|
457
|
-
]
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
def generate_connections(music: MusicProfile, wine: Wine) -> list[MusicWineConnection]:
|
|
461
|
-
"""Generate cross-domain connections. Uses graduated thresholds to ensure
|
|
462
|
-
most users get at least some connections (the entertainment value)."""
|
|
463
|
-
connections = []
|
|
464
|
-
g = music.genre_distribution
|
|
465
|
-
|
|
466
|
-
# Dark/brooding music → tannic wines (graduated threshold)
|
|
467
|
-
dark_score = (1 - music.avg_valence) * 0.5 + genre_affinity(g, ["post-punk", "metal", "goth", "dark", "doom", "grunge"]) * 0.5
|
|
468
|
-
if dark_score > 0.25 and wine.profile.tannin > 0.5:
|
|
469
|
-
connections.append(MusicWineConnection(
|
|
470
|
-
music_signal=f"Your music leans dark and introspective (valence: {music.avg_valence * 100:.0f}%)",
|
|
471
|
-
wine_signal="This wine has serious tannin structure",
|
|
472
|
-
explanation="You like your art with tension — this wine delivers that same brooding complexity",
|
|
473
|
-
strength=min(0.5 + dark_score, 0.95),
|
|
474
|
-
))
|
|
475
|
-
|
|
476
|
-
# Acoustic/organic → old-world wines (graduated)
|
|
477
|
-
acoustic_score = music.avg_acousticness * 0.5 + genre_affinity(g, ["folk", "acoustic", "singer-songwriter", "bluegrass", "classical"]) * 0.5
|
|
478
|
-
if acoustic_score > 0.3 and "old-world" in wine.tags:
|
|
479
|
-
connections.append(MusicWineConnection(
|
|
480
|
-
music_signal="You gravitate toward acoustic, organic sounds",
|
|
481
|
-
wine_signal=f"{wine.region} has centuries of winemaking tradition",
|
|
482
|
-
explanation="Analog music lover, traditional winemaking. No synthetic shortcuts",
|
|
483
|
-
strength=min(0.4 + acoustic_score, 0.9),
|
|
484
|
-
))
|
|
485
|
-
|
|
486
|
-
# Complexity seekers (graduated)
|
|
487
|
-
complexity_score = music.avg_complexity * 0.4 + genre_affinity(g, ["jazz", "classical", "prog", "experimental", "art rock"]) * 0.6
|
|
488
|
-
if complexity_score > 0.3 and wine.profile.complexity > 0.6:
|
|
489
|
-
connections.append(MusicWineConnection(
|
|
490
|
-
music_signal="You seek out musically complex, layered compositions",
|
|
491
|
-
wine_signal=f"This {wine.varietal} rewards patient attention",
|
|
492
|
-
explanation="You don't want background music, and you don't want background wine",
|
|
493
|
-
strength=min(0.5 + complexity_score * 0.5, 0.95),
|
|
494
|
-
))
|
|
495
|
-
|
|
496
|
-
# Niche taste → obscure varietals (graduated)
|
|
497
|
-
niche_score = music.obscurity_score
|
|
498
|
-
if niche_score > 0.4 and "obscure-varietal" in wine.tags:
|
|
499
|
-
artist_ref = music.top_artists[0] if music.top_artists else "niche artists"
|
|
500
|
-
connections.append(MusicWineConnection(
|
|
501
|
-
music_signal="Your taste runs deep into niche territory",
|
|
502
|
-
wine_signal=f"{wine.varietal} from {wine.region} — not exactly grocery store fare",
|
|
503
|
-
explanation=f"Someone who listens to {artist_ref} doesn't want the wine equivalent of Top 40",
|
|
504
|
-
strength=min(0.4 + niche_score * 0.5, 0.95),
|
|
505
|
-
))
|
|
506
|
-
|
|
507
|
-
# High energy → bold wines (graduated)
|
|
508
|
-
energy_score = music.avg_energy * 0.6 + genre_affinity(g, ["metal", "punk", "edm", "drum and bass", "hard rock"]) * 0.4
|
|
509
|
-
if energy_score > 0.4 and wine.profile.body > 0.6:
|
|
510
|
-
connections.append(MusicWineConnection(
|
|
511
|
-
music_signal="Your playlists hit hard — high energy, high intensity",
|
|
512
|
-
wine_signal=f"This is a full-bodied {wine.color} that doesn't hold back",
|
|
513
|
-
explanation="You like your music loud and your wine bold",
|
|
514
|
-
strength=min(0.4 + energy_score * 0.4, 0.85),
|
|
515
|
-
))
|
|
516
|
-
|
|
517
|
-
# Bright/happy → fruit-forward (graduated)
|
|
518
|
-
bright_score = music.avg_valence * 0.5 + genre_affinity(g, ["pop", "indie pop", "funk", "disco", "latin"]) * 0.5
|
|
519
|
-
if bright_score > 0.35 and wine.profile.fruitiness > 0.5:
|
|
520
|
-
connections.append(MusicWineConnection(
|
|
521
|
-
music_signal="Your music is bright and upbeat",
|
|
522
|
-
wine_signal=f"Fruit-forward {wine.varietal} with approachable charm",
|
|
523
|
-
explanation="Happy music, happy wine — nothing wrong with joy",
|
|
524
|
-
strength=min(0.3 + bright_score * 0.5, 0.8),
|
|
525
|
-
))
|
|
526
|
-
|
|
527
|
-
# Danceable → approachable/sweet
|
|
528
|
-
if music.avg_danceability > 0.55 and wine.profile.sweetness > 0.2:
|
|
529
|
-
connections.append(MusicWineConnection(
|
|
530
|
-
music_signal="You love a groove — high danceability across your library",
|
|
531
|
-
wine_signal=f"This {wine.varietal} has a touch of approachable sweetness",
|
|
532
|
-
explanation="Dance music and a hint of sweetness — both are about pleasure, not pretension",
|
|
533
|
-
strength=min(0.3 + music.avg_danceability * 0.4, 0.75),
|
|
534
|
-
))
|
|
535
|
-
|
|
536
|
-
# Quiet/ambient → delicate wines
|
|
537
|
-
quiet_score = (1 - music.avg_energy) * 0.5 + genre_affinity(g, ["ambient", "drone", "minimal", "new age"]) * 0.5
|
|
538
|
-
if quiet_score > 0.35 and wine.profile.body < 0.5:
|
|
539
|
-
connections.append(MusicWineConnection(
|
|
540
|
-
music_signal="Your listening is quiet, ambient, introspective",
|
|
541
|
-
wine_signal=f"A delicate {wine.color} that whispers rather than shouts",
|
|
542
|
-
explanation="You don't need volume to feel something. This wine understands that",
|
|
543
|
-
strength=min(0.4 + quiet_score * 0.4, 0.85),
|
|
544
|
-
))
|
|
545
|
-
|
|
546
|
-
# Genre-region affinity
|
|
547
|
-
if genre_affinity(g, ["bossa nova", "latin", "flamenco"]) > 0.1 and wine.country in ("Spain", "Argentina", "Portugal"):
|
|
548
|
-
connections.append(MusicWineConnection(
|
|
549
|
-
music_signal="Latin and Mediterranean rhythms in your library",
|
|
550
|
-
wine_signal=f"This {wine.varietal} comes from {wine.country}'s winemaking tradition",
|
|
551
|
-
explanation="Your music has warmth and rhythm — so does this wine's homeland",
|
|
552
|
-
strength=0.65,
|
|
553
|
-
))
|
|
554
|
-
|
|
555
|
-
if genre_affinity(g, ["blues", "soul", "gospel", "americana"]) > 0.1 and wine.country == "USA":
|
|
556
|
-
connections.append(MusicWineConnection(
|
|
557
|
-
music_signal="American roots music runs through your listening",
|
|
558
|
-
wine_signal=f"{wine.producer} in {wine.region} — American terroir",
|
|
559
|
-
explanation="Rooted in American soil, both the music and the wine",
|
|
560
|
-
strength=0.6,
|
|
561
|
-
))
|
|
562
|
-
|
|
563
|
-
return connections
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
def recommend_wines(music: MusicProfile, wines: list[Wine], top_n: int = 3) -> list[Recommendation]:
|
|
567
|
-
target = music_to_wine_profile(music)
|
|
568
|
-
|
|
569
|
-
rating_boosts = load_rating_boosts()
|
|
570
|
-
|
|
571
|
-
scored = []
|
|
572
|
-
for wine in wines:
|
|
573
|
-
score = score_wine_match(target, wine)
|
|
574
|
-
# Multiplicative boost from ratings (caps total adjustment at +/-15%)
|
|
575
|
-
boost = sum(rating_boosts.get(tag, 0.0) for tag in wine.tags)
|
|
576
|
-
boost = max(-0.15, min(0.15, boost * 0.03))
|
|
577
|
-
score = clamp01(score * (1 + boost))
|
|
578
|
-
connections = generate_connections(music, wine)
|
|
579
|
-
scored.append((wine, score, connections))
|
|
580
|
-
|
|
581
|
-
scored.sort(key=lambda x: -x[1])
|
|
582
|
-
# Normalize so scores use the full display range
|
|
583
|
-
scored = normalize_scores(scored)
|
|
584
|
-
|
|
585
|
-
results = []
|
|
586
|
-
for wine, score, connections in scored[:top_n]:
|
|
587
|
-
best_conn = sorted(connections, key=lambda c: -c.strength) if connections else []
|
|
588
|
-
if best_conn:
|
|
589
|
-
reasoning = f"{best_conn[0].explanation}. Try this {wine.name} — a {wine.varietal} from {wine.producer} in {wine.region}."
|
|
590
|
-
else:
|
|
591
|
-
reasoning = f"Based on your overall taste profile, this {wine.varietal} from {wine.region} should resonate with you."
|
|
592
|
-
results.append(Recommendation(wine=wine, score=score, reasoning=reasoning, connections=connections))
|
|
593
|
-
|
|
594
|
-
return results
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
def load_rating_boosts() -> dict[str, float]:
|
|
598
|
-
"""Load tag-level boosts from past ratings. Positive = liked, negative = disliked."""
|
|
599
|
-
if not RATINGS_FILE.exists():
|
|
600
|
-
return {}
|
|
601
|
-
try:
|
|
602
|
-
ratings = json.loads(RATINGS_FILE.read_text())
|
|
603
|
-
except (json.JSONDecodeError, OSError):
|
|
604
|
-
return {}
|
|
605
|
-
if not isinstance(ratings, list):
|
|
606
|
-
return {}
|
|
607
|
-
tag_scores: dict[str, list[float]] = {}
|
|
608
|
-
for entry in ratings:
|
|
609
|
-
normalized = (entry.get("rating", 3) - 3) / 2 # 1=-1, 2=-0.5, 3=0, 4=0.5, 5=1
|
|
610
|
-
for tag in entry.get("tags", []):
|
|
611
|
-
tag_scores.setdefault(tag, []).append(normalized)
|
|
612
|
-
return {tag: sum(scores) / len(scores) for tag, scores in tag_scores.items()}
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
# ──────────────────────────────────────────────
|
|
616
|
-
# Demo profile
|
|
617
|
-
# ──────────────────────────────────────────────
|
|
618
|
-
|
|
619
|
-
DEMO_PROFILE = MusicProfile(
|
|
620
|
-
avg_valence=0.32,
|
|
621
|
-
avg_energy=0.55,
|
|
622
|
-
avg_danceability=0.45,
|
|
623
|
-
avg_acousticness=0.62,
|
|
624
|
-
avg_tempo=115.0,
|
|
625
|
-
avg_complexity=0.72,
|
|
626
|
-
obscurity_score=0.68,
|
|
627
|
-
genre_distribution={
|
|
628
|
-
"art rock": 0.15, "post-punk": 0.12, "jazz": 0.10,
|
|
629
|
-
"indie rock": 0.09, "ambient": 0.08, "shoegaze": 0.07, "classical": 0.06,
|
|
630
|
-
},
|
|
631
|
-
mood_label="contemplative",
|
|
632
|
-
top_artists=["Radiohead", "Nick Cave", "Talk Talk", "Bjork", "John Coltrane",
|
|
633
|
-
"Portishead", "The National", "Brian Eno", "Deerhunter", "Low"],
|
|
634
|
-
top_tracks=["Reckoner — Radiohead", "Into My Arms — Nick Cave",
|
|
635
|
-
"Bloodbuzz Ohio — The National", "Hyperballad — Bjork",
|
|
636
|
-
"A Love Supreme Pt. 1 — John Coltrane"],
|
|
637
|
-
has_audio_features=True,
|
|
638
|
-
)
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
# ──────────────────────────────────────────────
|
|
642
|
-
# Output formatting
|
|
643
|
-
# ──────────────────────────────────────────────
|
|
644
|
-
|
|
645
|
-
def format_bar(value: float, width: int = 20) -> str:
|
|
646
|
-
filled = round(max(0.0, min(1.0, value)) * width)
|
|
647
|
-
return "=" * filled + "-" * (width - filled)
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
def print_profile(music: MusicProfile, wine_profile: WineProfile) -> None:
|
|
651
|
-
print("=" * 50)
|
|
652
|
-
print("YOUR MUSIC PROFILE")
|
|
653
|
-
print("=" * 50)
|
|
654
|
-
if not music.has_audio_features:
|
|
655
|
-
print(" (estimated from genres — audio features unavailable)")
|
|
656
|
-
print(f" Mood: {music.mood_label}")
|
|
657
|
-
print(f" Valence: [{format_bar(music.avg_valence)}] {music.avg_valence * 100:.0f}%")
|
|
658
|
-
print(f" Energy: [{format_bar(music.avg_energy)}] {music.avg_energy * 100:.0f}%")
|
|
659
|
-
print(f" Danceability: [{format_bar(music.avg_danceability)}] {music.avg_danceability * 100:.0f}%")
|
|
660
|
-
print(f" Acousticness: [{format_bar(music.avg_acousticness)}] {music.avg_acousticness * 100:.0f}%")
|
|
661
|
-
print(f" Complexity: [{format_bar(music.avg_complexity)}] {music.avg_complexity * 100:.0f}%")
|
|
662
|
-
print(f" Obscurity: [{format_bar(music.obscurity_score)}] {music.obscurity_score * 100:.0f}%")
|
|
663
|
-
print(f" Tempo: {music.avg_tempo:.0f} BPM")
|
|
664
|
-
if music.top_artists:
|
|
665
|
-
print(f" Top artists: {', '.join(music.top_artists[:5])}")
|
|
666
|
-
if music.genre_distribution:
|
|
667
|
-
top_genres = list(music.genre_distribution.keys())[:5]
|
|
668
|
-
print(f" Top genres: {', '.join(top_genres)}")
|
|
669
|
-
print()
|
|
670
|
-
|
|
671
|
-
print("=" * 50)
|
|
672
|
-
print("DERIVED WINE PROFILE")
|
|
673
|
-
print("=" * 50)
|
|
674
|
-
for dim in ["body", "sweetness", "tannin", "acidity", "complexity", "fruitiness", "earthiness", "spiciness"]:
|
|
675
|
-
val = getattr(wine_profile, dim)
|
|
676
|
-
print(f" {dim:<12} [{format_bar(val)}] {val * 100:.0f}%")
|
|
677
|
-
print()
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
def print_recommendations(recs: list[Recommendation]) -> None:
|
|
681
|
-
print("=" * 50)
|
|
682
|
-
print("RECOMMENDATIONS")
|
|
683
|
-
print("=" * 50)
|
|
684
|
-
print()
|
|
685
|
-
|
|
686
|
-
for i, rec in enumerate(recs, 1):
|
|
687
|
-
w = rec.wine
|
|
688
|
-
stars = "*" * round(rec.score * 5) + "." * (5 - round(rec.score * 5))
|
|
689
|
-
print(f"{i}. {w.name}")
|
|
690
|
-
print(f" {w.varietal} | {w.region}, {w.country} | {w.price_range}")
|
|
691
|
-
if w.vintage:
|
|
692
|
-
print(f" Vintage: {w.vintage}")
|
|
693
|
-
print(f" Match: [{stars}] ({rec.score * 100:.0f}%)")
|
|
694
|
-
print(f" \"{w.description}\"")
|
|
695
|
-
print(f" Why: {rec.reasoning}")
|
|
696
|
-
for conn in rec.connections:
|
|
697
|
-
print(f" ~ {conn.explanation}")
|
|
698
|
-
print()
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
def print_json_output(music: MusicProfile, wine_profile: WineProfile, recs: list[Recommendation]) -> None:
|
|
702
|
-
output = {
|
|
703
|
-
"music_profile": asdict(music),
|
|
704
|
-
"wine_profile": asdict(wine_profile),
|
|
705
|
-
"recommendations": [
|
|
706
|
-
{
|
|
707
|
-
"wine": {
|
|
708
|
-
"id": r.wine.id,
|
|
709
|
-
"name": r.wine.name,
|
|
710
|
-
"producer": r.wine.producer,
|
|
711
|
-
"region": r.wine.region,
|
|
712
|
-
"country": r.wine.country,
|
|
713
|
-
"varietal": r.wine.varietal,
|
|
714
|
-
"color": r.wine.color,
|
|
715
|
-
"price_range": r.wine.price_range,
|
|
716
|
-
"description": r.wine.description,
|
|
717
|
-
"tags": r.wine.tags,
|
|
718
|
-
"vintage": r.wine.vintage,
|
|
719
|
-
"profile": asdict(r.wine.profile),
|
|
720
|
-
},
|
|
721
|
-
"score": round(r.score, 3),
|
|
722
|
-
"reasoning": r.reasoning,
|
|
723
|
-
"connections": [asdict(c) for c in r.connections],
|
|
724
|
-
}
|
|
725
|
-
for r in recs
|
|
726
|
-
],
|
|
727
|
-
}
|
|
728
|
-
print(json.dumps(output, indent=2))
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
# ──────────────────────────────────────────────
|
|
732
|
-
# Main
|
|
733
|
-
# ──────────────────────────────────────────────
|
|
734
|
-
|
|
735
|
-
def main():
|
|
736
|
-
parser = argparse.ArgumentParser(description="SommeliAgent — Wine recommendations from your Spotify")
|
|
737
|
-
parser.add_argument("--demo", action="store_true", help="Use demo profile (no Spotify needed)")
|
|
738
|
-
parser.add_argument("--color", choices=["red", "white", "rose", "orange", "sparkling", "dessert"], help="Filter by wine color")
|
|
739
|
-
parser.add_argument("--price", choices=["budget", "mid", "premium", "luxury"], help="Filter by price range")
|
|
740
|
-
parser.add_argument("--count", type=int, default=3, help="Number of recommendations (default: 3)")
|
|
741
|
-
parser.add_argument("--profile", action="store_true", help="Show full music + wine profile")
|
|
742
|
-
parser.add_argument("--json", action="store_true", help="Output as JSON (for programmatic use)")
|
|
743
|
-
parser.add_argument("--no-cache", action="store_true", help="Bypass profile cache")
|
|
744
|
-
args = parser.parse_args()
|
|
745
|
-
|
|
746
|
-
if args.demo:
|
|
747
|
-
music = DEMO_PROFILE
|
|
748
|
-
else:
|
|
749
|
-
if args.no_cache and CACHE_FILE.exists():
|
|
750
|
-
CACHE_FILE.unlink()
|
|
751
|
-
token = get_access_token()
|
|
752
|
-
music = build_music_profile(token)
|
|
753
|
-
|
|
754
|
-
wines = WINE_DB
|
|
755
|
-
if args.color:
|
|
756
|
-
wines = [w for w in wines if w.color == args.color]
|
|
757
|
-
if args.price:
|
|
758
|
-
wines = [w for w in wines if w.price_range == args.price]
|
|
759
|
-
|
|
760
|
-
if not wines:
|
|
761
|
-
print("No wines match your filters.", file=sys.stderr)
|
|
762
|
-
sys.exit(1)
|
|
763
|
-
|
|
764
|
-
wine_profile = music_to_wine_profile(music)
|
|
765
|
-
count = max(1, min(args.count, len(wines)))
|
|
766
|
-
recs = recommend_wines(music, wines, count)
|
|
767
|
-
|
|
768
|
-
if args.json:
|
|
769
|
-
print_json_output(music, wine_profile, recs)
|
|
770
|
-
else:
|
|
771
|
-
if args.profile:
|
|
772
|
-
print_profile(music, wine_profile)
|
|
773
|
-
print_recommendations(recs)
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
if __name__ == "__main__":
|
|
777
|
-
main()
|