@hyperspell/openclaw-hyperspell 0.7.2 → 0.8.0

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.
@@ -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()