diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml
index 4ec30a7..7988421 100644
--- a/.github/workflows/code-quality.yml
+++ b/.github/workflows/code-quality.yml
@@ -105,8 +105,9 @@ jobs:
run: |
just install
# Install workspace packages
- uv pip install -e dashboard
uv pip install -e common
+ uv pip install -e dashboard
+ uv pip install -e explore
- name: ๐งช Run pre-commit hooks
run: |
diff --git a/.github/workflows/docker-validate.yml b/.github/workflows/docker-validate.yml
index 2942145..a2d861d 100644
--- a/.github/workflows/docker-validate.yml
+++ b/.github/workflows/docker-validate.yml
@@ -59,6 +59,7 @@ jobs:
failure-threshold: error
- name: ๐ณ Test Docker build for ${{ matrix.sub-project }}
+ if: matrix.sub-project != 'discovery'
run: |
echo "๐จ Testing build for ${{ matrix.sub-project }}..."
docker build --build-arg PYTHON_VERSION=${{ env.PYTHON_VERSION }} -f ${{ matrix.sub-project }}/Dockerfile . --target builder
@@ -90,7 +91,7 @@ jobs:
- name: ๐ Check docker-compose services
run: |
services=$(docker-compose config --services | sort | tr "\n" " " | sed "s/ $//")
- expected="dashboard discovery extractor graphinator neo4j postgres rabbitmq redis tableinator"
+ expected="dashboard discovery explore extractor graphinator neo4j postgres rabbitmq redis tableinator"
if [ "$services" != "$expected" ]; then
echo "โ Service mismatch!"
@@ -127,6 +128,12 @@ jobs:
exit 1
fi
+ deps=$(docker-compose config | yq eval '.services.explore.depends_on | keys | .[]' -)
+ if [ "$deps" != "neo4j" ]; then
+ echo "โ Explore should only depend on neo4j"
+ exit 1
+ fi
+
deps=$(docker-compose config | yq eval '.services.tableinator.depends_on | keys | sort | join(" ")' -)
if [ "$deps" != "postgres rabbitmq" ]; then
echo "โ Tableinator should depend on postgres and rabbitmq"
@@ -138,7 +145,7 @@ jobs:
- name: ๐ก๏ธ Check for security best practices
run: |
# Check that services run as non-root user
- for service in dashboard discovery extractor graphinator tableinator; do
+ for service in dashboard discovery explore extractor graphinator tableinator; do
user=$(docker-compose config | yq eval ".services.$service.user" -)
if [ "$user" != "1000:1000" ]; then
echo "โ $service should run as user 1000:1000"
@@ -148,7 +155,7 @@ jobs:
echo "โ
All services run as non-root user"
# Check security options
- for service in dashboard discovery extractor graphinator tableinator; do
+ for service in dashboard discovery explore extractor graphinator tableinator; do
security_opt=$(docker-compose config | yq eval ".services.$service.security_opt[]" - | grep "no-new-privileges:true" || true)
if [ -z "$security_opt" ]; then
echo "โ $service should have no-new-privileges security option"
diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml
index 48aa41a..858a2e6 100644
--- a/.github/workflows/e2e-test.yml
+++ b/.github/workflows/e2e-test.yml
@@ -1,6 +1,6 @@
---
# This workflow runs all E2E tests including desktop browsers and mobile device emulation.
-# It can be called from other workflows or triggered directly on dashboard changes.
+# It can be called from other workflows or triggered directly on service changes.
name: E2E Test
on:
@@ -11,7 +11,9 @@ on:
- main
paths:
- "dashboard/**"
+ - "explore/**"
- "tests/dashboard/**"
+ - "tests/explore/**"
- ".github/workflows/e2e-test.yml"
- "common/**"
- "pyproject.toml"
@@ -21,7 +23,9 @@ on:
- main
paths:
- "dashboard/**"
+ - "explore/**"
- "tests/dashboard/**"
+ - "tests/explore/**"
- ".github/workflows/e2e-test.yml"
- "common/**"
- "pyproject.toml"
@@ -39,6 +43,7 @@ env:
permissions:
contents: read
pull-requests: write # Required for coverage report comments
+ statuses: write # Required for coverage status creation
jobs:
e2e-test:
@@ -119,6 +124,7 @@ jobs:
uv sync --all-extras --frozen
# Install workspace packages
uv pip install -e dashboard
+ uv pip install -e explore
uv pip install -e common
- name: ๐ญ Install Playwright browsers
@@ -129,13 +135,26 @@ jobs:
# Install system dependencies
uv run playwright install-deps ${{ matrix.browser-install }}
+ - name: ๐งช Run dashboard unit tests (coverage baseline)
+ run: |
+ # Run unit tests first to establish coverage baseline for dashboard + explore
+ uv run pytest tests/dashboard/ -v -m 'not e2e' \
+ --cov=dashboard --cov=explore --cov-report=term
+
+ - name: ๐งช Run explore unit tests (coverage baseline)
+ run: |
+ # Append explore unit test coverage
+ uv run pytest tests/explore/ -v -m 'not e2e' \
+ --cov=dashboard --cov=explore --cov-append --cov-report=term
+
- name: ๐งช Run dashboard E2E tests (Desktop)
if: matrix.device == null
run: |
# Run E2E tests with coverage - server is started automatically by pytest fixture
uv run pytest tests/dashboard/test_dashboard_ui.py -v -m e2e \
--browser ${{ matrix.browser }} \
- --cov --cov-report=xml --cov-report=json --cov-report=term
+ --cov=dashboard --cov=explore --cov-append \
+ --cov-report=xml --cov-report=json --cov-report=term
- name: ๐ฑ Run dashboard E2E tests (Mobile)
if: matrix.device != null
@@ -144,22 +163,47 @@ jobs:
uv run pytest tests/dashboard/test_dashboard_ui.py -v -m e2e \
--browser ${{ matrix.browser }} \
--device "${{ matrix.device }}" \
- --cov --cov-report=xml --cov-report=json --cov-report=term
+ --cov=dashboard --cov=explore --cov-append \
+ --cov-report=xml --cov-report=json --cov-report=term
+
+ - name: ๐ Run explore E2E tests (Desktop)
+ if: matrix.device == null
+ run: |
+ # Run explore E2E tests with coverage - server is started automatically by pytest fixture
+ uv run pytest tests/explore/test_explore_ui.py -v -m e2e \
+ --browser ${{ matrix.browser }} \
+ --cov=dashboard --cov=explore --cov-append \
+ --cov-report=xml --cov-report=json --cov-report=term
+
+ - name: ๐ฑ Run explore E2E tests (Mobile)
+ if: matrix.device != null
+ run: |
+ # Run explore E2E tests with coverage and device emulation
+ uv run pytest tests/explore/test_explore_ui.py -v -m e2e \
+ --browser ${{ matrix.browser }} \
+ --device "${{ matrix.device }}" \
+ --cov=dashboard --cov=explore --cov-append \
+ --cov-report=xml --cov-report=json --cov-report=term
- name: ๐ Process E2E coverage reports
id: coverage-setup
if: always() # Process coverage even if tests fail
run: |
- # Transform coverage.json (generated by pytest) to coverage-summary.json format
- jq '{
- total: {
- statements: {total: .totals.num_statements, covered: .totals.covered_lines},
- lines: {total: .totals.num_statements, covered: .totals.covered_lines},
- functions: {total: 0, covered: 0},
- branches: {total: 0, covered: 0}
- }
- }' coverage.json > coverage-summary.json
- echo "coverage-exists=true" >> "$GITHUB_OUTPUT"
+ if [ -f coverage.json ]; then
+ # Transform coverage.json (generated by pytest) to coverage-summary.json format
+ jq '{
+ total: {
+ statements: {total: .totals.num_statements, covered: .totals.covered_lines},
+ lines: {total: .totals.num_statements, covered: .totals.covered_lines},
+ functions: {total: 0, covered: 0},
+ branches: {total: 0, covered: 0}
+ }
+ }' coverage.json > coverage-summary.json
+ echo "coverage-exists=true" >> "$GITHUB_OUTPUT"
+ else
+ echo "โ ๏ธ No coverage.json found โ tests may not have run"
+ echo "coverage-exists=false" >> "$GITHUB_OUTPUT"
+ fi
- name: ๐ค Upload test results
if: always()
diff --git a/.github/workflows/list-sub-projects.yml b/.github/workflows/list-sub-projects.yml
index 63b9389..c277039 100644
--- a/.github/workflows/list-sub-projects.yml
+++ b/.github/workflows/list-sub-projects.yml
@@ -33,6 +33,7 @@ jobs:
[
{"name": "dashboard", "use_cache": true},
{"name": "discovery", "use_cache": false},
+ {"name": "explore", "use_cache": true},
{"name": "extractor/pyextractor", "use_cache": true},
{"name": "extractor/rustextractor", "use_cache": true},
{"name": "graphinator", "use_cache": true},
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index a5fbf0b..8473cc7 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -147,6 +147,61 @@ jobs:
flags: discovery
name: discovery-tests
+ # ============================================================================
+ # EXPLORE SERVICE TESTS - Runs in parallel with all other test jobs
+ # ============================================================================
+ test-explore:
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+
+ steps:
+ - name: ๐ Checkout repository
+ uses: actions/checkout@v6
+
+ - name: ๐ง Setup Python and UV
+ uses: ./.github/actions/setup-python-uv
+ with:
+ python-version: ${{ env.PYTHON_VERSION }}
+
+ - name: ๐ง Setup Just
+ uses: ./.github/actions/setup-just
+
+ - name: ๐ฆ Install dependencies
+ run: just install
+
+ - name: ๐งช Run explore tests
+ run: |
+ uv run pytest tests/explore/ -v -m 'not e2e' \
+ --cov=explore --cov-report=xml --cov-report=json --cov-report=term
+
+ - name: ๐ Process coverage reports
+ id: coverage-setup
+ if: always()
+ run: |
+ if [ -f coverage.json ]; then
+ jq '{
+ total: {
+ statements: {total: .totals.num_statements, covered: .totals.covered_lines},
+ lines: {total: .totals.num_statements, covered: .totals.covered_lines},
+ functions: {total: 0, covered: 0},
+ branches: {total: 0, covered: 0}
+ }
+ }' coverage.json > coverage-summary.json
+ echo "coverage-exists=true" >> "$GITHUB_OUTPUT"
+ else
+ echo "coverage-exists=false" >> "$GITHUB_OUTPUT"
+ fi
+
+ - name: ๐ Upload coverage (explore)
+ if: always()
+ uses: codecov/codecov-action@v5
+ with:
+ token: ${{ secrets.CODECOV_TOKEN }}
+ files: ./coverage.xml
+ flags: explore
+ name: explore-tests
+ fail_ci_if_error: false
+
# ============================================================================
# PYTHON EXTRACTOR TESTS - Runs in parallel with all other test jobs
# ============================================================================
@@ -334,6 +389,7 @@ jobs:
- test-common
- test-dashboard
- test-discovery
+ - test-explore
- test-pyextractor
- test-graphinator
- test-tableinator
@@ -347,6 +403,7 @@ jobs:
echo " Common: ${{ needs.test-common.result }}"
echo " Dashboard: ${{ needs.test-dashboard.result }}"
echo " Discovery: ${{ needs.test-discovery.result }}"
+ echo " Explore: ${{ needs.test-explore.result }}"
echo " PyExtractor: ${{ needs.test-pyextractor.result }}"
echo " Graphinator: ${{ needs.test-graphinator.result }}"
echo " Tableinator: ${{ needs.test-tableinator.result }}"
@@ -356,6 +413,7 @@ jobs:
if [[ "${{ needs.test-common.result }}" == "failure" ]] || \
[[ "${{ needs.test-dashboard.result }}" == "failure" ]] || \
[[ "${{ needs.test-discovery.result }}" == "failure" ]] || \
+ [[ "${{ needs.test-explore.result }}" == "failure" ]] || \
[[ "${{ needs.test-pyextractor.result }}" == "failure" ]] || \
[[ "${{ needs.test-graphinator.result }}" == "failure" ]] || \
[[ "${{ needs.test-tableinator.result }}" == "failure" ]] || \
diff --git a/CLAUDE.md b/CLAUDE.md
index e30ce4b..6939de0 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -346,6 +346,7 @@ http://localhost:8000/api/health
### Service Ports
- Dashboard: 8003
+- Explore: 8006 (service), 8007 (health)
- Discovery: 8005 (service), 8004 (health)
- Neo4j: 7474 (browser), 7687 (bolt)
- PostgreSQL: 5433 (mapped from 5432)
diff --git a/README.md b/README.md
index f5feb8a..e394006 100644
--- a/README.md
+++ b/README.md
@@ -47,6 +47,7 @@ Perfect for music researchers, data scientists, developers, and music enthusiast
| **[๐](docs/emoji-guide.md#service-identifiers) Graphinator** | Builds Neo4j knowledge graphs | `neo4j-driver`, graph algorithms |
| **[๐](docs/emoji-guide.md#service-identifiers) Tableinator** | Creates PostgreSQL analytics tables | `psycopg3`, JSONB, full-text search |
| **[๐ต](docs/emoji-guide.md#service-identifiers) Discovery** | AI-powered music intelligence | `sentence-transformers`, `plotly`, `networkx` |
+| **[๐](docs/emoji-guide.md#service-identifiers) Explore** | Interactive graph exploration & trends | `FastAPI`, `D3.js`, `Plotly.js`, Neo4j |
| **[๐](docs/emoji-guide.md#service-identifiers) Dashboard** | Real-time system monitoring | `FastAPI`, WebSocket, reactive UI |
### ๐ System Architecture
@@ -64,6 +65,7 @@ graph TD
TABLE[["๐ Tableinator
Table Builder"]]
DASH[["๐ Dashboard
Real-time Monitor
WebSocket"]]
DISCO[["๐ต Discovery
AI Engine
ML Models"]]
+ EXPLORE[["๐ Explore
Graph Explorer
Trends"]]
S3 -->|1a. Download & Parse| PYEXT
S3 -->|1b. Download & Parse| RSEXT
@@ -79,6 +81,8 @@ graph TD
DISCO -.->|Cache| REDIS
DISCO -.->|Analyze| DISCO
+ EXPLORE -.->|Query| NEO4J
+
DASH -.->|Monitor| PYEXT
DASH -.->|Monitor| RSEXT
DASH -.->|Monitor| GRAPH
@@ -98,6 +102,7 @@ graph TD
style REDIS fill:#ffebee,stroke:#b71c1c,stroke-width:2px
style DASH fill:#fce4ec,stroke:#880e4f,stroke-width:2px
style DISCO fill:#e3f2fd,stroke:#0d47a1,stroke-width:2px
+ style EXPLORE fill:#e8eaf6,stroke:#283593,stroke-width:2px
```
## ๐ Key Features
@@ -212,6 +217,7 @@ open http://localhost:8003
| Service | URL | Default Credentials | Purpose |
| ----------------- | ---------------------- | ----------------------------------- | ------------------ |
| ๐ **Dashboard** | http://localhost:8003 | None | System monitoring |
+| ๐ **Explore** | http://localhost:8006 | None | Graph exploration |
| ๐ต **Discovery** | http://localhost:8005 | None | AI music discovery |
| ๐ฐ **RabbitMQ** | http://localhost:15672 | `discogsography` / `discogsography` | Queue management |
| ๐ **Neo4j** | http://localhost:7474 | `neo4j` / `discogsography` | Graph exploration |
@@ -238,6 +244,7 @@ just init
# 5. Run any service
just dashboard # Monitoring UI
+just explore # Graph exploration & trends
just discovery # AI discovery
just pyextractor # Python data ingestion
just rustextractor-run # Rust data ingestion (requires cargo)
@@ -523,6 +530,7 @@ uv run pytest tests/extractor/ # Extractor tests (Python)
uv run pytest tests/graphinator/ # Graphinator tests
uv run pytest tests/tableinator/ # Tableinator tests
uv run pytest tests/dashboard/ # Dashboard tests
+uv run pytest tests/explore/ # Explore tests
```
#### ๐ญ E2E Testing with Playwright
@@ -566,6 +574,9 @@ discogsography/
โโโ ๐ dashboard/ # Real-time monitoring dashboard
โ โโโ dashboard.py # FastAPI backend with WebSocket
โ โโโ static/ # Frontend HTML/CSS/JS
+โโโ ๐ explore/ # Interactive graph exploration & trends
+โ โโโ explore.py # FastAPI backend with Neo4j queries
+โ โโโ static/ # Frontend HTML/CSS/JS (D3.js, Plotly.js)
โโโ ๐ฅ extractor/ # Data extraction services
โ โโโ pyextractor/ # Python-based Discogs data ingestion
โ โ โโโ extractor.py # Main processing logic
@@ -850,6 +861,7 @@ PGPASSWORD=discogsography psql -h localhost -U discogsography -d discogsography
curl http://localhost:8002/health # Tableinator
curl http://localhost:8003/health # Dashboard
curl http://localhost:8004/health # Discovery
+ curl http://localhost:8007/health # Explore
```
1. **๐ Enable Debug Logging**
diff --git a/common/__init__.py b/common/__init__.py
index f560566..64d68b1 100644
--- a/common/__init__.py
+++ b/common/__init__.py
@@ -7,6 +7,7 @@
AMQP_QUEUE_PREFIX_TABLEINATOR,
DATA_TYPES,
DashboardConfig,
+ ExploreConfig,
ExtractorConfig,
GraphinatorConfig,
TableinatorConfig,
@@ -80,6 +81,7 @@
"CircuitState",
"DashboardConfig",
"DownloadPhase",
+ "ExploreConfig",
"ExponentialBackoff",
"ExtractionSummary",
"ExtractorConfig",
diff --git a/common/config.py b/common/config.py
index 951df19..d6f7100 100644
--- a/common/config.py
+++ b/common/config.py
@@ -355,6 +355,39 @@ def from_env(cls) -> "DashboardConfig":
DiscoveryConfig = DashboardConfig
+@dataclass(frozen=True)
+class ExploreConfig:
+ """Configuration for the explore service."""
+
+ neo4j_address: str
+ neo4j_username: str
+ neo4j_password: str
+
+ @classmethod
+ def from_env(cls) -> "ExploreConfig":
+ """Create configuration from environment variables."""
+ neo4j_address = getenv("NEO4J_ADDRESS")
+ neo4j_username = getenv("NEO4J_USERNAME")
+ neo4j_password = getenv("NEO4J_PASSWORD")
+
+ missing_vars = []
+ if not neo4j_address:
+ missing_vars.append("NEO4J_ADDRESS")
+ if not neo4j_username:
+ missing_vars.append("NEO4J_USERNAME")
+ if not neo4j_password:
+ missing_vars.append("NEO4J_PASSWORD")
+
+ if missing_vars:
+ raise ValueError(f"Missing required environment variables: {', '.join(missing_vars)}")
+
+ return cls(
+ neo4j_address=neo4j_address, # type: ignore
+ neo4j_username=neo4j_username, # type: ignore
+ neo4j_password=neo4j_password, # type: ignore
+ )
+
+
def get_config() -> DashboardConfig:
"""Get dashboard/discovery configuration from environment.
diff --git a/dashboard/dashboard.py b/dashboard/dashboard.py
index 4ea9aad..21780fc 100755
--- a/dashboard/dashboard.py
+++ b/dashboard/dashboard.py
@@ -219,7 +219,6 @@ async def get_service_statuses(self) -> list[ServiceStatus]:
("extractor", "http://extractor:8000/health"),
("graphinator", "http://graphinator:8001/health"),
("tableinator", "http://tableinator:8002/health"),
- ("discovery", "http://discovery:8004/health"),
]
async with httpx.AsyncClient(timeout=5.0) as client:
@@ -530,158 +529,6 @@ async def prometheus_metrics() -> Response:
return Response(content=generate_latest(), media_type="text/plain")
-# Discovery API Proxy Endpoints for Phase 4.3 UI
-
-
-@app.get("/api/discovery/ml/status")
-async def get_ml_status() -> ORJSONResponse:
- """Get ML API status from Discovery service."""
- API_REQUESTS.labels(endpoint="/api/discovery/ml/status", method="GET").inc()
- try:
- async with httpx.AsyncClient(timeout=5.0) as client:
- response = await client.get("http://discovery:8005/api/ml/status")
- if response.status_code == 200:
- return ORJSONResponse(content=response.json())
- return ORJSONResponse(content={"error": f"Discovery API returned {response.status_code}"}, status_code=response.status_code)
- except Exception as e:
- logger.error(f"โ Error fetching ML status: {e}")
- return ORJSONResponse(content={"error": str(e)}, status_code=503)
-
-
-@app.post("/api/discovery/ml/recommend/collaborative")
-async def get_collaborative_recommendations(artist_id: str = "The Beatles", limit: int = 10, min_similarity: float = 0.1) -> ORJSONResponse:
- """Get collaborative filtering recommendations from Discovery service."""
- API_REQUESTS.labels(endpoint="/api/discovery/ml/recommend/collaborative", method="POST").inc()
- try:
- async with httpx.AsyncClient(timeout=10.0) as client:
- response = await client.post(
- "http://discovery:8005/api/ml/recommend/collaborative",
- json={"artist_id": artist_id, "limit": limit, "min_similarity": min_similarity},
- )
- if response.status_code == 200:
- return ORJSONResponse(content=response.json())
- return ORJSONResponse(content={"error": f"Discovery API returned {response.status_code}"}, status_code=response.status_code)
- except Exception as e:
- logger.error(f"โ Error fetching collaborative recommendations: {e}")
- return ORJSONResponse(content={"error": str(e)}, status_code=503)
-
-
-@app.post("/api/discovery/ml/recommend/hybrid")
-async def get_hybrid_recommendations(artist_name: str = "The Beatles", limit: int = 10, strategy: str = "weighted") -> ORJSONResponse:
- """Get hybrid recommendations from Discovery service."""
- API_REQUESTS.labels(endpoint="/api/discovery/ml/recommend/hybrid", method="POST").inc()
- try:
- async with httpx.AsyncClient(timeout=10.0) as client:
- response = await client.post(
- "http://discovery:8005/api/ml/recommend/hybrid",
- json={"artist_name": artist_name, "limit": limit, "strategy": strategy},
- )
- if response.status_code == 200:
- return ORJSONResponse(content=response.json())
- return ORJSONResponse(content={"error": f"Discovery API returned {response.status_code}"}, status_code=response.status_code)
- except Exception as e:
- logger.error(f"โ Error fetching hybrid recommendations: {e}")
- return ORJSONResponse(content={"error": str(e)}, status_code=503)
-
-
-@app.get("/api/discovery/search/status")
-async def get_search_status() -> ORJSONResponse:
- """Get Search API status from Discovery service."""
- API_REQUESTS.labels(endpoint="/api/discovery/search/status", method="GET").inc()
- try:
- async with httpx.AsyncClient(timeout=5.0) as client:
- response = await client.get("http://discovery:8005/api/search/status")
- if response.status_code == 200:
- return ORJSONResponse(content=response.json())
- return ORJSONResponse(content={"error": f"Discovery API returned {response.status_code}"}, status_code=response.status_code)
- except Exception as e:
- logger.error(f"โ Error fetching search status: {e}")
- return ORJSONResponse(content={"error": str(e)}, status_code=503)
-
-
-@app.get("/api/discovery/search/stats")
-async def get_search_stats() -> ORJSONResponse:
- """Get search statistics from Discovery service."""
- API_REQUESTS.labels(endpoint="/api/discovery/search/stats", method="GET").inc()
- try:
- async with httpx.AsyncClient(timeout=5.0) as client:
- response = await client.get("http://discovery:8005/api/search/stats")
- if response.status_code == 200:
- return ORJSONResponse(content=response.json())
- return ORJSONResponse(content={"error": f"Discovery API returned {response.status_code}"}, status_code=response.status_code)
- except Exception as e:
- logger.error(f"โ Error fetching search stats: {e}")
- return ORJSONResponse(content={"error": str(e)}, status_code=503)
-
-
-@app.get("/api/discovery/graph/status")
-async def get_graph_status() -> ORJSONResponse:
- """Get Graph Analytics API status from Discovery service."""
- API_REQUESTS.labels(endpoint="/api/discovery/graph/status", method="GET").inc()
- try:
- async with httpx.AsyncClient(timeout=5.0) as client:
- response = await client.get("http://discovery:8005/api/graph/status")
- if response.status_code == 200:
- return ORJSONResponse(content=response.json())
- return ORJSONResponse(content={"error": f"Discovery API returned {response.status_code}"}, status_code=response.status_code)
- except Exception as e:
- logger.error(f"โ Error fetching graph status: {e}")
- return ORJSONResponse(content={"error": str(e)}, status_code=503)
-
-
-@app.post("/api/discovery/graph/centrality")
-async def get_centrality_metrics(
- metric: str = "pagerank", limit: int = 20, node_type: str = "artist", sample_size: int | None = None
-) -> ORJSONResponse:
- """Get centrality metrics from Discovery service."""
- API_REQUESTS.labels(endpoint="/api/discovery/graph/centrality", method="POST").inc()
- try:
- async with httpx.AsyncClient(timeout=30.0) as client:
- response = await client.post(
- "http://discovery:8005/api/graph/centrality",
- json={"metric": metric, "limit": limit, "node_type": node_type, "sample_size": sample_size},
- )
- if response.status_code == 200:
- return ORJSONResponse(content=response.json())
- return ORJSONResponse(content={"error": f"Discovery API returned {response.status_code}"}, status_code=response.status_code)
- except Exception as e:
- logger.error(f"โ Error fetching centrality metrics: {e}")
- return ORJSONResponse(content={"error": str(e)}, status_code=503)
-
-
-@app.get("/api/discovery/realtime/status")
-async def get_realtime_status() -> ORJSONResponse:
- """Get Real-Time API status from Discovery service."""
- API_REQUESTS.labels(endpoint="/api/discovery/realtime/status", method="GET").inc()
- try:
- async with httpx.AsyncClient(timeout=5.0) as client:
- response = await client.get("http://discovery:8005/api/realtime/status")
- if response.status_code == 200:
- return ORJSONResponse(content=response.json())
- return ORJSONResponse(content={"error": f"Discovery API returned {response.status_code}"}, status_code=response.status_code)
- except Exception as e:
- logger.error(f"โ Error fetching realtime status: {e}")
- return ORJSONResponse(content={"error": str(e)}, status_code=503)
-
-
-@app.post("/api/discovery/realtime/trending")
-async def get_trending(category: str = "artists", limit: int = 10, time_window: str = "day") -> ORJSONResponse:
- """Get trending items from Discovery service."""
- API_REQUESTS.labels(endpoint="/api/discovery/realtime/trending", method="POST").inc()
- try:
- async with httpx.AsyncClient(timeout=10.0) as client:
- response = await client.post(
- "http://discovery:8005/api/realtime/trending",
- json={"category": category, "limit": limit, "time_window": time_window},
- )
- if response.status_code == 200:
- return ORJSONResponse(content=response.json())
- return ORJSONResponse(content={"error": f"Discovery API returned {response.status_code}"}, status_code=response.status_code)
- except Exception as e:
- logger.error(f"โ Error fetching trending items: {e}")
- return ORJSONResponse(content={"error": str(e)}, status_code=503)
-
-
@app.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket) -> None:
"""WebSocket endpoint for real-time updates."""
diff --git a/dashboard/static/dashboard.js b/dashboard/static/dashboard.js
index 27d9113..59c9c42 100644
--- a/dashboard/static/dashboard.js
+++ b/dashboard/static/dashboard.js
@@ -6,7 +6,6 @@ class Dashboard {
this.activityLog = [];
this.maxLogEntries = 50;
this.queueChart = null;
- this.centralityChart = null;
this.queueChartData = {
labels: [],
datasets: []
@@ -17,10 +16,7 @@ class Dashboard {
this.initializeWebSocket();
this.initializeChart();
- this.initializeCentralityChart();
- this.initializeDiscoveryUI();
this.fetchInitialData();
- this.fetchDiscoveryData();
}
initializeWebSocket() {
@@ -621,308 +617,6 @@ class Dashboard {
return date.toLocaleString();
}
- // Discovery API Methods
-
- initializeDiscoveryUI() {
- // ML Recommendations button
- const recBtn = document.getElementById('getRecommendationsBtn');
- if (recBtn) {
- recBtn.addEventListener('click', () => this.fetchRecommendations());
- }
-
- // Strategy select
- const strategySelect = document.getElementById('strategySelect');
- if (strategySelect) {
- strategySelect.addEventListener('change', (e) => {
- document.getElementById('currentStrategy').textContent = e.target.value;
- });
- }
-
- // Centrality button
- const centralityBtn = document.getElementById('getCentralityBtn');
- if (centralityBtn) {
- centralityBtn.addEventListener('click', () => this.fetchCentralityMetrics());
- }
-
- // Trending button
- const trendingBtn = document.getElementById('getTrendingBtn');
- if (trendingBtn) {
- trendingBtn.addEventListener('click', () => this.fetchTrending());
- }
- }
-
- async fetchDiscoveryData() {
- await this.fetchSearchStats();
- await this.fetchSearchStatus();
- }
-
- async fetchRecommendations() {
- const artistInput = document.getElementById('artistInput');
- const strategySelect = document.getElementById('strategySelect');
- const artist = artistInput.value || 'The Beatles';
- const strategy = strategySelect.value;
-
- // Collaborative filtering
- try {
- const response = await fetch(`/api/discovery/ml/recommend/collaborative?artist_id=${encodeURIComponent(artist)}&limit=10&min_similarity=0.1`, {
- method: 'POST'
- });
- if (response.ok) {
- const data = await response.json();
- this.renderCollaborativeResults(data);
- }
- } catch (error) {
- console.error('Error fetching collaborative recommendations:', error);
- document.getElementById('collaborativeResults').innerHTML = '
Error loading recommendations
';
- }
-
- // Hybrid recommendations
- try {
- const response = await fetch(`/api/discovery/ml/recommend/hybrid?artist_name=${encodeURIComponent(artist)}&limit=10&strategy=${strategy}`, {
- method: 'POST'
- });
- if (response.ok) {
- const data = await response.json();
- this.renderHybridResults(data);
- }
- } catch (error) {
- console.error('Error fetching hybrid recommendations:', error);
- document.getElementById('hybridResults').innerHTML = 'Error loading recommendations
';
- }
- }
-
- renderCollaborativeResults(data) {
- const container = document.getElementById('collaborativeResults');
- if (!data.recommendations || data.recommendations.length === 0) {
- container.innerHTML = 'No recommendations available
';
- return;
- }
-
- container.innerHTML = data.recommendations.map((rec, index) => `
-
- ${index + 1}
- ${rec.artist_name || rec.artist_id}
- ${(rec.similarity * 100).toFixed(1)}%
-
- `).join('');
- }
-
- renderHybridResults(data) {
- const container = document.getElementById('hybridResults');
- if (!data.recommendations || data.recommendations.length === 0) {
- container.innerHTML = 'No recommendations available
';
- return;
- }
-
- container.innerHTML = data.recommendations.map((rec, index) => `
-
- ${index + 1}
- ${rec.artist_name || rec.name}
- ${(rec.score * 100).toFixed(1)}%
-
- `).join('');
- }
-
- async fetchSearchStats() {
- try {
- const response = await fetch('/api/discovery/search/stats');
- if (response.ok) {
- const data = await response.json();
- this.renderSearchStats(data);
- }
- } catch (error) {
- console.error('Error fetching search stats:', error);
- }
- }
-
- async fetchSearchStatus() {
- try {
- const response = await fetch('/api/discovery/search/status');
- if (response.ok) {
- const data = await response.json();
- this.renderSearchStatus(data);
- }
- } catch (error) {
- console.error('Error fetching search status:', error);
- }
- }
-
- renderSearchStats(data) {
- const container = document.getElementById('searchStats');
- if (!data.statistics) {
- container.innerHTML = 'No statistics available
';
- return;
- }
-
- const stats = data.statistics;
- container.innerHTML = `
-
- Total Artists:
- ${(stats.artist_count || 0).toLocaleString()}
-
-
- Total Releases:
- ${(stats.release_count || 0).toLocaleString()}
-
-
- Total Labels:
- ${(stats.label_count || 0).toLocaleString()}
-
-
- Total Masters:
- ${(stats.master_count || 0).toLocaleString()}
-
- `;
- }
-
- renderSearchStatus(data) {
- const container = document.getElementById('searchStatus');
- if (!data.features) {
- container.innerHTML = 'No status available
';
- return;
- }
-
- container.innerHTML = `
-
- Full-Text Search:
- ${data.features.fulltext_search}
-
-
- Semantic Search:
- ${data.features.semantic_search}
-
-
- Faceted Search:
- ${data.features.faceted_search}
-
-
- Autocomplete:
- ${data.features.autocomplete}
-
- `;
- }
-
- initializeCentralityChart() {
- const ctx = document.getElementById('centralityChart');
- if (!ctx) return;
-
- this.centralityChart = new Chart(ctx.getContext('2d'), {
- type: 'bar',
- data: {
- labels: [],
- datasets: [{
- label: 'Centrality Score',
- data: [],
- backgroundColor: 'rgba(24, 119, 242, 0.6)',
- borderColor: 'rgba(24, 119, 242, 1)',
- borderWidth: 1
- }]
- },
- options: {
- responsive: true,
- maintainAspectRatio: false,
- indexAxis: 'y',
- plugins: {
- legend: {
- display: false
- },
- title: {
- display: true,
- text: 'Top Artists by Centrality',
- color: '#e4e6eb'
- }
- },
- scales: {
- x: {
- ticks: { color: '#b0b3b8' },
- grid: { color: 'rgba(45, 48, 81, 0.5)' }
- },
- y: {
- ticks: { color: '#b0b3b8' },
- grid: { color: 'rgba(45, 48, 81, 0.5)' }
- }
- }
- }
- });
- }
-
- async fetchCentralityMetrics() {
- const metricSelect = document.getElementById('centralityMetric');
- const metric = metricSelect.value;
-
- try {
- const response = await fetch(`/api/discovery/graph/centrality?metric=${metric}&limit=20&node_type=artist`, {
- method: 'POST'
- });
- if (response.ok) {
- const data = await response.json();
- this.renderCentralityChart(data);
- }
- } catch (error) {
- console.error('Error fetching centrality metrics:', error);
- this.addLogEntry('Failed to fetch centrality metrics', 'error');
- }
- }
-
- renderCentralityChart(data) {
- if (!this.centralityChart || !data.top_nodes) return;
-
- const labels = data.top_nodes.map(item => item.node);
- const scores = data.top_nodes.map(item => item.score);
-
- this.centralityChart.data.labels = labels;
- this.centralityChart.data.datasets[0].data = scores;
- this.centralityChart.data.datasets[0].label = `${data.metric} Score`;
- this.centralityChart.options.plugins.title.text = `Top Artists by ${data.metric}`;
- this.centralityChart.update();
-
- this.addLogEntry(`Calculated ${data.metric} for ${data.total_nodes} nodes`, 'info');
- }
-
- async fetchTrending() {
- const categorySelect = document.getElementById('trendingCategory');
- const category = categorySelect.value;
-
- try {
- const response = await fetch(`/api/discovery/realtime/trending?category=${category}&limit=10&time_window=day`, {
- method: 'POST'
- });
- if (response.ok) {
- const data = await response.json();
- this.renderTrending(data);
- }
- } catch (error) {
- console.error('Error fetching trending:', error);
- document.getElementById('trendingResults').innerHTML = 'Error loading trending data
';
- }
- }
-
- renderTrending(data) {
- const container = document.getElementById('trendingResults');
- if (!data.trending_items || data.trending_items.length === 0) {
- container.innerHTML = 'No trending data available
';
- return;
- }
-
- container.innerHTML = `
-
-
- ${data.trending_items.map((item, index) => `
-
- #${index + 1}
- ${item.item_name || item.item_id}
- ${item.score.toFixed(2)}
-
- ${item.change > 0 ? 'โฒ' : item.change < 0 ? 'โผ' : 'โ'} ${Math.abs(item.change).toFixed(2)}
-
-
- `).join('')}
-
- `;
- }
}
// Initialize dashboard when DOM is loaded
diff --git a/dashboard/static/index.html b/dashboard/static/index.html
index f85f655..72bd62a 100644
--- a/dashboard/static/index.html
+++ b/dashboard/static/index.html
@@ -55,86 +55,6 @@ Recent Activity
-
-
- Discovery API - ML Recommendations
-
-
-
-
-
-
-
-
Collaborative Filtering
-
-
Click 'Get Recommendations' to start
-
-
-
-
Hybrid Recommendations (weighted)
-
-
Click 'Get Recommendations' to start
-
-
-
-
-
-
-
- Discovery API - Search Analytics
-
-
-
Search Index Statistics
-
-
-
-
-
-
-
-
- Discovery API - Graph Analytics
-
-
-
-
-
-
-
-
-
-
-
- Discovery API - Real-Time Trending
-
-
-
-
-
-
Click 'Get Trending' to start
-
-