SulmanK commited on
Commit
965b972
·
1 Parent(s): b9c6427

feat: Complete Phase 1 - Foundation and API clients

Browse files
README.md ADDED
@@ -0,0 +1,227 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 🎵 BeatDebate
2
+
3
+ **Multi-Agent Music Recommendation System for AgentX Competition**
4
+
5
+ BeatDebate is a sophisticated music recommendation system that uses 4 specialized AI agents to discover under-the-radar tracks through intelligent debate and strategic planning. Built for the AgentX competition, it demonstrates advanced agentic planning behavior in a real-world application.
6
+
7
+ ## 🎯 Key Features
8
+
9
+ - **Strategic Planning**: PlannerAgent coordinates recommendation strategy
10
+ - **Multi-Agent Debate**: 4 specialized agents collaborate and compete
11
+ - **Underground Discovery**: Focus on indie and lesser-known tracks
12
+ - **Explainable AI**: Transparent reasoning for every recommendation
13
+ - **Conversational Interface**: ChatGPT-style music discovery experience
14
+ - **$0 Cost**: Built on free tiers (Gemini, Last.fm, Spotify, HuggingFace)
15
+
16
+ ## 🏗️ Architecture
17
+
18
+ ```
19
+ User Query → PlannerAgent (Strategy) → [GenreMoodAgent || DiscoveryAgent] → JudgeAgent → Response
20
+ ```
21
+
22
+ ### Agent Roles
23
+ - **🧠 PlannerAgent**: Strategic coordination and planning (AgentX focus)
24
+ - **🎸 GenreMoodAgent**: Genre and mood specialist
25
+ - **🔍 DiscoveryAgent**: Similarity and underground discovery specialist
26
+ - **⚖️ JudgeAgent**: Multi-criteria decision making with explanations
27
+
28
+ ## 🚀 Quick Start
29
+
30
+ ### Prerequisites
31
+ - Python 3.11+
32
+ - API keys for Gemini, Last.fm, and Spotify
33
+
34
+ ### Installation
35
+
36
+ 1. **Clone and setup environment**
37
+ ```bash
38
+ git clone <your-repo-url>
39
+ cd beatdebate
40
+ ```
41
+
42
+ 2. **Install uv dependency manager**
43
+ ```bash
44
+ curl -LsSf https://astral.sh/uv/install.sh | sh
45
+ ```
46
+
47
+ 3. **Setup project**
48
+ ```bash
49
+ uv venv
50
+ source .venv/bin/activate # On Windows: .venv\Scripts\activate
51
+ uv sync --dev
52
+ ```
53
+
54
+ 4. **Configure environment**
55
+ ```bash
56
+ cp env.example .env
57
+ # Edit .env with your API keys
58
+ ```
59
+
60
+ 5. **Run the application**
61
+ ```bash
62
+ uv run python src/main.py
63
+ ```
64
+
65
+ ### API Keys Setup
66
+
67
+ You'll need:
68
+ - **Gemini API Key**: Get from [Google AI Studio](https://makersuite.google.com/app/apikey)
69
+ - **Last.fm API Key**: Get from [Last.fm API](https://www.last.fm/api/account/create)
70
+ - **Spotify Client ID/Secret**: Get from [Spotify Developer Dashboard](https://developer.spotify.com/dashboard)
71
+
72
+ ## 🧪 Development
73
+
74
+ ### Data Validation
75
+ Before building the full system, validate data sources:
76
+ ```bash
77
+ uv run python scripts/validate_lastfm.py
78
+ uv run python scripts/validate_spotify.py
79
+ ```
80
+
81
+ ### Testing
82
+ ```bash
83
+ # Run all tests
84
+ uv run pytest
85
+
86
+ # Run with coverage
87
+ uv run pytest --cov=src --cov-report=html
88
+
89
+ # Run specific test
90
+ uv run pytest tests/test_agents.py
91
+ ```
92
+
93
+ ### Code Quality
94
+ ```bash
95
+ # Format code
96
+ uv run black src/ tests/
97
+ uv run isort src/ tests/
98
+
99
+ # Lint code
100
+ uv run ruff check src/ tests/
101
+
102
+ # Type check
103
+ uv run mypy src/
104
+ ```
105
+
106
+ ## 📁 Project Structure
107
+
108
+ ```
109
+ beatdebate/
110
+ ├── src/
111
+ │ ├── agents/ # Agent implementations
112
+ │ │ ├── base_agent.py
113
+ │ │ ├── planner_agent.py # Strategic coordinator
114
+ │ │ ├── genre_mood_agent.py # Genre/mood specialist
115
+ │ │ ├── discovery_agent.py # Similarity specialist
116
+ │ │ └── judge_agent.py # Decision maker
117
+ │ ├── api/ # External API clients
118
+ │ │ ├── lastfm_client.py
119
+ │ │ └── spotify_client.py
120
+ │ ├── models/ # Data models and schemas
121
+ │ ├── services/ # Business logic
122
+ │ │ ├── recommendation_engine.py # LangGraph workflow
123
+ │ │ ├── cache_manager.py
124
+ │ │ └── vector_store.py
125
+ │ ├── ui/ # Frontend components
126
+ │ │ └── chat_interface.py
127
+ │ └── main.py # Application entry point
128
+ ├── tests/ # Test files
129
+ ├── data/ # Local data and cache
130
+ ├── scripts/ # Utility scripts
131
+ ├── docs/ # Documentation
132
+ └── Design/ # Design documents
133
+ ```
134
+
135
+ ## 🎵 Usage Examples
136
+
137
+ ### Basic Music Discovery
138
+ ```
139
+ You: "I need focus music for coding"
140
+
141
+ 🧠 PlannerAgent: "Analyzing coding music requirements..."
142
+ 🎸 GenreMoodAgent: "Searching instrumental post-rock..."
143
+ 🔍 DiscoveryAgent: "Finding underground study music..."
144
+ ⚖️ JudgeAgent: "Selecting optimal recommendations..."
145
+
146
+ 🎵 Results: 3 tracks with detailed explanations and previews
147
+ ```
148
+
149
+ ### Mood-Based Search
150
+ ```
151
+ You: "Something upbeat but not mainstream"
152
+
153
+ 🧠 PlannerAgent: "Planning discovery for high-energy indie tracks..."
154
+ [Agent coordination and reasoning...]
155
+ 🎵 Results: Fresh indie tracks with energy explanations
156
+ ```
157
+
158
+ ## 🏆 AgentX Competition
159
+
160
+ BeatDebate demonstrates sophisticated **agentic planning behavior** for the AgentX competition:
161
+
162
+ - **Strategic Planning**: PlannerAgent creates comprehensive recommendation strategies
163
+ - **Agent Coordination**: Sophisticated inter-agent communication protocols
164
+ - **Reasoning Transparency**: All agent decisions are explainable and traceable
165
+ - **Technical Innovation**: Novel approach to music recommendation through multi-agent planning
166
+
167
+ ### Competition Positioning
168
+ - **Track**: Entrepreneurship (consumer music application)
169
+ - **Key Innovation**: Planning-driven multi-agent recommendation system
170
+ - **Market Opportunity**: $25B music streaming + AI personalization trends
171
+ - **Competitive Advantage**: Only explainable multi-agent music recommender
172
+
173
+ ## 📊 Technical Details
174
+
175
+ ### Rate Limiting Strategy
176
+ - **Gemini**: 12 calls/minute (4 agents × 3 calls each)
177
+ - **Last.fm**: 3 calls/second with aggressive caching
178
+ - **Spotify**: 50 calls/hour with batch requests
179
+
180
+ ### Caching & Performance
181
+ - **File-based cache** with TTL for API responses
182
+ - **ChromaDB** for vector similarity search
183
+ - **Async processing** for agent coordination
184
+ - **Request optimization** to stay within free tiers
185
+
186
+ ### Data Sources
187
+ - **Last.fm**: 15M+ tracks with rich metadata and indie focus
188
+ - **Spotify**: Audio features and 30-second previews
189
+ - **Text embeddings**: Sentence transformers for semantic search
190
+
191
+ ## 🚀 Deployment
192
+
193
+ ### HuggingFace Spaces
194
+ The application is designed for HuggingFace Spaces deployment:
195
+ ```bash
196
+ # Production build
197
+ uv run python -m src.main
198
+ ```
199
+
200
+ ### Local Development
201
+ ```bash
202
+ # Development server with hot reload
203
+ uv run uvicorn src.main:app --reload --port 7860
204
+ ```
205
+
206
+ ## 🤝 Contributing
207
+
208
+ 1. **Create feature branch**: `git checkout -b feature/new-feature`
209
+ 2. **Follow code standards**: Run `uv run black` and `uv run ruff`
210
+ 3. **Add tests**: Include tests for new functionality
211
+ 4. **Update docs**: Update README and docstrings
212
+ 5. **Create PR**: Submit for review
213
+
214
+ ## 📄 License
215
+
216
+ MIT License - see LICENSE file for details.
217
+
218
+ ## 🔗 Links
219
+
220
+ - **Design Document**: `Design/Plans/beatdebate-design-doc.md`
221
+ - **AgentX Course**: [LLM Agents Learning](https://llmagents-learning.org/sp25)
222
+ - **HuggingFace Spaces**: [Coming Soon]
223
+ - **Demo Video**: [Coming Soon]
224
+
225
+ ---
226
+
227
+ **Built for AgentX Competition 2025** | **Demonstrates Advanced Agentic Planning**
env.example ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # BeatDebate Environment Configuration
2
+
3
+ # ================================
4
+ # LLM API Configuration
5
+ # ================================
6
+ # Gemini 2.5 Flash API Key (Primary LLM)
7
+ GEMINI_API_KEY=your_gemini_api_key_here
8
+
9
+ # ================================
10
+ # Music Data API Configuration
11
+ # ================================
12
+ # Last.fm API (Primary music data source)
13
+ LASTFM_API_KEY=your_lastfm_api_key_here
14
+
15
+ # Spotify Web API (Secondary - audio features & previews)
16
+ SPOTIFY_CLIENT_ID=your_spotify_client_id_here
17
+ SPOTIFY_CLIENT_SECRET=your_spotify_client_secret_here
18
+
19
+ # ================================
20
+ # Application Configuration
21
+ # ================================
22
+ # Environment (development, production)
23
+ ENVIRONMENT=development
24
+
25
+ # Logging Level (DEBUG, INFO, WARNING, ERROR)
26
+ LOG_LEVEL=INFO
27
+
28
+ # Cache Configuration
29
+ CACHE_TTL_HOURS=24
30
+ CACHE_DIR=data/cache
31
+
32
+ # Vector Store Configuration
33
+ CHROMADB_PERSIST_DIR=data/chromadb
34
+
35
+ # ================================
36
+ # Rate Limiting Configuration
37
+ # ================================
38
+ # Gemini API (requests per minute)
39
+ GEMINI_RATE_LIMIT=12
40
+
41
+ # Last.fm API (requests per second)
42
+ LASTFM_RATE_LIMIT=3
43
+
44
+ # Spotify API (requests per hour)
45
+ SPOTIFY_RATE_LIMIT=50
46
+
47
+ # ================================
48
+ # Agent Configuration
49
+ # ================================
50
+ # Number of tracks to consider during agent debate
51
+ CANDIDATE_TRACK_COUNT=20
52
+
53
+ # Final recommendations to return
54
+ FINAL_RECOMMENDATION_COUNT=3
55
+
56
+ # Agent reasoning depth (1-3, higher = more sophisticated)
57
+ REASONING_DEPTH=2
58
+
59
+ # ================================
60
+ # UI Configuration
61
+ # ================================
62
+ # Gradio interface settings
63
+ GRADIO_SHARE=false
64
+ GRADIO_SERVER_PORT=7860
65
+ GRADIO_THEME=soft
66
+
67
+ # ================================
68
+ # Development Configuration
69
+ # ================================
70
+ # Enable debug features
71
+ DEBUG_MODE=true
72
+
73
+ # Enable agent reasoning logs in UI
74
+ SHOW_AGENT_REASONING=true
75
+
76
+ # Enable performance monitoring
77
+ ENABLE_MONITORING=true
pyproject.toml ADDED
@@ -0,0 +1,146 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [project]
2
+ name = "beatdebate"
3
+ version = "0.1.0"
4
+ description = "Multi-agent music recommendation system using LLM debates"
5
+ authors = [
6
+ {name = "BeatDebate Team", email = "team@beatdebate.com"}
7
+ ]
8
+ readme = "README.md"
9
+ requires-python = ">=3.11"
10
+ dependencies = [
11
+ # Core web framework
12
+ "fastapi>=0.104.0",
13
+ "gradio>=4.0.0",
14
+ "uvicorn>=0.24.0",
15
+
16
+ # LLM and Agent Framework
17
+ "langchain>=0.1.0",
18
+ "langchain-google-genai>=1.0.0",
19
+ "langgraph>=0.0.40",
20
+
21
+ # Vector Store and Embeddings
22
+ "chromadb>=0.4.0",
23
+ "sentence-transformers>=2.2.0",
24
+
25
+ # HTTP and API clients
26
+ "requests>=2.31.0",
27
+ "aiohttp>=3.9.0",
28
+ "httpx>=0.25.0",
29
+
30
+ # Environment and Configuration
31
+ "python-dotenv>=1.0.0",
32
+ "pydantic>=2.5.0",
33
+ "pydantic-settings>=2.1.0",
34
+
35
+ # Data Processing
36
+ "pandas>=2.1.0",
37
+ "numpy>=1.24.0",
38
+
39
+ # Caching and Storage
40
+ "diskcache>=5.6.0",
41
+
42
+ # Logging and Monitoring
43
+ "structlog>=23.2.0",
44
+ "rich>=13.7.0",
45
+ ]
46
+
47
+ [project.optional-dependencies]
48
+ dev = [
49
+ # Testing
50
+ "pytest>=7.4.0",
51
+ "pytest-asyncio>=0.21.0",
52
+ "pytest-cov>=4.1.0",
53
+ "httpx>=0.25.0", # For testing async clients
54
+
55
+ # Code Quality
56
+ "black>=23.11.0",
57
+ "isort>=5.12.0",
58
+ "mypy>=1.7.0",
59
+ "ruff>=0.1.6",
60
+
61
+ # Development Tools
62
+ "pre-commit>=3.5.0",
63
+ "jupyter>=1.0.0",
64
+ ]
65
+
66
+ [build-system]
67
+ requires = ["hatchling"]
68
+ build-backend = "hatchling.build"
69
+
70
+ [tool.hatch.build.targets.wheel]
71
+ packages = ["src"]
72
+
73
+ [tool.black]
74
+ line-length = 88
75
+ target-version = ['py311']
76
+ include = '\.pyi?$'
77
+ extend-exclude = '''
78
+ /(
79
+ # directories
80
+ \.eggs
81
+ | \.git
82
+ | \.hg
83
+ | \.mypy_cache
84
+ | \.tox
85
+ | \.venv
86
+ | build
87
+ | dist
88
+ )/
89
+ '''
90
+
91
+ [tool.isort]
92
+ profile = "black"
93
+ multi_line_output = 3
94
+ line_length = 88
95
+ known_first_party = ["beatdebate"]
96
+
97
+ [tool.mypy]
98
+ python_version = "3.11"
99
+ warn_return_any = true
100
+ warn_unused_configs = true
101
+ disallow_untyped_defs = true
102
+ disallow_incomplete_defs = true
103
+ check_untyped_defs = true
104
+ disallow_untyped_decorators = true
105
+ no_implicit_optional = true
106
+ warn_redundant_casts = true
107
+ warn_unused_ignores = true
108
+ warn_no_return = true
109
+ warn_unreachable = true
110
+ strict_equality = true
111
+
112
+ [tool.pytest.ini_options]
113
+ testpaths = ["tests"]
114
+ python_files = "test_*.py"
115
+ python_classes = "Test*"
116
+ python_functions = "test_*"
117
+ addopts = "--cov=src --cov-report=term-missing --cov-report=html"
118
+ asyncio_mode = "auto"
119
+
120
+ [tool.ruff]
121
+ line-length = 88
122
+ target-version = "py311"
123
+ extend-exclude = [
124
+ ".venv",
125
+ "build",
126
+ "dist",
127
+ ]
128
+
129
+ [tool.ruff.lint]
130
+ select = [
131
+ "E", # pycodestyle errors
132
+ "W", # pycodestyle warnings
133
+ "F", # pyflakes
134
+ "I", # isort
135
+ "B", # flake8-bugbear
136
+ "C4", # flake8-comprehensions
137
+ "UP", # pyupgrade
138
+ ]
139
+ ignore = [
140
+ "E501", # line too long, handled by black
141
+ "B008", # do not perform function calls in argument defaults
142
+ ]
143
+
144
+ [tool.ruff.lint.per-file-ignores]
145
+ "__init__.py" = ["F401"]
146
+ "tests/**/*" = ["B018"]
scripts/validate_lastfm.py ADDED
@@ -0,0 +1,360 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Last.fm Data Validation Script
4
+
5
+ Tests Last.fm API quality for indie/underground track discovery
6
+ before building the full BeatDebate system.
7
+ """
8
+
9
+ import asyncio
10
+ import os
11
+ import json
12
+ from pathlib import Path
13
+ from typing import Dict, List, Any
14
+ from datetime import datetime
15
+
16
+ import structlog
17
+ from dotenv import load_dotenv
18
+
19
+ # Add src to path for imports
20
+ import sys
21
+ sys.path.append(str(Path(__file__).parent.parent / "src"))
22
+
23
+ from api.lastfm_client import LastFmClient, TrackMetadata
24
+
25
+ # Load environment variables
26
+ load_dotenv()
27
+
28
+ # Configure logging for validation
29
+ structlog.configure(
30
+ processors=[
31
+ structlog.stdlib.add_log_level,
32
+ structlog.processors.TimeStamper(fmt="ISO"),
33
+ structlog.processors.JSONRenderer()
34
+ ],
35
+ logger_factory=structlog.stdlib.LoggerFactory(),
36
+ wrapper_class=structlog.stdlib.BoundLogger,
37
+ cache_logger_on_first_use=True,
38
+ )
39
+
40
+ logger = structlog.get_logger(__name__)
41
+
42
+
43
+ class LastFmValidator:
44
+ """Validates Last.fm API quality for BeatDebate use case."""
45
+
46
+ def __init__(self, api_key: str):
47
+ self.api_key = api_key
48
+ self.test_queries = [
49
+ "indie rock underground",
50
+ "ambient electronic experimental",
51
+ "post-rock instrumental",
52
+ "folk indie singer-songwriter",
53
+ "experimental jazz fusion",
54
+ "synthwave retro",
55
+ "math rock progressive",
56
+ "chillhop lo-fi"
57
+ ]
58
+ self.results = {}
59
+
60
+ async def run_validation(self) -> Dict[str, Any]:
61
+ """Run complete validation suite."""
62
+ logger.info("Starting Last.fm validation")
63
+
64
+ async with LastFmClient(self.api_key) as client:
65
+ # Test track search quality
66
+ search_results = await self._test_track_search(client)
67
+
68
+ # Test metadata richness
69
+ metadata_results = await self._test_metadata_richness(client)
70
+
71
+ # Test diversity and discovery potential
72
+ diversity_results = await self._test_diversity(client)
73
+
74
+ # Test tag-based search
75
+ tag_results = await self._test_tag_search(client)
76
+
77
+ # Compile final results
78
+ validation_results = {
79
+ "timestamp": datetime.utcnow().isoformat(),
80
+ "api_key_valid": True,
81
+ "search_quality": search_results,
82
+ "metadata_richness": metadata_results,
83
+ "diversity_analysis": diversity_results,
84
+ "tag_search": tag_results,
85
+ "recommendations": self._generate_recommendations()
86
+ }
87
+
88
+ logger.info("Last.fm validation completed")
89
+ return validation_results
90
+
91
+ async def _test_track_search(self, client: LastFmClient) -> Dict[str, Any]:
92
+ """Test basic track search functionality."""
93
+ logger.info("Testing track search quality")
94
+
95
+ search_results = {}
96
+ total_tracks = 0
97
+ queries_with_results = 0
98
+
99
+ for query in self.test_queries:
100
+ try:
101
+ tracks = await client.search_tracks(query, limit=20)
102
+
103
+ result_count = len(tracks)
104
+ total_tracks += result_count
105
+
106
+ if result_count > 0:
107
+ queries_with_results += 1
108
+
109
+ search_results[query] = {
110
+ "result_count": result_count,
111
+ "sample_tracks": [
112
+ {
113
+ "name": track.name,
114
+ "artist": track.artist,
115
+ "listeners": track.listeners
116
+ }
117
+ for track in tracks[:3] # Sample first 3
118
+ ]
119
+ }
120
+
121
+ logger.info(
122
+ "Search completed",
123
+ query=query,
124
+ results=result_count
125
+ )
126
+
127
+ except Exception as e:
128
+ logger.error(
129
+ "Search failed",
130
+ query=query,
131
+ error=str(e)
132
+ )
133
+ search_results[query] = {"error": str(e)}
134
+
135
+ # Calculate metrics
136
+ avg_results_per_query = total_tracks / len(self.test_queries) if self.test_queries else 0
137
+ success_rate = queries_with_results / len(self.test_queries) if self.test_queries else 0
138
+
139
+ return {
140
+ "total_queries": len(self.test_queries),
141
+ "successful_queries": queries_with_results,
142
+ "success_rate": success_rate,
143
+ "average_results_per_query": avg_results_per_query,
144
+ "total_tracks_found": total_tracks,
145
+ "detailed_results": search_results
146
+ }
147
+
148
+ async def _test_metadata_richness(self, client: LastFmClient) -> Dict[str, Any]:
149
+ """Test quality and richness of track metadata."""
150
+ logger.info("Testing metadata richness")
151
+
152
+ # Test with known indie tracks
153
+ test_tracks = [
154
+ ("Radiohead", "Weird Fishes"),
155
+ ("Bon Iver", "Holocene"),
156
+ ("The National", "Fake Empire"),
157
+ ("Sigur Rós", "Hoppípolla"),
158
+ ("Explosions in the Sky", "Your Hand in Mine")
159
+ ]
160
+
161
+ metadata_scores = []
162
+
163
+ for artist, track in test_tracks:
164
+ try:
165
+ metadata = await client.get_track_info(artist, track)
166
+
167
+ if metadata:
168
+ score = self._calculate_metadata_score(metadata)
169
+ metadata_scores.append(score)
170
+
171
+ logger.info(
172
+ "Metadata retrieved",
173
+ artist=artist,
174
+ track=track,
175
+ score=score
176
+ )
177
+ else:
178
+ logger.warning(
179
+ "No metadata found",
180
+ artist=artist,
181
+ track=track
182
+ )
183
+
184
+ except Exception as e:
185
+ logger.error(
186
+ "Metadata retrieval failed",
187
+ artist=artist,
188
+ track=track,
189
+ error=str(e)
190
+ )
191
+
192
+ avg_score = sum(metadata_scores) / len(metadata_scores) if metadata_scores else 0
193
+
194
+ return {
195
+ "tracks_tested": len(test_tracks),
196
+ "successful_retrievals": len(metadata_scores),
197
+ "average_metadata_score": avg_score,
198
+ "metadata_quality": "excellent" if avg_score > 0.8 else "good" if avg_score > 0.6 else "fair"
199
+ }
200
+
201
+ def _calculate_metadata_score(self, metadata: TrackMetadata) -> float:
202
+ """Calculate metadata richness score (0-1)."""
203
+ score = 0.0
204
+ max_score = 7.0
205
+
206
+ # Check various metadata fields
207
+ if metadata.name:
208
+ score += 1.0
209
+ if metadata.artist:
210
+ score += 1.0
211
+ if metadata.tags and len(metadata.tags) > 0:
212
+ score += 1.0
213
+ if metadata.similar_tracks and len(metadata.similar_tracks) > 0:
214
+ score += 1.0
215
+ if metadata.listeners and metadata.listeners > 0:
216
+ score += 1.0
217
+ if metadata.playcount and metadata.playcount > 0:
218
+ score += 1.0
219
+ if metadata.summary:
220
+ score += 1.0
221
+
222
+ return score / max_score
223
+
224
+ async def _test_diversity(self, client: LastFmClient) -> Dict[str, Any]:
225
+ """Test diversity of search results."""
226
+ logger.info("Testing result diversity")
227
+
228
+ # Get tracks from first query for diversity analysis
229
+ query = self.test_queries[0]
230
+ tracks = await client.search_tracks(query, limit=50)
231
+
232
+ if not tracks:
233
+ return {"error": "No tracks for diversity analysis"}
234
+
235
+ # Analyze artist diversity
236
+ artists = [track.artist for track in tracks]
237
+ unique_artists = set(artists)
238
+ artist_diversity = len(unique_artists) / len(tracks) if tracks else 0
239
+
240
+ # Analyze popularity distribution (listeners)
241
+ listener_counts = [track.listeners or 0 for track in tracks]
242
+ avg_listeners = sum(listener_counts) / len(listener_counts) if listener_counts else 0
243
+
244
+ # Check for mainstream bias (high listener counts might indicate mainstream bias)
245
+ mainstream_threshold = 100000 # 100k listeners
246
+ mainstream_count = sum(1 for count in listener_counts if count > mainstream_threshold)
247
+ mainstream_ratio = mainstream_count / len(tracks) if tracks else 0
248
+
249
+ return {
250
+ "total_tracks_analyzed": len(tracks),
251
+ "unique_artists": len(unique_artists),
252
+ "artist_diversity_ratio": artist_diversity,
253
+ "average_listeners": avg_listeners,
254
+ "mainstream_tracks": mainstream_count,
255
+ "mainstream_ratio": mainstream_ratio,
256
+ "discovery_potential": "high" if mainstream_ratio < 0.3 else "medium" if mainstream_ratio < 0.6 else "low"
257
+ }
258
+
259
+ async def _test_tag_search(self, client: LastFmClient) -> Dict[str, Any]:
260
+ """Test tag-based search for genre/mood discovery."""
261
+ logger.info("Testing tag-based search")
262
+
263
+ test_tags = ["indie", "experimental", "ambient", "post-rock", "electronic"]
264
+ tag_results = {}
265
+
266
+ for tag in test_tags:
267
+ try:
268
+ tracks = await client.search_by_tags([tag], limit=10)
269
+
270
+ tag_results[tag] = {
271
+ "result_count": len(tracks),
272
+ "sample_artists": list(set([track.artist for track in tracks[:5]]))
273
+ }
274
+
275
+ logger.info(
276
+ "Tag search completed",
277
+ tag=tag,
278
+ results=len(tracks)
279
+ )
280
+
281
+ except Exception as e:
282
+ logger.error(
283
+ "Tag search failed",
284
+ tag=tag,
285
+ error=str(e)
286
+ )
287
+ tag_results[tag] = {"error": str(e)}
288
+
289
+ return tag_results
290
+
291
+ def _generate_recommendations(self) -> List[str]:
292
+ """Generate recommendations based on validation results."""
293
+ recommendations = []
294
+
295
+ # Basic recommendations
296
+ recommendations.append("Last.fm provides good coverage for indie/underground music discovery")
297
+ recommendations.append("Tag-based search is effective for genre-specific discovery")
298
+ recommendations.append("Metadata richness varies but generally sufficient for embeddings")
299
+ recommendations.append("Rate limiting should be implemented (3 requests/second max)")
300
+ recommendations.append("Caching is essential due to API response times")
301
+
302
+ return recommendations
303
+
304
+
305
+ async def main():
306
+ """Main validation function."""
307
+ # Check for API key
308
+ api_key = os.getenv("LASTFM_API_KEY")
309
+ if not api_key:
310
+ logger.error("LASTFM_API_KEY environment variable not set")
311
+ return
312
+
313
+ # Create output directory
314
+ output_dir = Path("data/validation")
315
+ output_dir.mkdir(parents=True, exist_ok=True)
316
+
317
+ # Run validation
318
+ validator = LastFmValidator(api_key)
319
+
320
+ try:
321
+ results = await validator.run_validation()
322
+
323
+ # Save results
324
+ output_file = output_dir / f"lastfm_validation_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json"
325
+ with open(output_file, 'w') as f:
326
+ json.dump(results, f, indent=2)
327
+
328
+ # Print summary
329
+ print("\n" + "="*60)
330
+ print("LAST.FM VALIDATION SUMMARY")
331
+ print("="*60)
332
+
333
+ search_quality = results.get("search_quality", {})
334
+ print(f"Search Success Rate: {search_quality.get('success_rate', 0):.1%}")
335
+ print(f"Average Results per Query: {search_quality.get('average_results_per_query', 0):.1f}")
336
+ print(f"Total Tracks Found: {search_quality.get('total_tracks_found', 0)}")
337
+
338
+ metadata_quality = results.get("metadata_richness", {})
339
+ print(f"Metadata Quality: {metadata_quality.get('metadata_quality', 'unknown')}")
340
+
341
+ diversity = results.get("diversity_analysis", {})
342
+ print(f"Discovery Potential: {diversity.get('discovery_potential', 'unknown')}")
343
+ print(f"Artist Diversity: {diversity.get('artist_diversity_ratio', 0):.1%}")
344
+
345
+ print(f"\nDetailed results saved to: {output_file}")
346
+
347
+ # Print recommendations
348
+ print("\nRECOMMENDATIONS:")
349
+ for rec in results.get("recommendations", []):
350
+ print(f"• {rec}")
351
+
352
+ print("\n" + "="*60)
353
+
354
+ except Exception as e:
355
+ logger.error("Validation failed", error=str(e))
356
+ print(f"ERROR: Validation failed - {e}")
357
+
358
+
359
+ if __name__ == "__main__":
360
+ asyncio.run(main())
src/__init__.py ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ BeatDebate - Multi-Agent Music Recommendation System
3
+
4
+ A sophisticated music recommendation system using 4 specialized AI agents
5
+ that demonstrate advanced agentic planning behavior for music discovery.
6
+
7
+ Built for the AgentX competition.
8
+ """
9
+
10
+ __version__ = "0.1.0"
11
+ __author__ = "BeatDebate Team"
src/__pycache__/__init__.cpython-311.pyc ADDED
Binary file (504 Bytes). View file
 
src/__pycache__/main.cpython-311.pyc ADDED
Binary file (8.02 kB). View file
 
src/agents/__init__.py ADDED
File without changes
src/api/__init__.py ADDED
File without changes
src/api/lastfm_client.py ADDED
@@ -0,0 +1,509 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Last.fm API Client
3
+
4
+ Provides access to Last.fm's music database with rate limiting and caching.
5
+ Focus on indie/underground track metadata and discovery.
6
+ """
7
+
8
+ import asyncio
9
+ import time
10
+ from typing import Dict, List, Optional, Any
11
+ from dataclasses import dataclass
12
+ from functools import wraps
13
+
14
+ import aiohttp
15
+ import structlog
16
+ from pydantic import BaseModel, Field
17
+
18
+ logger = structlog.get_logger(__name__)
19
+
20
+
21
+ @dataclass
22
+ class TrackMetadata:
23
+ """Last.fm track metadata."""
24
+ name: str
25
+ artist: str
26
+ mbid: Optional[str] = None
27
+ url: Optional[str] = None
28
+ tags: List[str] = None
29
+ similar_tracks: List[str] = None
30
+ listeners: Optional[int] = None
31
+ playcount: Optional[int] = None
32
+ summary: Optional[str] = None
33
+
34
+ def __post_init__(self):
35
+ if self.tags is None:
36
+ self.tags = []
37
+ if self.similar_tracks is None:
38
+ self.similar_tracks = []
39
+
40
+
41
+ @dataclass
42
+ class ArtistMetadata:
43
+ """Last.fm artist metadata."""
44
+ name: str
45
+ mbid: Optional[str] = None
46
+ url: Optional[str] = None
47
+ tags: List[str] = None
48
+ similar_artists: List[str] = None
49
+ listeners: Optional[int] = None
50
+ playcount: Optional[int] = None
51
+ bio: Optional[str] = None
52
+
53
+ def __post_init__(self):
54
+ if self.tags is None:
55
+ self.tags = []
56
+ if self.similar_artists is None:
57
+ self.similar_artists = []
58
+
59
+
60
+ class RateLimiter:
61
+ """Rate limiter for API calls."""
62
+
63
+ def __init__(self, calls_per_second: float = 3.0):
64
+ self.calls_per_second = calls_per_second
65
+ self.min_interval = 1.0 / calls_per_second
66
+ self.last_call = 0.0
67
+
68
+ async def wait_if_needed(self) -> None:
69
+ """Wait if necessary to respect rate limits."""
70
+ now = time.time()
71
+ time_since_last = now - self.last_call
72
+
73
+ if time_since_last < self.min_interval:
74
+ wait_time = self.min_interval - time_since_last
75
+ await asyncio.sleep(wait_time)
76
+
77
+ self.last_call = time.time()
78
+
79
+
80
+ class LastFmClient:
81
+ """
82
+ Last.fm API client with rate limiting and error handling.
83
+
84
+ Focused on music discovery and metadata extraction for indie/underground tracks.
85
+ """
86
+
87
+ BASE_URL = "https://ws.audioscrobbler.com/2.0/"
88
+
89
+ def __init__(self, api_key: str, rate_limit: float = 3.0):
90
+ """
91
+ Initialize Last.fm client.
92
+
93
+ Args:
94
+ api_key: Last.fm API key
95
+ rate_limit: Requests per second (default: 3.0)
96
+ """
97
+ self.api_key = api_key
98
+ self.rate_limiter = RateLimiter(rate_limit)
99
+ self.session: Optional[aiohttp.ClientSession] = None
100
+
101
+ async def __aenter__(self):
102
+ """Async context manager entry."""
103
+ self.session = aiohttp.ClientSession()
104
+ return self
105
+
106
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
107
+ """Async context manager exit."""
108
+ if self.session:
109
+ await self.session.close()
110
+
111
+ async def _make_request(
112
+ self,
113
+ method: str,
114
+ params: Dict[str, Any],
115
+ retries: int = 3
116
+ ) -> Dict[str, Any]:
117
+ """
118
+ Make rate-limited request to Last.fm API.
119
+
120
+ Args:
121
+ method: Last.fm API method
122
+ params: Additional parameters
123
+ retries: Number of retry attempts
124
+
125
+ Returns:
126
+ API response data
127
+ """
128
+ if not self.session:
129
+ raise RuntimeError("Client not initialized. Use async context manager.")
130
+
131
+ await self.rate_limiter.wait_if_needed()
132
+
133
+ # Build request parameters
134
+ request_params = {
135
+ "method": method,
136
+ "api_key": self.api_key,
137
+ "format": "json",
138
+ **params
139
+ }
140
+
141
+ for attempt in range(retries + 1):
142
+ try:
143
+ async with self.session.get(
144
+ self.BASE_URL,
145
+ params=request_params,
146
+ timeout=aiohttp.ClientTimeout(total=10)
147
+ ) as response:
148
+ if response.status == 200:
149
+ data = await response.json()
150
+
151
+ if "error" in data:
152
+ logger.error(
153
+ "Last.fm API error",
154
+ error=data["error"],
155
+ message=data.get("message", "Unknown error"),
156
+ method=method
157
+ )
158
+ raise Exception(f"Last.fm API error: {data.get('message', 'Unknown')}")
159
+
160
+ logger.debug(
161
+ "Last.fm API request successful",
162
+ method=method,
163
+ params=params
164
+ )
165
+ return data
166
+
167
+ elif response.status == 429: # Rate limited
168
+ wait_time = 2 ** attempt # Exponential backoff
169
+ logger.warning(
170
+ "Rate limited by Last.fm",
171
+ attempt=attempt,
172
+ wait_time=wait_time
173
+ )
174
+ await asyncio.sleep(wait_time)
175
+ continue
176
+
177
+ else:
178
+ logger.warning(
179
+ "Last.fm API request failed",
180
+ status=response.status,
181
+ method=method
182
+ )
183
+ response.raise_for_status()
184
+
185
+ except asyncio.TimeoutError:
186
+ logger.warning(
187
+ "Last.fm API timeout",
188
+ attempt=attempt,
189
+ method=method
190
+ )
191
+ if attempt == retries:
192
+ raise
193
+ await asyncio.sleep(2 ** attempt)
194
+
195
+ except Exception as e:
196
+ logger.error(
197
+ "Last.fm API request failed",
198
+ error=str(e),
199
+ attempt=attempt,
200
+ method=method
201
+ )
202
+ if attempt == retries:
203
+ raise
204
+ await asyncio.sleep(2 ** attempt)
205
+
206
+ raise Exception(f"Failed to complete Last.fm request after {retries + 1} attempts")
207
+
208
+ async def search_tracks(
209
+ self,
210
+ query: str,
211
+ limit: int = 20,
212
+ page: int = 1
213
+ ) -> List[TrackMetadata]:
214
+ """
215
+ Search for tracks by query.
216
+
217
+ Args:
218
+ query: Search query (e.g., "indie rock underground")
219
+ limit: Maximum results per page
220
+ page: Page number
221
+
222
+ Returns:
223
+ List of track metadata
224
+ """
225
+ try:
226
+ data = await self._make_request(
227
+ "track.search",
228
+ {
229
+ "track": query,
230
+ "limit": limit,
231
+ "page": page
232
+ }
233
+ )
234
+
235
+ tracks = []
236
+ if "results" in data and "trackmatches" in data["results"]:
237
+ track_matches = data["results"]["trackmatches"]
238
+ track_list = track_matches.get("track", [])
239
+
240
+ # Handle single track result (not in list)
241
+ if isinstance(track_list, dict):
242
+ track_list = [track_list]
243
+
244
+ for track_data in track_list:
245
+ track = TrackMetadata(
246
+ name=track_data.get("name", ""),
247
+ artist=track_data.get("artist", ""),
248
+ mbid=track_data.get("mbid"),
249
+ url=track_data.get("url"),
250
+ listeners=int(track_data.get("listeners", 0)),
251
+ )
252
+ tracks.append(track)
253
+
254
+ logger.info(
255
+ "Track search completed",
256
+ query=query,
257
+ results_count=len(tracks),
258
+ limit=limit
259
+ )
260
+
261
+ return tracks
262
+
263
+ except Exception as e:
264
+ logger.error(
265
+ "Track search failed",
266
+ query=query,
267
+ error=str(e)
268
+ )
269
+ return []
270
+
271
+ async def get_track_info(
272
+ self,
273
+ artist: str,
274
+ track: str,
275
+ mbid: Optional[str] = None
276
+ ) -> Optional[TrackMetadata]:
277
+ """
278
+ Get detailed track information.
279
+
280
+ Args:
281
+ artist: Artist name
282
+ track: Track name
283
+ mbid: MusicBrainz ID (optional)
284
+
285
+ Returns:
286
+ Detailed track metadata or None if not found
287
+ """
288
+ try:
289
+ params = {"artist": artist, "track": track}
290
+ if mbid:
291
+ params["mbid"] = mbid
292
+
293
+ data = await self._make_request("track.getInfo", params)
294
+
295
+ if "track" not in data:
296
+ return None
297
+
298
+ track_data = data["track"]
299
+
300
+ # Extract tags
301
+ tags = []
302
+ if "toptags" in track_data and "tag" in track_data["toptags"]:
303
+ tag_list = track_data["toptags"]["tag"]
304
+ if isinstance(tag_list, dict):
305
+ tag_list = [tag_list]
306
+ tags = [tag.get("name", "") for tag in tag_list]
307
+
308
+ # Extract similar tracks
309
+ similar_tracks = []
310
+ if "similarartists" in track_data and "artist" in track_data["similarartists"]:
311
+ similar_list = track_data["similarartists"]["artist"]
312
+ if isinstance(similar_list, dict):
313
+ similar_list = [similar_list]
314
+ similar_tracks = [artist.get("name", "") for artist in similar_list]
315
+
316
+ return TrackMetadata(
317
+ name=track_data.get("name", ""),
318
+ artist=track_data.get("artist", {}).get("name", artist),
319
+ mbid=track_data.get("mbid"),
320
+ url=track_data.get("url"),
321
+ tags=tags,
322
+ similar_tracks=similar_tracks,
323
+ listeners=int(track_data.get("listeners", 0)),
324
+ playcount=int(track_data.get("playcount", 0)),
325
+ summary=track_data.get("wiki", {}).get("summary", "")
326
+ )
327
+
328
+ except Exception as e:
329
+ logger.error(
330
+ "Get track info failed",
331
+ artist=artist,
332
+ track=track,
333
+ error=str(e)
334
+ )
335
+ return None
336
+
337
+ async def get_similar_tracks(
338
+ self,
339
+ artist: str,
340
+ track: str,
341
+ limit: int = 20
342
+ ) -> List[TrackMetadata]:
343
+ """
344
+ Get similar tracks for discovery.
345
+
346
+ Args:
347
+ artist: Artist name
348
+ track: Track name
349
+ limit: Maximum results
350
+
351
+ Returns:
352
+ List of similar tracks
353
+ """
354
+ try:
355
+ data = await self._make_request(
356
+ "track.getSimilar",
357
+ {
358
+ "artist": artist,
359
+ "track": track,
360
+ "limit": limit
361
+ }
362
+ )
363
+
364
+ tracks = []
365
+ if "similartracks" in data and "track" in data["similartracks"]:
366
+ track_list = data["similartracks"]["track"]
367
+ if isinstance(track_list, dict):
368
+ track_list = [track_list]
369
+
370
+ for track_data in track_list:
371
+ track = TrackMetadata(
372
+ name=track_data.get("name", ""),
373
+ artist=track_data.get("artist", {}).get("name", ""),
374
+ mbid=track_data.get("mbid"),
375
+ url=track_data.get("url"),
376
+ )
377
+ tracks.append(track)
378
+
379
+ logger.info(
380
+ "Similar tracks search completed",
381
+ seed_artist=artist,
382
+ seed_track=track,
383
+ results_count=len(tracks)
384
+ )
385
+
386
+ return tracks
387
+
388
+ except Exception as e:
389
+ logger.error(
390
+ "Similar tracks search failed",
391
+ artist=artist,
392
+ track=track,
393
+ error=str(e)
394
+ )
395
+ return []
396
+
397
+ async def get_artist_info(self, artist: str) -> Optional[ArtistMetadata]:
398
+ """
399
+ Get detailed artist information.
400
+
401
+ Args:
402
+ artist: Artist name
403
+
404
+ Returns:
405
+ Artist metadata or None if not found
406
+ """
407
+ try:
408
+ data = await self._make_request(
409
+ "artist.getInfo",
410
+ {"artist": artist}
411
+ )
412
+
413
+ if "artist" not in data:
414
+ return None
415
+
416
+ artist_data = data["artist"]
417
+
418
+ # Extract tags
419
+ tags = []
420
+ if "tags" in artist_data and "tag" in artist_data["tags"]:
421
+ tag_list = artist_data["tags"]["tag"]
422
+ if isinstance(tag_list, dict):
423
+ tag_list = [tag_list]
424
+ tags = [tag.get("name", "") for tag in tag_list]
425
+
426
+ # Extract similar artists
427
+ similar_artists = []
428
+ if "similar" in artist_data and "artist" in artist_data["similar"]:
429
+ similar_list = artist_data["similar"]["artist"]
430
+ if isinstance(similar_list, dict):
431
+ similar_list = [similar_list]
432
+ similar_artists = [a.get("name", "") for a in similar_list]
433
+
434
+ return ArtistMetadata(
435
+ name=artist_data.get("name", ""),
436
+ mbid=artist_data.get("mbid"),
437
+ url=artist_data.get("url"),
438
+ tags=tags,
439
+ similar_artists=similar_artists,
440
+ listeners=int(artist_data.get("stats", {}).get("listeners", 0)),
441
+ playcount=int(artist_data.get("stats", {}).get("playcount", 0)),
442
+ bio=artist_data.get("bio", {}).get("summary", "")
443
+ )
444
+
445
+ except Exception as e:
446
+ logger.error(
447
+ "Get artist info failed",
448
+ artist=artist,
449
+ error=str(e)
450
+ )
451
+ return None
452
+
453
+ async def search_by_tags(
454
+ self,
455
+ tags: List[str],
456
+ limit: int = 20
457
+ ) -> List[TrackMetadata]:
458
+ """
459
+ Search tracks by tags for genre/mood discovery.
460
+
461
+ Args:
462
+ tags: List of tags (e.g., ["indie", "experimental"])
463
+ limit: Maximum results
464
+
465
+ Returns:
466
+ List of tracks matching tags
467
+ """
468
+ tracks = []
469
+
470
+ for tag in tags:
471
+ try:
472
+ data = await self._make_request(
473
+ "tag.getTopTracks",
474
+ {
475
+ "tag": tag,
476
+ "limit": min(limit // len(tags), 10) # Distribute across tags
477
+ }
478
+ )
479
+
480
+ if "tracks" in data and "track" in data["tracks"]:
481
+ track_list = data["tracks"]["track"]
482
+ if isinstance(track_list, dict):
483
+ track_list = [track_list]
484
+
485
+ for track_data in track_list:
486
+ track = TrackMetadata(
487
+ name=track_data.get("name", ""),
488
+ artist=track_data.get("artist", {}).get("name", ""),
489
+ mbid=track_data.get("mbid"),
490
+ url=track_data.get("url"),
491
+ tags=[tag], # Add the search tag
492
+ )
493
+ tracks.append(track)
494
+
495
+ except Exception as e:
496
+ logger.warning(
497
+ "Tag search failed for tag",
498
+ tag=tag,
499
+ error=str(e)
500
+ )
501
+ continue
502
+
503
+ logger.info(
504
+ "Tag search completed",
505
+ tags=tags,
506
+ results_count=len(tracks)
507
+ )
508
+
509
+ return tracks[:limit] # Limit final results
src/api/spotify_client.py ADDED
@@ -0,0 +1,511 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Spotify Web API Client
3
+
4
+ Provides access to Spotify's database for audio features and preview URLs.
5
+ Used as secondary data source for BeatDebate.
6
+ """
7
+
8
+ import asyncio
9
+ import base64
10
+ import time
11
+ from typing import Dict, List, Optional, Any
12
+ from dataclasses import dataclass
13
+
14
+ import aiohttp
15
+ import structlog
16
+
17
+ logger = structlog.get_logger(__name__)
18
+
19
+
20
+ @dataclass
21
+ class SpotifyTrack:
22
+ """Spotify track data."""
23
+ id: str
24
+ name: str
25
+ artist: str
26
+ album: str
27
+ preview_url: Optional[str] = None
28
+ external_urls: Optional[Dict[str, str]] = None
29
+ duration_ms: Optional[int] = None
30
+ popularity: Optional[int] = None
31
+
32
+
33
+ @dataclass
34
+ class AudioFeatures:
35
+ """Spotify audio features."""
36
+ track_id: str
37
+ danceability: float
38
+ energy: float
39
+ valence: float
40
+ acousticness: float
41
+ instrumentalness: float
42
+ speechiness: float
43
+ liveness: float
44
+ loudness: float
45
+ tempo: float
46
+ time_signature: int
47
+ key: int
48
+ mode: int
49
+
50
+
51
+ class SpotifyRateLimiter:
52
+ """Rate limiter for Spotify API calls."""
53
+
54
+ def __init__(self, calls_per_hour: int = 50):
55
+ self.calls_per_hour = calls_per_hour
56
+ self.min_interval = 3600.0 / calls_per_hour # seconds between calls
57
+ self.last_call = 0.0
58
+
59
+ async def wait_if_needed(self) -> None:
60
+ """Wait if necessary to respect rate limits."""
61
+ now = time.time()
62
+ time_since_last = now - self.last_call
63
+
64
+ if time_since_last < self.min_interval:
65
+ wait_time = self.min_interval - time_since_last
66
+ await asyncio.sleep(wait_time)
67
+
68
+ self.last_call = time.time()
69
+
70
+
71
+ class SpotifyClient:
72
+ """
73
+ Spotify Web API client with authentication and rate limiting.
74
+
75
+ Focuses on audio features and preview URLs for BeatDebate.
76
+ """
77
+
78
+ BASE_URL = "https://api.spotify.com/v1"
79
+ AUTH_URL = "https://accounts.spotify.com/api/token"
80
+
81
+ def __init__(
82
+ self,
83
+ client_id: str,
84
+ client_secret: str,
85
+ rate_limit: int = 50
86
+ ):
87
+ """
88
+ Initialize Spotify client.
89
+
90
+ Args:
91
+ client_id: Spotify client ID
92
+ client_secret: Spotify client secret
93
+ rate_limit: Requests per hour (default: 50)
94
+ """
95
+ self.client_id = client_id
96
+ self.client_secret = client_secret
97
+ self.rate_limiter = SpotifyRateLimiter(rate_limit)
98
+ self.session: Optional[aiohttp.ClientSession] = None
99
+ self.access_token: Optional[str] = None
100
+ self.token_expires_at: float = 0.0
101
+
102
+ async def __aenter__(self):
103
+ """Async context manager entry."""
104
+ self.session = aiohttp.ClientSession()
105
+ await self._authenticate()
106
+ return self
107
+
108
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
109
+ """Async context manager exit."""
110
+ if self.session:
111
+ await self.session.close()
112
+
113
+ async def _authenticate(self) -> None:
114
+ """Authenticate with Spotify API using client credentials flow."""
115
+ if not self.session:
116
+ raise RuntimeError("Client not initialized. Use async context manager.")
117
+
118
+ # Prepare authentication
119
+ auth_str = f"{self.client_id}:{self.client_secret}"
120
+ auth_b64 = base64.b64encode(auth_str.encode()).decode()
121
+
122
+ headers = {
123
+ "Authorization": f"Basic {auth_b64}",
124
+ "Content-Type": "application/x-www-form-urlencoded"
125
+ }
126
+
127
+ data = {"grant_type": "client_credentials"}
128
+
129
+ try:
130
+ async with self.session.post(
131
+ self.AUTH_URL,
132
+ headers=headers,
133
+ data=data,
134
+ timeout=aiohttp.ClientTimeout(total=10)
135
+ ) as response:
136
+ if response.status == 200:
137
+ token_data = await response.json()
138
+ self.access_token = token_data["access_token"]
139
+ expires_in = token_data.get("expires_in", 3600)
140
+ self.token_expires_at = time.time() + expires_in - 60 # 1min buffer
141
+
142
+ logger.info(
143
+ "Spotify authentication successful",
144
+ expires_in=expires_in
145
+ )
146
+ else:
147
+ error_data = await response.json()
148
+ logger.error(
149
+ "Spotify authentication failed",
150
+ status=response.status,
151
+ error=error_data
152
+ )
153
+ raise Exception(f"Spotify auth failed: {error_data}")
154
+
155
+ except Exception as e:
156
+ logger.error("Spotify authentication error", error=str(e))
157
+ raise
158
+
159
+ async def _ensure_valid_token(self) -> None:
160
+ """Ensure we have a valid access token."""
161
+ if not self.access_token or time.time() >= self.token_expires_at:
162
+ await self._authenticate()
163
+
164
+ async def _make_request(
165
+ self,
166
+ endpoint: str,
167
+ params: Optional[Dict[str, Any]] = None,
168
+ retries: int = 3
169
+ ) -> Dict[str, Any]:
170
+ """
171
+ Make authenticated request to Spotify API.
172
+
173
+ Args:
174
+ endpoint: API endpoint (without base URL)
175
+ params: Query parameters
176
+ retries: Number of retry attempts
177
+
178
+ Returns:
179
+ API response data
180
+ """
181
+ if not self.session:
182
+ raise RuntimeError("Client not initialized. Use async context manager.")
183
+
184
+ await self._ensure_valid_token()
185
+ await self.rate_limiter.wait_if_needed()
186
+
187
+ headers = {
188
+ "Authorization": f"Bearer {self.access_token}",
189
+ "Content-Type": "application/json"
190
+ }
191
+
192
+ url = f"{self.BASE_URL}/{endpoint.lstrip('/')}"
193
+
194
+ for attempt in range(retries + 1):
195
+ try:
196
+ async with self.session.get(
197
+ url,
198
+ headers=headers,
199
+ params=params or {},
200
+ timeout=aiohttp.ClientTimeout(total=10)
201
+ ) as response:
202
+ if response.status == 200:
203
+ data = await response.json()
204
+ logger.debug(
205
+ "Spotify API request successful",
206
+ endpoint=endpoint,
207
+ params=params
208
+ )
209
+ return data
210
+
211
+ elif response.status == 401: # Unauthorized
212
+ logger.warning("Spotify token expired, re-authenticating")
213
+ await self._authenticate()
214
+ # Don't count this as a retry attempt
215
+ continue
216
+
217
+ elif response.status == 429: # Rate limited
218
+ retry_after = int(response.headers.get('Retry-After', 1))
219
+ logger.warning(
220
+ "Rate limited by Spotify",
221
+ retry_after=retry_after,
222
+ attempt=attempt
223
+ )
224
+ await asyncio.sleep(retry_after)
225
+ continue
226
+
227
+ elif response.status == 404: # Not found
228
+ logger.debug(
229
+ "Spotify resource not found",
230
+ endpoint=endpoint,
231
+ params=params
232
+ )
233
+ return {}
234
+
235
+ else:
236
+ logger.warning(
237
+ "Spotify API request failed",
238
+ status=response.status,
239
+ endpoint=endpoint
240
+ )
241
+ response.raise_for_status()
242
+
243
+ except asyncio.TimeoutError:
244
+ logger.warning(
245
+ "Spotify API timeout",
246
+ attempt=attempt,
247
+ endpoint=endpoint
248
+ )
249
+ if attempt == retries:
250
+ raise
251
+ await asyncio.sleep(2 ** attempt)
252
+
253
+ except Exception as e:
254
+ logger.error(
255
+ "Spotify API request failed",
256
+ error=str(e),
257
+ attempt=attempt,
258
+ endpoint=endpoint
259
+ )
260
+ if attempt == retries:
261
+ raise
262
+ await asyncio.sleep(2 ** attempt)
263
+
264
+ raise Exception(f"Failed to complete Spotify request after {retries + 1} attempts")
265
+
266
+ async def search_track(
267
+ self,
268
+ artist: str,
269
+ track: str,
270
+ limit: int = 1
271
+ ) -> Optional[SpotifyTrack]:
272
+ """
273
+ Search for a track on Spotify.
274
+
275
+ Args:
276
+ artist: Artist name
277
+ track: Track name
278
+ limit: Number of results (default: 1)
279
+
280
+ Returns:
281
+ First matching Spotify track or None
282
+ """
283
+ try:
284
+ query = f"artist:{artist} track:{track}"
285
+ data = await self._make_request(
286
+ "search",
287
+ {
288
+ "q": query,
289
+ "type": "track",
290
+ "limit": limit
291
+ }
292
+ )
293
+
294
+ if "tracks" in data and data["tracks"]["items"]:
295
+ track_data = data["tracks"]["items"][0]
296
+
297
+ return SpotifyTrack(
298
+ id=track_data["id"],
299
+ name=track_data["name"],
300
+ artist=track_data["artists"][0]["name"],
301
+ album=track_data["album"]["name"],
302
+ preview_url=track_data.get("preview_url"),
303
+ external_urls=track_data.get("external_urls"),
304
+ duration_ms=track_data.get("duration_ms"),
305
+ popularity=track_data.get("popularity")
306
+ )
307
+
308
+ return None
309
+
310
+ except Exception as e:
311
+ logger.error(
312
+ "Spotify track search failed",
313
+ artist=artist,
314
+ track=track,
315
+ error=str(e)
316
+ )
317
+ return None
318
+
319
+ async def get_track(self, track_id: str) -> Optional[SpotifyTrack]:
320
+ """
321
+ Get track details by Spotify ID.
322
+
323
+ Args:
324
+ track_id: Spotify track ID
325
+
326
+ Returns:
327
+ Track details or None
328
+ """
329
+ try:
330
+ data = await self._make_request(f"tracks/{track_id}")
331
+
332
+ if not data:
333
+ return None
334
+
335
+ return SpotifyTrack(
336
+ id=data["id"],
337
+ name=data["name"],
338
+ artist=data["artists"][0]["name"],
339
+ album=data["album"]["name"],
340
+ preview_url=data.get("preview_url"),
341
+ external_urls=data.get("external_urls"),
342
+ duration_ms=data.get("duration_ms"),
343
+ popularity=data.get("popularity")
344
+ )
345
+
346
+ except Exception as e:
347
+ logger.error(
348
+ "Get Spotify track failed",
349
+ track_id=track_id,
350
+ error=str(e)
351
+ )
352
+ return None
353
+
354
+ async def get_audio_features(self, track_id: str) -> Optional[AudioFeatures]:
355
+ """
356
+ Get audio features for a track.
357
+
358
+ Args:
359
+ track_id: Spotify track ID
360
+
361
+ Returns:
362
+ Audio features or None
363
+ """
364
+ try:
365
+ data = await self._make_request(f"audio-features/{track_id}")
366
+
367
+ if not data or "id" not in data:
368
+ return None
369
+
370
+ return AudioFeatures(
371
+ track_id=data["id"],
372
+ danceability=data.get("danceability", 0.0),
373
+ energy=data.get("energy", 0.0),
374
+ valence=data.get("valence", 0.0),
375
+ acousticness=data.get("acousticness", 0.0),
376
+ instrumentalness=data.get("instrumentalness", 0.0),
377
+ speechiness=data.get("speechiness", 0.0),
378
+ liveness=data.get("liveness", 0.0),
379
+ loudness=data.get("loudness", 0.0),
380
+ tempo=data.get("tempo", 0.0),
381
+ time_signature=data.get("time_signature", 4),
382
+ key=data.get("key", 0),
383
+ mode=data.get("mode", 0)
384
+ )
385
+
386
+ except Exception as e:
387
+ logger.error(
388
+ "Get audio features failed",
389
+ track_id=track_id,
390
+ error=str(e)
391
+ )
392
+ return None
393
+
394
+ async def get_multiple_audio_features(
395
+ self,
396
+ track_ids: List[str]
397
+ ) -> Dict[str, AudioFeatures]:
398
+ """
399
+ Get audio features for multiple tracks (batch request).
400
+
401
+ Args:
402
+ track_ids: List of Spotify track IDs
403
+
404
+ Returns:
405
+ Dictionary mapping track IDs to audio features
406
+ """
407
+ features = {}
408
+
409
+ # Process in batches of 100 (Spotify limit)
410
+ for i in range(0, len(track_ids), 100):
411
+ batch = track_ids[i:i + 100]
412
+
413
+ try:
414
+ data = await self._make_request(
415
+ "audio-features",
416
+ {"ids": ",".join(batch)}
417
+ )
418
+
419
+ if "audio_features" in data:
420
+ for feature_data in data["audio_features"]:
421
+ if feature_data and "id" in feature_data:
422
+ features[feature_data["id"]] = AudioFeatures(
423
+ track_id=feature_data["id"],
424
+ danceability=feature_data.get("danceability", 0.0),
425
+ energy=feature_data.get("energy", 0.0),
426
+ valence=feature_data.get("valence", 0.0),
427
+ acousticness=feature_data.get("acousticness", 0.0),
428
+ instrumentalness=feature_data.get("instrumentalness", 0.0),
429
+ speechiness=feature_data.get("speechiness", 0.0),
430
+ liveness=feature_data.get("liveness", 0.0),
431
+ loudness=feature_data.get("loudness", 0.0),
432
+ tempo=feature_data.get("tempo", 0.0),
433
+ time_signature=feature_data.get("time_signature", 4),
434
+ key=feature_data.get("key", 0),
435
+ mode=feature_data.get("mode", 0)
436
+ )
437
+
438
+ except Exception as e:
439
+ logger.error(
440
+ "Batch audio features request failed",
441
+ batch_size=len(batch),
442
+ error=str(e)
443
+ )
444
+ continue
445
+
446
+ logger.info(
447
+ "Audio features batch completed",
448
+ requested=len(track_ids),
449
+ retrieved=len(features)
450
+ )
451
+
452
+ return features
453
+
454
+ async def search_tracks(
455
+ self,
456
+ query: str,
457
+ limit: int = 20,
458
+ offset: int = 0
459
+ ) -> List[SpotifyTrack]:
460
+ """
461
+ Search for tracks with a general query.
462
+
463
+ Args:
464
+ query: Search query
465
+ limit: Number of results
466
+ offset: Result offset
467
+
468
+ Returns:
469
+ List of matching tracks
470
+ """
471
+ try:
472
+ data = await self._make_request(
473
+ "search",
474
+ {
475
+ "q": query,
476
+ "type": "track",
477
+ "limit": limit,
478
+ "offset": offset
479
+ }
480
+ )
481
+
482
+ tracks = []
483
+ if "tracks" in data and "items" in data["tracks"]:
484
+ for track_data in data["tracks"]["items"]:
485
+ track = SpotifyTrack(
486
+ id=track_data["id"],
487
+ name=track_data["name"],
488
+ artist=track_data["artists"][0]["name"],
489
+ album=track_data["album"]["name"],
490
+ preview_url=track_data.get("preview_url"),
491
+ external_urls=track_data.get("external_urls"),
492
+ duration_ms=track_data.get("duration_ms"),
493
+ popularity=track_data.get("popularity")
494
+ )
495
+ tracks.append(track)
496
+
497
+ logger.info(
498
+ "Spotify search completed",
499
+ query=query,
500
+ results_count=len(tracks)
501
+ )
502
+
503
+ return tracks
504
+
505
+ except Exception as e:
506
+ logger.error(
507
+ "Spotify search failed",
508
+ query=query,
509
+ error=str(e)
510
+ )
511
+ return []
src/main.py ADDED
@@ -0,0 +1,199 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ BeatDebate - Main Application Entry Point
3
+
4
+ Multi-agent music recommendation system using sophisticated planning behavior.
5
+ Built for the AgentX competition.
6
+ """
7
+
8
+ import logging
9
+ import os
10
+ from contextlib import asynccontextmanager
11
+ from typing import Dict, Any
12
+
13
+ import structlog
14
+ import uvicorn
15
+ from fastapi import FastAPI, HTTPException
16
+ from fastapi.middleware.cors import CORSMiddleware
17
+ from pydantic import BaseModel
18
+ from dotenv import load_dotenv
19
+
20
+ # Load environment variables
21
+ load_dotenv()
22
+
23
+ # Configure structured logging
24
+ structlog.configure(
25
+ processors=[
26
+ structlog.stdlib.filter_by_level,
27
+ structlog.stdlib.add_logger_name,
28
+ structlog.stdlib.add_log_level,
29
+ structlog.stdlib.PositionalArgumentsFormatter(),
30
+ structlog.processors.TimeStamper(fmt="ISO"),
31
+ structlog.processors.StackInfoRenderer(),
32
+ structlog.processors.format_exc_info,
33
+ structlog.processors.UnicodeDecoder(),
34
+ structlog.processors.JSONRenderer()
35
+ ],
36
+ context_class=dict,
37
+ logger_factory=structlog.stdlib.LoggerFactory(),
38
+ wrapper_class=structlog.stdlib.BoundLogger,
39
+ cache_logger_on_first_use=True,
40
+ )
41
+
42
+ logger = structlog.get_logger(__name__)
43
+
44
+
45
+ class HealthResponse(BaseModel):
46
+ """Health check response model."""
47
+ status: str
48
+ version: str
49
+ environment: str
50
+ api_keys_configured: Dict[str, bool]
51
+ dependencies: Dict[str, str]
52
+
53
+
54
+ @asynccontextmanager
55
+ async def lifespan(app: FastAPI):
56
+ """Application lifespan manager."""
57
+ logger.info("🎵 Starting BeatDebate application")
58
+
59
+ # Startup validation
60
+ required_env_vars = ["GEMINI_API_KEY", "LASTFM_API_KEY", "SPOTIFY_CLIENT_ID"]
61
+ missing_vars = [var for var in required_env_vars if not os.getenv(var)]
62
+
63
+ if missing_vars:
64
+ logger.warning(
65
+ "Missing environment variables",
66
+ missing_vars=missing_vars
67
+ )
68
+ else:
69
+ logger.info("✅ All required API keys configured")
70
+
71
+ yield
72
+
73
+ logger.info("🎵 Shutting down BeatDebate application")
74
+
75
+
76
+ # Create FastAPI application
77
+ app = FastAPI(
78
+ title="BeatDebate",
79
+ description="Multi-Agent Music Recommendation System",
80
+ version="0.1.0",
81
+ docs_url="/docs",
82
+ redoc_url="/redoc",
83
+ lifespan=lifespan
84
+ )
85
+
86
+ # Add CORS middleware for web interface
87
+ app.add_middleware(
88
+ CORSMiddleware,
89
+ allow_origins=["*"], # Configure appropriately for production
90
+ allow_credentials=True,
91
+ allow_methods=["*"],
92
+ allow_headers=["*"],
93
+ )
94
+
95
+
96
+ @app.get("/", response_model=Dict[str, str])
97
+ async def root() -> Dict[str, str]:
98
+ """Root endpoint with basic information."""
99
+ return {
100
+ "name": "BeatDebate",
101
+ "description": "Multi-Agent Music Recommendation System",
102
+ "version": "0.1.0",
103
+ "status": "active",
104
+ "docs": "/docs",
105
+ "health": "/health"
106
+ }
107
+
108
+
109
+ @app.get("/health", response_model=HealthResponse)
110
+ async def health_check() -> HealthResponse:
111
+ """Comprehensive health check endpoint."""
112
+ try:
113
+ # Check environment configuration
114
+ api_keys_configured = {
115
+ "gemini": bool(os.getenv("GEMINI_API_KEY")),
116
+ "lastfm": bool(os.getenv("LASTFM_API_KEY")),
117
+ "spotify": bool(os.getenv("SPOTIFY_CLIENT_ID") and os.getenv("SPOTIFY_CLIENT_SECRET")),
118
+ }
119
+
120
+ # Check dependencies (basic import test)
121
+ dependencies = {}
122
+ try:
123
+ import langchain
124
+ dependencies["langchain"] = langchain.__version__
125
+ except ImportError:
126
+ dependencies["langchain"] = "not_available"
127
+
128
+ try:
129
+ import chromadb
130
+ dependencies["chromadb"] = chromadb.__version__
131
+ except ImportError:
132
+ dependencies["chromadb"] = "not_available"
133
+
134
+ try:
135
+ import sentence_transformers
136
+ dependencies["sentence_transformers"] = sentence_transformers.__version__
137
+ except ImportError:
138
+ dependencies["sentence_transformers"] = "not_available"
139
+
140
+ environment = os.getenv("ENVIRONMENT", "development")
141
+
142
+ logger.info(
143
+ "Health check completed",
144
+ api_keys=api_keys_configured,
145
+ dependencies=dependencies,
146
+ environment=environment
147
+ )
148
+
149
+ return HealthResponse(
150
+ status="healthy",
151
+ version="0.1.0",
152
+ environment=environment,
153
+ api_keys_configured=api_keys_configured,
154
+ dependencies=dependencies
155
+ )
156
+
157
+ except Exception as e:
158
+ logger.error("Health check failed", error=str(e))
159
+ raise HTTPException(status_code=500, detail=f"Health check failed: {str(e)}")
160
+
161
+
162
+ @app.get("/api/test")
163
+ async def test_endpoint() -> Dict[str, Any]:
164
+ """Test endpoint for development."""
165
+ return {
166
+ "message": "BeatDebate API is working!",
167
+ "timestamp": "2025-01-25T10:00:00Z",
168
+ "agents": {
169
+ "planner": "ready",
170
+ "genre_mood": "ready",
171
+ "discovery": "ready",
172
+ "judge": "ready"
173
+ },
174
+ "data_sources": {
175
+ "lastfm": "configured" if os.getenv("LASTFM_API_KEY") else "missing",
176
+ "spotify": "configured" if os.getenv("SPOTIFY_CLIENT_ID") else "missing"
177
+ }
178
+ }
179
+
180
+
181
+ if __name__ == "__main__":
182
+ # Get configuration from environment
183
+ port = int(os.getenv("GRADIO_SERVER_PORT", 7860))
184
+ debug = os.getenv("DEBUG_MODE", "true").lower() == "true"
185
+
186
+ logger.info(
187
+ "Starting BeatDebate server",
188
+ port=port,
189
+ debug=debug,
190
+ environment=os.getenv("ENVIRONMENT", "development")
191
+ )
192
+
193
+ uvicorn.run(
194
+ "src.main:app",
195
+ host="0.0.0.0",
196
+ port=port,
197
+ reload=debug,
198
+ log_level="info"
199
+ )
src/models/__init__.py ADDED
File without changes
src/services/__init__.py ADDED
File without changes
src/services/cache_manager.py ADDED
@@ -0,0 +1,436 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Cache Management System
3
+
4
+ Provides file-based caching with TTL for API responses and track metadata.
5
+ Optimized for BeatDebate's specific caching needs.
6
+ """
7
+
8
+ import json
9
+ import hashlib
10
+ import time
11
+ import os
12
+ from pathlib import Path
13
+ from typing import Any, Dict, Optional, List
14
+ from dataclasses import asdict, is_dataclass
15
+
16
+ import structlog
17
+ from diskcache import Cache
18
+
19
+ logger = structlog.get_logger(__name__)
20
+
21
+
22
+ class CacheManager:
23
+ """
24
+ File-based cache manager with TTL support.
25
+
26
+ Handles caching for:
27
+ - Last.fm API responses
28
+ - Spotify API responses
29
+ - Track metadata
30
+ - Agent strategies
31
+ - User preferences
32
+ """
33
+
34
+ def __init__(self, cache_dir: str = "data/cache"):
35
+ """
36
+ Initialize cache manager.
37
+
38
+ Args:
39
+ cache_dir: Directory for cache storage
40
+ """
41
+ self.cache_dir = Path(cache_dir)
42
+ self.cache_dir.mkdir(parents=True, exist_ok=True)
43
+
44
+ # Initialize diskcache for different data types
45
+ self.caches = {
46
+ "lastfm": Cache(str(self.cache_dir / "lastfm")),
47
+ "spotify": Cache(str(self.cache_dir / "spotify")),
48
+ "tracks": Cache(str(self.cache_dir / "tracks")),
49
+ "strategies": Cache(str(self.cache_dir / "strategies")),
50
+ "preferences": Cache(str(self.cache_dir / "preferences")),
51
+ "embeddings": Cache(str(self.cache_dir / "embeddings"))
52
+ }
53
+
54
+ # Default TTL values (in seconds)
55
+ self.default_ttl = {
56
+ "lastfm": 7 * 24 * 3600, # 1 week
57
+ "spotify": 7 * 24 * 3600, # 1 week
58
+ "tracks": 3 * 24 * 3600, # 3 days
59
+ "strategies": 12 * 3600, # 12 hours
60
+ "preferences": 24 * 3600, # 1 day
61
+ "embeddings": 30 * 24 * 3600, # 30 days
62
+ }
63
+
64
+ logger.info(
65
+ "Cache manager initialized",
66
+ cache_dir=str(self.cache_dir),
67
+ cache_types=list(self.caches.keys())
68
+ )
69
+
70
+ def _serialize_value(self, value: Any) -> Any:
71
+ """Serialize value for caching."""
72
+ if is_dataclass(value):
73
+ return asdict(value)
74
+ elif isinstance(value, list) and value and is_dataclass(value[0]):
75
+ return [asdict(item) for item in value]
76
+ return value
77
+
78
+ def _generate_key(self, *args, **kwargs) -> str:
79
+ """Generate cache key from arguments."""
80
+ key_data = {
81
+ "args": args,
82
+ "kwargs": sorted(kwargs.items())
83
+ }
84
+ key_str = json.dumps(key_data, sort_keys=True, default=str)
85
+ return hashlib.md5(key_str.encode()).hexdigest()
86
+
87
+ def get(
88
+ self,
89
+ cache_type: str,
90
+ key: str,
91
+ default: Any = None
92
+ ) -> Any:
93
+ """
94
+ Get value from cache.
95
+
96
+ Args:
97
+ cache_type: Type of cache (lastfm, spotify, etc.)
98
+ key: Cache key
99
+ default: Default value if not found
100
+
101
+ Returns:
102
+ Cached value or default
103
+ """
104
+ if cache_type not in self.caches:
105
+ logger.warning("Invalid cache type", cache_type=cache_type)
106
+ return default
107
+
108
+ try:
109
+ cache = self.caches[cache_type]
110
+ value = cache.get(key, default)
111
+
112
+ if value is not default:
113
+ logger.debug(
114
+ "Cache hit",
115
+ cache_type=cache_type,
116
+ key=key[:16] + "..."
117
+ )
118
+ else:
119
+ logger.debug(
120
+ "Cache miss",
121
+ cache_type=cache_type,
122
+ key=key[:16] + "..."
123
+ )
124
+
125
+ return value
126
+
127
+ except Exception as e:
128
+ logger.error(
129
+ "Cache get failed",
130
+ cache_type=cache_type,
131
+ key=key[:16] + "...",
132
+ error=str(e)
133
+ )
134
+ return default
135
+
136
+ def set(
137
+ self,
138
+ cache_type: str,
139
+ key: str,
140
+ value: Any,
141
+ ttl: Optional[int] = None
142
+ ) -> bool:
143
+ """
144
+ Set value in cache.
145
+
146
+ Args:
147
+ cache_type: Type of cache
148
+ key: Cache key
149
+ value: Value to cache
150
+ ttl: Time to live in seconds (uses default if None)
151
+
152
+ Returns:
153
+ True if successful
154
+ """
155
+ if cache_type not in self.caches:
156
+ logger.warning("Invalid cache type", cache_type=cache_type)
157
+ return False
158
+
159
+ try:
160
+ cache = self.caches[cache_type]
161
+ if ttl is None:
162
+ ttl = self.default_ttl.get(cache_type, 3600)
163
+
164
+ # Serialize dataclasses
165
+ serialized_value = self._serialize_value(value)
166
+
167
+ cache.set(key, serialized_value, expire=ttl)
168
+
169
+ logger.debug(
170
+ "Cache set",
171
+ cache_type=cache_type,
172
+ key=key[:16] + "...",
173
+ ttl=ttl
174
+ )
175
+
176
+ return True
177
+
178
+ except Exception as e:
179
+ logger.error(
180
+ "Cache set failed",
181
+ cache_type=cache_type,
182
+ key=key[:16] + "...",
183
+ error=str(e)
184
+ )
185
+ return False
186
+
187
+ def delete(self, cache_type: str, key: str) -> bool:
188
+ """
189
+ Delete value from cache.
190
+
191
+ Args:
192
+ cache_type: Type of cache
193
+ key: Cache key
194
+
195
+ Returns:
196
+ True if successful
197
+ """
198
+ if cache_type not in self.caches:
199
+ return False
200
+
201
+ try:
202
+ cache = self.caches[cache_type]
203
+ return cache.delete(key)
204
+
205
+ except Exception as e:
206
+ logger.error(
207
+ "Cache delete failed",
208
+ cache_type=cache_type,
209
+ key=key[:16] + "...",
210
+ error=str(e)
211
+ )
212
+ return False
213
+
214
+ def clear(self, cache_type: Optional[str] = None) -> bool:
215
+ """
216
+ Clear cache(s).
217
+
218
+ Args:
219
+ cache_type: Specific cache to clear (all if None)
220
+
221
+ Returns:
222
+ True if successful
223
+ """
224
+ try:
225
+ if cache_type:
226
+ if cache_type in self.caches:
227
+ self.caches[cache_type].clear()
228
+ logger.info("Cache cleared", cache_type=cache_type)
229
+ return True
230
+ else:
231
+ logger.warning("Invalid cache type", cache_type=cache_type)
232
+ return False
233
+ else:
234
+ for cache_name, cache in self.caches.items():
235
+ cache.clear()
236
+ logger.info("All caches cleared")
237
+ return True
238
+
239
+ except Exception as e:
240
+ logger.error("Cache clear failed", error=str(e))
241
+ return False
242
+
243
+ def get_stats(self) -> Dict[str, Dict[str, Any]]:
244
+ """
245
+ Get cache statistics.
246
+
247
+ Returns:
248
+ Dictionary with cache stats
249
+ """
250
+ stats = {}
251
+
252
+ for cache_name, cache in self.caches.items():
253
+ try:
254
+ # Get basic stats
255
+ cache_stats = {
256
+ "size": len(cache),
257
+ "volume": cache.volume(),
258
+ "directory": str(cache.directory)
259
+ }
260
+
261
+ stats[cache_name] = cache_stats
262
+
263
+ except Exception as e:
264
+ logger.error(
265
+ "Failed to get cache stats",
266
+ cache_name=cache_name,
267
+ error=str(e)
268
+ )
269
+ stats[cache_name] = {"error": str(e)}
270
+
271
+ return stats
272
+
273
+ def warm_cache(self, cache_type: str, warm_data: Dict[str, Any]) -> int:
274
+ """
275
+ Warm cache with pre-computed data.
276
+
277
+ Args:
278
+ cache_type: Type of cache to warm
279
+ warm_data: Dictionary of key-value pairs
280
+
281
+ Returns:
282
+ Number of items cached
283
+ """
284
+ if cache_type not in self.caches:
285
+ logger.warning("Invalid cache type", cache_type=cache_type)
286
+ return 0
287
+
288
+ cached_count = 0
289
+
290
+ for key, value in warm_data.items():
291
+ if self.set(cache_type, key, value):
292
+ cached_count += 1
293
+
294
+ logger.info(
295
+ "Cache warmed",
296
+ cache_type=cache_type,
297
+ items_cached=cached_count,
298
+ total_items=len(warm_data)
299
+ )
300
+
301
+ return cached_count
302
+
303
+ # Convenience methods for specific cache types
304
+
305
+ def cache_lastfm_response(
306
+ self,
307
+ method: str,
308
+ params: Dict[str, Any],
309
+ response: Any
310
+ ) -> str:
311
+ """Cache Last.fm API response."""
312
+ key = self._generate_key("lastfm", method, **params)
313
+ self.set("lastfm", key, response)
314
+ return key
315
+
316
+ def get_lastfm_response(
317
+ self,
318
+ method: str,
319
+ params: Dict[str, Any]
320
+ ) -> Any:
321
+ """Get cached Last.fm API response."""
322
+ key = self._generate_key("lastfm", method, **params)
323
+ return self.get("lastfm", key)
324
+
325
+ def cache_spotify_response(
326
+ self,
327
+ endpoint: str,
328
+ params: Dict[str, Any],
329
+ response: Any
330
+ ) -> str:
331
+ """Cache Spotify API response."""
332
+ key = self._generate_key("spotify", endpoint, **params)
333
+ self.set("spotify", key, response)
334
+ return key
335
+
336
+ def get_spotify_response(
337
+ self,
338
+ endpoint: str,
339
+ params: Dict[str, Any]
340
+ ) -> Any:
341
+ """Get cached Spotify API response."""
342
+ key = self._generate_key("spotify", endpoint, **params)
343
+ return self.get("spotify", key)
344
+
345
+ def cache_track_metadata(
346
+ self,
347
+ artist: str,
348
+ track: str,
349
+ metadata: Any
350
+ ) -> str:
351
+ """Cache track metadata."""
352
+ key = self._generate_key("track", artist.lower(), track.lower())
353
+ self.set("tracks", key, metadata)
354
+ return key
355
+
356
+ def get_track_metadata(self, artist: str, track: str) -> Any:
357
+ """Get cached track metadata."""
358
+ key = self._generate_key("track", artist.lower(), track.lower())
359
+ return self.get("tracks", key)
360
+
361
+ def cache_strategy(
362
+ self,
363
+ user_query: str,
364
+ strategy: Dict[str, Any]
365
+ ) -> str:
366
+ """Cache planning strategy."""
367
+ key = self._generate_key("strategy", user_query)
368
+ self.set("strategies", key, strategy, ttl=12 * 3600) # 12 hours
369
+ return key
370
+
371
+ def get_strategy(self, user_query: str) -> Optional[Dict[str, Any]]:
372
+ """Get cached planning strategy."""
373
+ key = self._generate_key("strategy", user_query)
374
+ return self.get("strategies", key)
375
+
376
+ def cache_user_preferences(
377
+ self,
378
+ user_id: str,
379
+ preferences: Dict[str, Any]
380
+ ) -> str:
381
+ """Cache user preferences."""
382
+ key = self._generate_key("user_prefs", user_id)
383
+ self.set("preferences", key, preferences)
384
+ return key
385
+
386
+ def get_user_preferences(self, user_id: str) -> Optional[Dict[str, Any]]:
387
+ """Get cached user preferences."""
388
+ key = self._generate_key("user_prefs", user_id)
389
+ return self.get("preferences", key)
390
+
391
+ def cache_embeddings(
392
+ self,
393
+ text_hash: str,
394
+ embeddings: List[float]
395
+ ) -> str:
396
+ """Cache text embeddings."""
397
+ self.set("embeddings", text_hash, embeddings, ttl=30 * 24 * 3600)
398
+ return text_hash
399
+
400
+ def get_embeddings(self, text_hash: str) -> Optional[List[float]]:
401
+ """Get cached text embeddings."""
402
+ return self.get("embeddings", text_hash)
403
+
404
+ def close(self) -> None:
405
+ """Close all cache connections."""
406
+ for cache_name, cache in self.caches.items():
407
+ try:
408
+ cache.close()
409
+ logger.debug("Cache closed", cache_name=cache_name)
410
+ except Exception as e:
411
+ logger.error(
412
+ "Failed to close cache",
413
+ cache_name=cache_name,
414
+ error=str(e)
415
+ )
416
+
417
+
418
+ # Global cache instance
419
+ _cache_manager = None
420
+
421
+
422
+ def get_cache_manager() -> CacheManager:
423
+ """Get global cache manager instance."""
424
+ global _cache_manager
425
+ if _cache_manager is None:
426
+ cache_dir = os.getenv("CACHE_DIR", "data/cache")
427
+ _cache_manager = CacheManager(cache_dir)
428
+ return _cache_manager
429
+
430
+
431
+ def close_cache_manager() -> None:
432
+ """Close global cache manager."""
433
+ global _cache_manager
434
+ if _cache_manager:
435
+ _cache_manager.close()
436
+ _cache_manager = None
src/ui/__init__.py ADDED
File without changes
tests/__init__.py ADDED
File without changes
uv.lock ADDED
The diff for this file is too large to render. See raw diff