Spaces:
Sleeping
Sleeping
Initial deployment
Browse files- README.md +24 -7
- app.py +760 -0
- data/warkops.csv +24 -0
- models/__init__.py +5 -0
- models/__pycache__/__init__.cpython-313.pyc +0 -0
- models/__pycache__/recommender.cpython-313.pyc +0 -0
- models/recommender.py +409 -0
- requirements.txt +5 -0
README.md
CHANGED
|
@@ -1,13 +1,30 @@
|
|
| 1 |
---
|
| 2 |
-
title: Warkop Intelligence
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: gradio
|
| 7 |
-
sdk_version:
|
| 8 |
app_file: app.py
|
| 9 |
pinned: false
|
| 10 |
-
license: mit
|
| 11 |
---
|
| 12 |
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: Warkop Intelligence Finder
|
| 3 |
+
emoji: 🔍
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: green
|
| 6 |
sdk: gradio
|
| 7 |
+
sdk_version: 4.26.0
|
| 8 |
app_file: app.py
|
| 9 |
pinned: false
|
|
|
|
| 10 |
---
|
| 11 |
|
| 12 |
+
# Warkop Intelligence Finder - Lhokseumawe Edition
|
| 13 |
+
|
| 14 |
+
A smart recommendation system to find the best warkops in Lhokseumawe for coding, working, and hanging out.
|
| 15 |
+
|
| 16 |
+
## Features
|
| 17 |
+
- 🤖 ML-powered recommendations
|
| 18 |
+
- 💻 Best coding spots finder
|
| 19 |
+
- 🎉 Social spots ranking
|
| 20 |
+
- 📍 Location-based search
|
| 21 |
+
- 📊 Statistics & analytics
|
| 22 |
+
|
| 23 |
+
## Quick Start
|
| 24 |
+
1. Select your preferences (WiFi importance, sockets, vibe, quietness)
|
| 25 |
+
2. Get personalized recommendations
|
| 26 |
+
3. Explore by location or use pre-defined profiles
|
| 27 |
+
|
| 28 |
+
Powered by Scikit-learn + Gradio. Zero-cost deployment on Hugging Face Spaces.
|
| 29 |
+
|
| 30 |
+
**"Gas, Bre! Make warkops smarter!" ⚡**
|
app.py
ADDED
|
@@ -0,0 +1,760 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Warkop Intelligence Finder - Lhokseumawe Edition v3.0
|
| 3 |
+
Gradio-based UI for ML-powered warkop recommendations
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import gradio as gr
|
| 7 |
+
import pandas as pd
|
| 8 |
+
import numpy as np
|
| 9 |
+
import logging
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
from typing import Optional, Tuple
|
| 12 |
+
|
| 13 |
+
from models.recommender import WarkopRecommender, WarkopRecommenderError
|
| 14 |
+
|
| 15 |
+
# =============================================================================
|
| 16 |
+
# Setup
|
| 17 |
+
# =============================================================================
|
| 18 |
+
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
| 19 |
+
logger = logging.getLogger(__name__)
|
| 20 |
+
|
| 21 |
+
BASE_DIR = Path(__file__).parent
|
| 22 |
+
CSV_PATH = BASE_DIR / "data" / "warkops.csv"
|
| 23 |
+
|
| 24 |
+
PRESET_PROFILES = {
|
| 25 |
+
"coding": {
|
| 26 |
+
"label": "Coding & Deep Work",
|
| 27 |
+
"weights": {"wifi_imp": 0.45, "socket_imp": 0.30, "quiet_imp": 0.20, "value_imp": 0.05},
|
| 28 |
+
"desc": "WiFi stabil, banyak colokan, suasana tenang"
|
| 29 |
+
},
|
| 30 |
+
"social": {
|
| 31 |
+
"label": "Social & Hangout",
|
| 32 |
+
"weights": {"wifi_imp": 0.15, "socket_imp": 0.20, "quiet_imp": 0.05, "value_imp": 0.60},
|
| 33 |
+
"desc": "Suasana ramai, harga terjangkau, cocok berkumpul"
|
| 34 |
+
},
|
| 35 |
+
"student": {
|
| 36 |
+
"label": "Student Mode",
|
| 37 |
+
"weights": {"wifi_imp": 0.35, "socket_imp": 0.25, "quiet_imp": 0.10, "value_imp": 0.30},
|
| 38 |
+
"desc": "Balance antara WiFi, colokan, dan budget mahasiswa"
|
| 39 |
+
},
|
| 40 |
+
"premium": {
|
| 41 |
+
"label": "Premium Experience",
|
| 42 |
+
"weights": {"wifi_imp": 0.40, "socket_imp": 0.25, "quiet_imp": 0.30, "value_imp": 0.05},
|
| 43 |
+
"desc": "Kualitas terbaik, harga bukan masalah"
|
| 44 |
+
},
|
| 45 |
+
"chill": {
|
| 46 |
+
"label": "Chill & Quiet",
|
| 47 |
+
"weights": {"wifi_imp": 0.10, "socket_imp": 0.15, "quiet_imp": 0.45, "value_imp": 0.30},
|
| 48 |
+
"desc": "Tempat tenang untuk santai dan bersantai"
|
| 49 |
+
},
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
# =============================================================================
|
| 54 |
+
# Initialize Recommender
|
| 55 |
+
# =============================================================================
|
| 56 |
+
def initialize_recommender() -> Optional[WarkopRecommender]:
|
| 57 |
+
"""Safely initialize recommender with error handling."""
|
| 58 |
+
try:
|
| 59 |
+
rec = WarkopRecommender(str(CSV_PATH))
|
| 60 |
+
logger.info(f"Loaded {len(rec)} warkops successfully")
|
| 61 |
+
return rec
|
| 62 |
+
except Exception as e:
|
| 63 |
+
logger.exception(f"Failed to init recommender: {e}")
|
| 64 |
+
return None
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
recommender = initialize_recommender()
|
| 68 |
+
|
| 69 |
+
# Dropdown options
|
| 70 |
+
VIBE_TAGS = recommender.get_all_vibe_tags() if recommender else []
|
| 71 |
+
PRICE_RANGES = ['Cheap', 'Medium', 'Expensive']
|
| 72 |
+
NOISE_LEVELS = ['Low', 'Medium', 'High']
|
| 73 |
+
SOCKET_LEVELS = ['Low', 'Medium', 'High']
|
| 74 |
+
WARKOP_NAMES = sorted(recommender.df['name'].tolist()) if recommender else []
|
| 75 |
+
ADDRESS_LIST = sorted(recommender.df['address'].unique().tolist()) if recommender else []
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
# =============================================================================
|
| 79 |
+
# Helper Functions
|
| 80 |
+
# =============================================================================
|
| 81 |
+
def _check_recommender() -> Optional[str]:
|
| 82 |
+
"""Return error message if recommender not loaded."""
|
| 83 |
+
if recommender is None:
|
| 84 |
+
return ("### System Error\n\n"
|
| 85 |
+
"Recommender belum berhasil dimuat.\n\n"
|
| 86 |
+
"Pastikan file `data/warkops.csv` tersedia dan formatnya valid.")
|
| 87 |
+
return None
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def _format_results(df: pd.DataFrame, title: str = "") -> str:
|
| 91 |
+
"""Format DataFrame into clean markdown."""
|
| 92 |
+
if df is None or df.empty:
|
| 93 |
+
return "### Tidak ada hasil ditemukan\n\nCoba ubah filter atau preferensi Anda."
|
| 94 |
+
|
| 95 |
+
priority_cols = [
|
| 96 |
+
'name', 'address', 'wifi_speed_mbps', 'socket_availability',
|
| 97 |
+
'noise_level', 'price_range', 'vibe_category',
|
| 98 |
+
'match_score', 'similarity_score'
|
| 99 |
+
]
|
| 100 |
+
display_cols = [c for c in priority_cols if c in df.columns]
|
| 101 |
+
|
| 102 |
+
rename_map = {
|
| 103 |
+
'name': 'Nama Warkop',
|
| 104 |
+
'address': 'Alamat',
|
| 105 |
+
'wifi_speed_mbps': 'WiFi (Mbps)',
|
| 106 |
+
'socket_availability': 'Colokan',
|
| 107 |
+
'noise_level': 'Noise',
|
| 108 |
+
'price_range': 'Harga',
|
| 109 |
+
'vibe_category': 'Vibe',
|
| 110 |
+
'match_score': 'Score (%)',
|
| 111 |
+
'similarity_score': 'Similarity (%)',
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
display_df = df[display_cols].rename(columns=rename_map).reset_index(drop=True)
|
| 115 |
+
display_df.index = display_df.index + 1
|
| 116 |
+
|
| 117 |
+
header = f"### {title}\n\n" if title else ""
|
| 118 |
+
return header + display_df.to_markdown(index=True)
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
# =============================================================================
|
| 122 |
+
# Tab Handlers
|
| 123 |
+
# =============================================================================
|
| 124 |
+
def smart_recommend(wifi, socket, quiet, value, price_filter, vibe_filter, top_n):
|
| 125 |
+
"""Custom recommendation with sliders + filters."""
|
| 126 |
+
if err := _check_recommender():
|
| 127 |
+
return err
|
| 128 |
+
try:
|
| 129 |
+
filters = {}
|
| 130 |
+
if price_filter and price_filter != "Semua":
|
| 131 |
+
filters['price_range'] = price_filter
|
| 132 |
+
if vibe_filter and vibe_filter != "Semua":
|
| 133 |
+
filters['vibe_category'] = vibe_filter
|
| 134 |
+
|
| 135 |
+
result = recommender.recommend_by_weights(
|
| 136 |
+
wifi_imp=wifi, socket_imp=socket,
|
| 137 |
+
quiet_imp=quiet, value_imp=value,
|
| 138 |
+
top_n=int(top_n),
|
| 139 |
+
filters=filters or None
|
| 140 |
+
)
|
| 141 |
+
return _format_results(result, f"Top {len(result)} Rekomendasi Personal")
|
| 142 |
+
except Exception as e:
|
| 143 |
+
logger.exception("Error in smart_recommend")
|
| 144 |
+
return f"### Error\n\n```\n{e}\n```"
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
def use_preset(preset_key: str) -> Tuple[float, float, float, float]:
|
| 148 |
+
"""Apply preset weights to sliders."""
|
| 149 |
+
w = PRESET_PROFILES[preset_key]["weights"]
|
| 150 |
+
return w["wifi_imp"], w["socket_imp"], w["quiet_imp"], w["value_imp"]
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
def story_search(query, top_n):
|
| 154 |
+
"""NLP-based semantic search."""
|
| 155 |
+
if err := _check_recommender():
|
| 156 |
+
return err
|
| 157 |
+
if not query or not query.strip():
|
| 158 |
+
return "### Tulis cerita atau deskripsi pencarian Anda terlebih dahulu"
|
| 159 |
+
try:
|
| 160 |
+
result = recommender.recommend_by_story(query, top_n=int(top_n))
|
| 161 |
+
return _format_results(result, f"Hasil pencarian: \"{query}\"")
|
| 162 |
+
except Exception as e:
|
| 163 |
+
logger.exception("Error in story_search")
|
| 164 |
+
return f"### Error\n\n```\n{e}\n```"
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
def hybrid_search(query, wifi, socket, quiet, value, ratio, top_n):
|
| 168 |
+
"""Hybrid recommendation combining text + numeric."""
|
| 169 |
+
if err := _check_recommender():
|
| 170 |
+
return err
|
| 171 |
+
try:
|
| 172 |
+
result = recommender.recommend_hybrid(
|
| 173 |
+
user_query=query or "",
|
| 174 |
+
weights={"wifi_imp": wifi, "socket_imp": socket,
|
| 175 |
+
"quiet_imp": quiet, "value_imp": value},
|
| 176 |
+
text_ratio=float(ratio),
|
| 177 |
+
top_n=int(top_n)
|
| 178 |
+
)
|
| 179 |
+
return _format_results(result, "Hybrid AI Recommendation")
|
| 180 |
+
except Exception as e:
|
| 181 |
+
logger.exception("Error in hybrid_search")
|
| 182 |
+
return f"### Error\n\n```\n{e}\n```"
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
def vibe_search(vibe_tag, top_n):
|
| 186 |
+
"""Filter by vibe tag."""
|
| 187 |
+
if err := _check_recommender():
|
| 188 |
+
return err
|
| 189 |
+
if not vibe_tag:
|
| 190 |
+
return "### Pilih kategori vibe terlebih dahulu"
|
| 191 |
+
try:
|
| 192 |
+
result = recommender.recommend_by_weights(
|
| 193 |
+
top_n=int(top_n),
|
| 194 |
+
filters={'vibe_category': vibe_tag}
|
| 195 |
+
)
|
| 196 |
+
return _format_results(result, f"Warkop dengan Vibe: {vibe_tag}")
|
| 197 |
+
except Exception as e:
|
| 198 |
+
return f"### Error\n\n{e}"
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
def find_similar(warkop_name, top_n):
|
| 202 |
+
"""Find similar warkops."""
|
| 203 |
+
if err := _check_recommender():
|
| 204 |
+
return err
|
| 205 |
+
if not warkop_name:
|
| 206 |
+
return "### Pilih warkop referensi terlebih dahulu"
|
| 207 |
+
try:
|
| 208 |
+
result = recommender.find_similar(warkop_name, top_n=int(top_n))
|
| 209 |
+
return _format_results(result, f"Warkop Mirip dengan: {warkop_name}")
|
| 210 |
+
except WarkopRecommenderError as e:
|
| 211 |
+
return f"### {e}"
|
| 212 |
+
except Exception as e:
|
| 213 |
+
logger.exception("Error in find_similar")
|
| 214 |
+
return f"### Error\n\n{e}"
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
def filter_by_address(address, top_n):
|
| 218 |
+
"""Filter warkops by address/area."""
|
| 219 |
+
if err := _check_recommender():
|
| 220 |
+
return err
|
| 221 |
+
if not address:
|
| 222 |
+
return "### Pilih alamat/kawasan terlebih dahulu"
|
| 223 |
+
df = recommender.df.copy()
|
| 224 |
+
result = df[df['address'].str.contains(address, case=False, na=False)]
|
| 225 |
+
if result.empty:
|
| 226 |
+
return f"### Tidak ada warkop di kawasan '{address}'"
|
| 227 |
+
result = result.head(int(top_n))
|
| 228 |
+
return _format_results(result, f"Warkop di Kawasan: {address}")
|
| 229 |
+
|
| 230 |
+
|
| 231 |
+
def get_stats():
|
| 232 |
+
"""Generate statistics dashboard."""
|
| 233 |
+
if err := _check_recommender():
|
| 234 |
+
return err
|
| 235 |
+
try:
|
| 236 |
+
s = recommender.get_quick_stats()
|
| 237 |
+
top_vibes = "\n".join(
|
| 238 |
+
[f"- **{k}**: `{v}` warkop" for k, v in s['Top Vibe Tags'].items()]
|
| 239 |
+
)
|
| 240 |
+
|
| 241 |
+
return f"""
|
| 242 |
+
## Database Statistics - Warkop Lhokseumawe
|
| 243 |
+
|
| 244 |
+
### Overview
|
| 245 |
+
|
| 246 |
+
| Metric | Value |
|
| 247 |
+
|--------|-------|
|
| 248 |
+
| Total Warkops | `{s['Total Warkop']}` |
|
| 249 |
+
| Unique Vibe Tags | `{s['Total Unique Vibes']}` |
|
| 250 |
+
|
| 251 |
+
### WiFi Performance
|
| 252 |
+
|
| 253 |
+
| Metric | Value |
|
| 254 |
+
|--------|-------|
|
| 255 |
+
| Tercepat | `{s['WiFi Tercepat (Mbps)']:.0f} Mbps` |
|
| 256 |
+
| Rata-rata | `{s['WiFi Rata-rata (Mbps)']:.1f} Mbps` |
|
| 257 |
+
| Terlambat | `{s['WiFi Terlambat (Mbps)']:.0f} Mbps` |
|
| 258 |
+
|
| 259 |
+
### Special Features
|
| 260 |
+
|
| 261 |
+
| Feature | Count |
|
| 262 |
+
|---------|-------|
|
| 263 |
+
| Spot Tenang (Low Noise) | `{s['Spot Paling Tenang']}` |
|
| 264 |
+
| Spot Ramai (High Noise) | `{s['Spot Bising']}` |
|
| 265 |
+
| Budget Friendly (Cheap) | `{s['Budget Friendly']}` |
|
| 266 |
+
| Premium (Expensive) | `{s['Premium']}` |
|
| 267 |
+
| Colokan Melimpah (High) | `{s['Socket Melimpah']}` |
|
| 268 |
+
| Colokan Sedikit (Low) | `{s['Socket Sedikit']}` |
|
| 269 |
+
|
| 270 |
+
### Top Vibe Tags
|
| 271 |
+
|
| 272 |
+
{top_vibes}
|
| 273 |
+
|
| 274 |
+
---
|
| 275 |
+
*Powered by Content-Based Filtering + TF-IDF Vectorization*
|
| 276 |
+
"""
|
| 277 |
+
except Exception as e:
|
| 278 |
+
logger.exception("Error in get_stats")
|
| 279 |
+
return f"### Error\n\n{e}"
|
| 280 |
+
|
| 281 |
+
|
| 282 |
+
def display_all(sort_by, ascending):
|
| 283 |
+
"""Display complete database with sorting."""
|
| 284 |
+
if err := _check_recommender():
|
| 285 |
+
return err
|
| 286 |
+
try:
|
| 287 |
+
df = recommender.df.copy()
|
| 288 |
+
if sort_by in df.columns:
|
| 289 |
+
df = df.sort_values(sort_by, ascending=ascending)
|
| 290 |
+
df = df.drop(columns=[c for c in ['metadata'] if c in df.columns])
|
| 291 |
+
return _format_results(df, f"Database Lengkap (sorted by `{sort_by}`)")
|
| 292 |
+
except Exception as e:
|
| 293 |
+
return f"### Error\n\n{e}"
|
| 294 |
+
|
| 295 |
+
|
| 296 |
+
def compare_warkops(warkop_a, warkop_b):
|
| 297 |
+
"""Compare two warkops side-by-side."""
|
| 298 |
+
if err := _check_recommender():
|
| 299 |
+
return err
|
| 300 |
+
if not warkop_a or not warkop_b:
|
| 301 |
+
return "### Pilih dua warkop untuk dibandingkan"
|
| 302 |
+
if warkop_a == warkop_b:
|
| 303 |
+
return "### Pilih dua warkop yang berbeda"
|
| 304 |
+
|
| 305 |
+
df = recommender.df
|
| 306 |
+
row_a = df[df['name'] == warkop_a].iloc[0]
|
| 307 |
+
row_b = df[df['name'] == warkop_b].iloc[0]
|
| 308 |
+
|
| 309 |
+
cols = ['address', 'wifi_speed_mbps', 'socket_availability',
|
| 310 |
+
'noise_level', 'price_range', 'vibe_category']
|
| 311 |
+
labels = ['Alamat', 'WiFi (Mbps)', 'Colokan', 'Noise', 'Harga', 'Vibe']
|
| 312 |
+
|
| 313 |
+
table = f"| Atribut | {warkop_a} | {warkop_b} |\n|---------|---|---|\n"
|
| 314 |
+
for col, label in zip(cols, labels):
|
| 315 |
+
table += f"| **{label}** | {row_a[col]} | {row_b[col]} |\n"
|
| 316 |
+
|
| 317 |
+
return f"### Perbandingan Warkop\n\n{table}"
|
| 318 |
+
|
| 319 |
+
|
| 320 |
+
# =============================================================================
|
| 321 |
+
# Custom CSS
|
| 322 |
+
# =============================================================================
|
| 323 |
+
CUSTOM_CSS = """
|
| 324 |
+
.gradio-container { max-width: 1280px !important; margin: auto; }
|
| 325 |
+
|
| 326 |
+
.header-section {
|
| 327 |
+
background: linear-gradient(135deg, #5D4037 0%, #3E2723 100%);
|
| 328 |
+
color: white;
|
| 329 |
+
padding: 45px 30px;
|
| 330 |
+
border-radius: 16px;
|
| 331 |
+
margin-bottom: 25px;
|
| 332 |
+
box-shadow: 0 8px 24px rgba(62, 39, 35, 0.3);
|
| 333 |
+
text-align: center;
|
| 334 |
+
}
|
| 335 |
+
.header-section h1 {
|
| 336 |
+
margin: 0 0 12px 0;
|
| 337 |
+
font-size: 2.4em;
|
| 338 |
+
font-weight: 800;
|
| 339 |
+
letter-spacing: -0.5px;
|
| 340 |
+
}
|
| 341 |
+
.header-section .tagline {
|
| 342 |
+
font-size: 1.05em;
|
| 343 |
+
opacity: 0.95;
|
| 344 |
+
margin: 6px 0;
|
| 345 |
+
}
|
| 346 |
+
.header-section .badge {
|
| 347 |
+
display: inline-block;
|
| 348 |
+
background: rgba(255,255,255,0.18);
|
| 349 |
+
padding: 5px 14px;
|
| 350 |
+
border-radius: 20px;
|
| 351 |
+
font-size: 0.85em;
|
| 352 |
+
margin: 8px 4px 0 4px;
|
| 353 |
+
backdrop-filter: blur(10px);
|
| 354 |
+
border: 1px solid rgba(255,255,255,0.2);
|
| 355 |
+
}
|
| 356 |
+
|
| 357 |
+
.info-box {
|
| 358 |
+
background: linear-gradient(135deg, #FFF8F0 0%, #FFEEDD 100%);
|
| 359 |
+
border-left: 4px solid #5D4037;
|
| 360 |
+
padding: 16px 22px;
|
| 361 |
+
margin: 18px 0;
|
| 362 |
+
border-radius: 8px;
|
| 363 |
+
font-size: 0.95em;
|
| 364 |
+
color: #333;
|
| 365 |
+
}
|
| 366 |
+
.info-box strong { color: #5D4037; }
|
| 367 |
+
|
| 368 |
+
.footer-section {
|
| 369 |
+
text-align: center;
|
| 370 |
+
padding: 30px 20px;
|
| 371 |
+
color: #666;
|
| 372 |
+
font-size: 0.9em;
|
| 373 |
+
border-top: 2px solid #EEE;
|
| 374 |
+
margin-top: 40px;
|
| 375 |
+
}
|
| 376 |
+
.footer-section strong { color: #5D4037; }
|
| 377 |
+
"""
|
| 378 |
+
|
| 379 |
+
|
| 380 |
+
# =============================================================================
|
| 381 |
+
# Build Gradio App
|
| 382 |
+
# =============================================================================
|
| 383 |
+
def build_app() -> gr.Blocks:
|
| 384 |
+
"""Build the complete Gradio application."""
|
| 385 |
+
|
| 386 |
+
with gr.Blocks(
|
| 387 |
+
title="Warkop Intelligence Finder - Lhokseumawe",
|
| 388 |
+
theme=gr.themes.Soft(primary_hue="amber", secondary_hue="orange"),
|
| 389 |
+
css=CUSTOM_CSS
|
| 390 |
+
) as app:
|
| 391 |
+
|
| 392 |
+
# ===== Header =====
|
| 393 |
+
gr.HTML("""
|
| 394 |
+
<div class="header-section">
|
| 395 |
+
<h1>Warkop Intelligence Finder</h1>
|
| 396 |
+
<p class="tagline">Temukan warkop terbaik di Lhokseumawe sesuai kebutuhan Anda</p>
|
| 397 |
+
<p class="tagline" style="font-size: 0.95em;">Powered by Hybrid Machine Learning - Content-Based + Semantic Search</p>
|
| 398 |
+
<span class="badge">AI-Driven</span>
|
| 399 |
+
<span class="badge">Data-Backed</span>
|
| 400 |
+
<span class="badge">Local Lhokseumawe</span>
|
| 401 |
+
<span class="badge">Real-time</span>
|
| 402 |
+
</div>
|
| 403 |
+
""")
|
| 404 |
+
|
| 405 |
+
# ===== System Status Warning =====
|
| 406 |
+
if recommender is None:
|
| 407 |
+
gr.HTML("""
|
| 408 |
+
<div style="background:#FEE; border-left:4px solid #C00; padding:15px; border-radius:8px; margin-bottom:20px;">
|
| 409 |
+
<strong>System Warning:</strong> Recommender gagal dimuat.
|
| 410 |
+
Pastikan file <code>data/warkops.csv</code> tersedia dan valid.
|
| 411 |
+
</div>
|
| 412 |
+
""")
|
| 413 |
+
|
| 414 |
+
# ===== Tabs =====
|
| 415 |
+
with gr.Tabs():
|
| 416 |
+
|
| 417 |
+
# ----------------- Tab 1: Smart Recommendation -----------------
|
| 418 |
+
with gr.Tab("Smart Recommendation"):
|
| 419 |
+
gr.Markdown("### Rekomendasi Personal dengan Preset & Filter")
|
| 420 |
+
gr.HTML("""
|
| 421 |
+
<div class="info-box">
|
| 422 |
+
<strong>Tips:</strong> Klik salah satu preset untuk auto-fill slider,
|
| 423 |
+
atau atur manual sesuai kebutuhan. Tambahkan filter untuk hasil lebih spesifik.
|
| 424 |
+
</div>
|
| 425 |
+
""")
|
| 426 |
+
|
| 427 |
+
gr.Markdown("**Quick Presets:**")
|
| 428 |
+
with gr.Row():
|
| 429 |
+
preset_buttons = {}
|
| 430 |
+
for key, prof in PRESET_PROFILES.items():
|
| 431 |
+
preset_buttons[key] = gr.Button(prof["label"], size="sm")
|
| 432 |
+
|
| 433 |
+
gr.Markdown("**Atur Preferensi (otomatis dinormalisasi):**")
|
| 434 |
+
with gr.Row():
|
| 435 |
+
with gr.Column():
|
| 436 |
+
wifi_s = gr.Slider(0, 1, value=0.45, step=0.05,
|
| 437 |
+
label="WiFi Speed",
|
| 438 |
+
info="Kecepatan internet")
|
| 439 |
+
socket_s = gr.Slider(0, 1, value=0.30, step=0.05,
|
| 440 |
+
label="Socket Availability",
|
| 441 |
+
info="Ketersediaan colokan")
|
| 442 |
+
with gr.Column():
|
| 443 |
+
quiet_s = gr.Slider(0, 1, value=0.20, step=0.05,
|
| 444 |
+
label="Quietness",
|
| 445 |
+
info="Tingkat ketenangan")
|
| 446 |
+
value_s = gr.Slider(0, 1, value=0.05, step=0.05,
|
| 447 |
+
label="Value for Money",
|
| 448 |
+
info="Harga terjangkau")
|
| 449 |
+
|
| 450 |
+
gr.Markdown("**Optional Filters:**")
|
| 451 |
+
with gr.Row():
|
| 452 |
+
price_f = gr.Dropdown(
|
| 453 |
+
choices=["Semua"] + PRICE_RANGES, value="Semua",
|
| 454 |
+
label="Filter Harga"
|
| 455 |
+
)
|
| 456 |
+
vibe_f = gr.Dropdown(
|
| 457 |
+
choices=["Semua"] + VIBE_TAGS, value="Semua",
|
| 458 |
+
label="Filter Vibe"
|
| 459 |
+
)
|
| 460 |
+
top_n_s = gr.Slider(3, 20, value=7, step=1, label="Jumlah Hasil")
|
| 461 |
+
|
| 462 |
+
rec_btn = gr.Button("Get Recommendations", variant="primary", size="lg")
|
| 463 |
+
rec_out = gr.Markdown()
|
| 464 |
+
|
| 465 |
+
# Wire preset buttons
|
| 466 |
+
for key, btn in preset_buttons.items():
|
| 467 |
+
btn.click(
|
| 468 |
+
lambda k=key: use_preset(k),
|
| 469 |
+
outputs=[wifi_s, socket_s, quiet_s, value_s]
|
| 470 |
+
)
|
| 471 |
+
|
| 472 |
+
rec_btn.click(
|
| 473 |
+
smart_recommend,
|
| 474 |
+
inputs=[wifi_s, socket_s, quiet_s, value_s,
|
| 475 |
+
price_f, vibe_f, top_n_s],
|
| 476 |
+
outputs=rec_out
|
| 477 |
+
)
|
| 478 |
+
|
| 479 |
+
# ----------------- Tab 2: Story Search -----------------
|
| 480 |
+
with gr.Tab("Story Search"):
|
| 481 |
+
gr.Markdown("### Cari dengan Bahasa Natural")
|
| 482 |
+
gr.HTML("""
|
| 483 |
+
<div class="info-box">
|
| 484 |
+
<strong>NLP-Powered:</strong> Tuliskan deskripsi warkop ideal Anda dalam bahasa sehari-hari.
|
| 485 |
+
AI akan match menggunakan TF-IDF & Cosine Similarity.
|
| 486 |
+
</div>
|
| 487 |
+
""")
|
| 488 |
+
|
| 489 |
+
story_in = gr.Textbox(
|
| 490 |
+
label="Ceritakan warkop yang Anda cari",
|
| 491 |
+
placeholder="Contoh: warkop tenang di Bukit Rata buat ngerjain tugas dengan wifi cepat",
|
| 492 |
+
lines=3
|
| 493 |
+
)
|
| 494 |
+
story_n = gr.Slider(3, 10, value=5, step=1, label="Jumlah Hasil")
|
| 495 |
+
story_btn = gr.Button("Search by Story", variant="primary", size="lg")
|
| 496 |
+
story_out = gr.Markdown()
|
| 497 |
+
|
| 498 |
+
gr.Examples(
|
| 499 |
+
examples=[
|
| 500 |
+
"warkop tenang buat coding dengan wifi cepat",
|
| 501 |
+
"tempat nongkrong murah meriah buat kumpul teman",
|
| 502 |
+
"specialty coffee modern dan instagramable",
|
| 503 |
+
"warkop legendary di bukit rata",
|
| 504 |
+
"tempat 24 jam buat begadang ngerjain skripsi",
|
| 505 |
+
"premium deep work space dengan colokan banyak",
|
| 506 |
+
"warkop sporty buat nobar bareng teman",
|
| 507 |
+
"kafe creative buat freelancer di merdeka",
|
| 508 |
+
],
|
| 509 |
+
inputs=story_in,
|
| 510 |
+
label="Contoh Query"
|
| 511 |
+
)
|
| 512 |
+
|
| 513 |
+
story_btn.click(story_search, inputs=[story_in, story_n], outputs=story_out)
|
| 514 |
+
|
| 515 |
+
# ----------------- Tab 3: Hybrid AI -----------------
|
| 516 |
+
with gr.Tab("Hybrid AI"):
|
| 517 |
+
gr.Markdown("### Kombinasi Story Search + Personal Preferences")
|
| 518 |
+
gr.HTML("""
|
| 519 |
+
<div class="info-box">
|
| 520 |
+
<strong>Most Powerful:</strong> Gabungkan deskripsi natural dengan preferensi numerik.
|
| 521 |
+
Atur slider <em>Text Ratio</em> untuk balance antara semantic search & rule-based scoring.
|
| 522 |
+
</div>
|
| 523 |
+
""")
|
| 524 |
+
|
| 525 |
+
h_query = gr.Textbox(
|
| 526 |
+
label="Deskripsi (opsional)",
|
| 527 |
+
placeholder="Contoh: warkop modern quiet di pase",
|
| 528 |
+
lines=2
|
| 529 |
+
)
|
| 530 |
+
|
| 531 |
+
with gr.Row():
|
| 532 |
+
h_wifi = gr.Slider(0, 1, value=0.35, step=0.05, label="WiFi")
|
| 533 |
+
h_socket = gr.Slider(0, 1, value=0.25, step=0.05, label="Socket")
|
| 534 |
+
h_quiet = gr.Slider(0, 1, value=0.20, step=0.05, label="Quiet")
|
| 535 |
+
h_value = gr.Slider(0, 1, value=0.20, step=0.05, label="Value")
|
| 536 |
+
|
| 537 |
+
with gr.Row():
|
| 538 |
+
ratio_s = gr.Slider(
|
| 539 |
+
0, 1, value=0.5, step=0.05,
|
| 540 |
+
label="Text vs Numeric Ratio",
|
| 541 |
+
info="0 = pakai slider saja, 1 = pakai deskripsi saja"
|
| 542 |
+
)
|
| 543 |
+
h_top = gr.Slider(3, 15, value=7, step=1, label="Jumlah Hasil")
|
| 544 |
+
|
| 545 |
+
h_btn = gr.Button("Run Hybrid AI", variant="primary", size="lg")
|
| 546 |
+
h_out = gr.Markdown()
|
| 547 |
+
|
| 548 |
+
h_btn.click(
|
| 549 |
+
hybrid_search,
|
| 550 |
+
inputs=[h_query, h_wifi, h_socket, h_quiet, h_value, ratio_s, h_top],
|
| 551 |
+
outputs=h_out
|
| 552 |
+
)
|
| 553 |
+
|
| 554 |
+
# ----------------- Tab 4: By Vibe -----------------
|
| 555 |
+
with gr.Tab("By Vibe"):
|
| 556 |
+
gr.Markdown("### Filter Berdasarkan Suasana / Vibe Tag")
|
| 557 |
+
gr.HTML("""
|
| 558 |
+
<div class="info-box">
|
| 559 |
+
Vibe akan match secara substring. Misalnya pilih <strong>"Social"</strong>
|
| 560 |
+
akan menampilkan semua warkop dengan tag mengandung kata Social.
|
| 561 |
+
</div>
|
| 562 |
+
""")
|
| 563 |
+
|
| 564 |
+
with gr.Row():
|
| 565 |
+
vibe_dd = gr.Dropdown(
|
| 566 |
+
choices=VIBE_TAGS,
|
| 567 |
+
label="Pilih Kategori Vibe",
|
| 568 |
+
scale=2
|
| 569 |
+
)
|
| 570 |
+
vibe_n = gr.Slider(3, 20, value=8, step=1, label="Jumlah Hasil", scale=1)
|
| 571 |
+
|
| 572 |
+
vibe_btn = gr.Button("Search by Vibe", variant="primary", size="lg")
|
| 573 |
+
vibe_out = gr.Markdown()
|
| 574 |
+
vibe_btn.click(vibe_search, inputs=[vibe_dd, vibe_n], outputs=vibe_out)
|
| 575 |
+
|
| 576 |
+
# ----------------- Tab 5: Find Similar -----------------
|
| 577 |
+
with gr.Tab("Find Similar"):
|
| 578 |
+
gr.Markdown("### Cari Warkop dengan Karakteristik Serupa")
|
| 579 |
+
gr.HTML("""
|
| 580 |
+
<div class="info-box">
|
| 581 |
+
Pilih warkop favorit Anda, lalu temukan warkop lain dengan vibe & fitur serupa
|
| 582 |
+
berdasarkan kombinasi numeric + text similarity.
|
| 583 |
+
</div>
|
| 584 |
+
""")
|
| 585 |
+
|
| 586 |
+
with gr.Row():
|
| 587 |
+
sim_dd = gr.Dropdown(
|
| 588 |
+
choices=WARKOP_NAMES,
|
| 589 |
+
label="Pilih Warkop Referensi",
|
| 590 |
+
scale=2
|
| 591 |
+
)
|
| 592 |
+
sim_n = gr.Slider(3, 10, value=5, step=1, label="Jumlah Hasil", scale=1)
|
| 593 |
+
|
| 594 |
+
sim_btn = gr.Button("Find Similar", variant="primary", size="lg")
|
| 595 |
+
sim_out = gr.Markdown()
|
| 596 |
+
sim_btn.click(find_similar, inputs=[sim_dd, sim_n], outputs=sim_out)
|
| 597 |
+
|
| 598 |
+
# ----------------- Tab 6: By Location -----------------
|
| 599 |
+
with gr.Tab("By Location"):
|
| 600 |
+
gr.Markdown("### Filter Berdasarkan Kawasan / Alamat")
|
| 601 |
+
gr.HTML("""
|
| 602 |
+
<div class="info-box">
|
| 603 |
+
Pilih kawasan untuk melihat semua warkop di area tersebut.
|
| 604 |
+
Pencarian dilakukan dengan substring match (case-insensitive).
|
| 605 |
+
</div>
|
| 606 |
+
""")
|
| 607 |
+
|
| 608 |
+
with gr.Row():
|
| 609 |
+
addr_dd = gr.Dropdown(
|
| 610 |
+
choices=ADDRESS_LIST,
|
| 611 |
+
label="Pilih Kawasan / Alamat",
|
| 612 |
+
scale=2
|
| 613 |
+
)
|
| 614 |
+
addr_n = gr.Slider(3, 20, value=10, step=1, label="Jumlah Hasil", scale=1)
|
| 615 |
+
|
| 616 |
+
addr_btn = gr.Button("Search by Location", variant="primary", size="lg")
|
| 617 |
+
addr_out = gr.Markdown()
|
| 618 |
+
addr_btn.click(filter_by_address, inputs=[addr_dd, addr_n], outputs=addr_out)
|
| 619 |
+
|
| 620 |
+
# ----------------- Tab 7: Compare Warkops -----------------
|
| 621 |
+
with gr.Tab("Compare"):
|
| 622 |
+
gr.Markdown("### Bandingkan Dua Warkop Side-by-Side")
|
| 623 |
+
gr.HTML("""
|
| 624 |
+
<div class="info-box">
|
| 625 |
+
Pilih dua warkop berbeda untuk membandingkan atribut mereka secara langsung.
|
| 626 |
+
</div>
|
| 627 |
+
""")
|
| 628 |
+
|
| 629 |
+
with gr.Row():
|
| 630 |
+
cmp_a = gr.Dropdown(choices=WARKOP_NAMES, label="Warkop A")
|
| 631 |
+
cmp_b = gr.Dropdown(choices=WARKOP_NAMES, label="Warkop B")
|
| 632 |
+
|
| 633 |
+
cmp_btn = gr.Button("Compare", variant="primary", size="lg")
|
| 634 |
+
cmp_out = gr.Markdown()
|
| 635 |
+
cmp_btn.click(compare_warkops, inputs=[cmp_a, cmp_b], outputs=cmp_out)
|
| 636 |
+
|
| 637 |
+
# ----------------- Tab 8: Statistics -----------------
|
| 638 |
+
with gr.Tab("Statistics"):
|
| 639 |
+
gr.Markdown("### Database Insights & Analytics")
|
| 640 |
+
stats_btn = gr.Button("Refresh Statistics", variant="primary", size="lg")
|
| 641 |
+
stats_output = gr.Markdown()
|
| 642 |
+
stats_btn.click(get_stats, outputs=stats_output)
|
| 643 |
+
|
| 644 |
+
# Auto-load on app start
|
| 645 |
+
app.load(get_stats, outputs=stats_output)
|
| 646 |
+
|
| 647 |
+
# ----------------- Tab 9: All Warkops -----------------
|
| 648 |
+
with gr.Tab("All Warkops"):
|
| 649 |
+
gr.Markdown("### Database Lengkap")
|
| 650 |
+
gr.HTML("""
|
| 651 |
+
<div class="info-box">
|
| 652 |
+
Lihat seluruh database warkop dengan opsi sorting berdasarkan kolom tertentu.
|
| 653 |
+
</div>
|
| 654 |
+
""")
|
| 655 |
+
|
| 656 |
+
with gr.Row():
|
| 657 |
+
sort_dd = gr.Dropdown(
|
| 658 |
+
choices=['name', 'wifi_speed_mbps', 'price_range',
|
| 659 |
+
'noise_level', 'socket_availability',
|
| 660 |
+
'vibe_category', 'address'],
|
| 661 |
+
value='name',
|
| 662 |
+
label="Urutkan berdasarkan"
|
| 663 |
+
)
|
| 664 |
+
asc_cb = gr.Checkbox(value=True, label="Ascending (A-Z, kecil-besar)")
|
| 665 |
+
|
| 666 |
+
all_btn = gr.Button("Load Database", variant="primary", size="lg")
|
| 667 |
+
all_out = gr.Markdown()
|
| 668 |
+
all_btn.click(display_all, inputs=[sort_dd, asc_cb], outputs=all_out)
|
| 669 |
+
|
| 670 |
+
# ----------------- Tab 10: About -----------------
|
| 671 |
+
with gr.Tab("About"):
|
| 672 |
+
gr.Markdown("""
|
| 673 |
+
## Tentang Warkop Intelligence Finder
|
| 674 |
+
|
| 675 |
+
Aplikasi ini adalah **sistem rekomendasi cerdas** untuk membantu Anda menemukan
|
| 676 |
+
warkop terbaik di Lhokseumawe sesuai kebutuhan personal Anda.
|
| 677 |
+
|
| 678 |
+
### Teknologi yang Digunakan
|
| 679 |
+
|
| 680 |
+
| Teknologi | Fungsi |
|
| 681 |
+
|-----------|--------|
|
| 682 |
+
| **Content-Based Filtering** | Rekomendasi berbasis fitur numerik |
|
| 683 |
+
| **TF-IDF Vectorization** | Konversi teks ke vektor numerik |
|
| 684 |
+
| **Cosine Similarity** | Mengukur kemiripan antar item |
|
| 685 |
+
| **MinMax Scaler** | Normalisasi fitur numerik |
|
| 686 |
+
| **Hybrid Scoring** | Kombinasi numeric + semantic |
|
| 687 |
+
| **Gradio Framework** | Interactive UI |
|
| 688 |
+
|
| 689 |
+
### Fitur Utama
|
| 690 |
+
|
| 691 |
+
1. **Smart Recommendation** - Rekomendasi via slider preferensi
|
| 692 |
+
2. **Story Search** - Cari dengan bahasa natural (NLP)
|
| 693 |
+
3. **Hybrid AI** - Kombinasi text + numeric scoring
|
| 694 |
+
4. **By Vibe** - Filter berdasarkan suasana
|
| 695 |
+
5. **Find Similar** - Temukan warkop mirip
|
| 696 |
+
6. **By Location** - Filter berdasarkan kawasan
|
| 697 |
+
7. **Compare** - Bandingkan dua warkop
|
| 698 |
+
8. **Statistics** - Dashboard analitik database
|
| 699 |
+
9. **All Warkops** - Browse seluruh database
|
| 700 |
+
|
| 701 |
+
### Schema Data
|
| 702 |
+
|
| 703 |
+
| Kolom | Tipe | Deskripsi |
|
| 704 |
+
|-------|------|-----------|
|
| 705 |
+
| `name` | string | Nama warkop |
|
| 706 |
+
| `address` | string | Alamat / kawasan |
|
| 707 |
+
| `wifi_speed_mbps` | int | Kecepatan WiFi (Mbps) |
|
| 708 |
+
| `socket_availability` | enum | Low / Medium / High |
|
| 709 |
+
| `noise_level` | enum | Low / Medium / High |
|
| 710 |
+
| `vibe_category` | string | Tag vibe (slash-separated) |
|
| 711 |
+
| `price_range` | enum | Cheap / Medium / Expensive |
|
| 712 |
+
|
| 713 |
+
### Algoritma Scoring
|
| 714 |
+
|
| 715 |
+
**Numeric Score:**
|
| 716 |
+
```
|
| 717 |
+
score = w_wifi * wifi_norm
|
| 718 |
+
+ w_socket * socket_norm
|
| 719 |
+
+ w_quiet * (1 - noise_norm)
|
| 720 |
+
+ w_value * (1 - price_norm)
|
| 721 |
+
```
|
| 722 |
+
|
| 723 |
+
**Hybrid Score:**
|
| 724 |
+
```
|
| 725 |
+
final = text_ratio * cosine_sim(query, metadata)
|
| 726 |
+
+ (1 - text_ratio) * numeric_score
|
| 727 |
+
```
|
| 728 |
+
|
| 729 |
+
### Pengembang
|
| 730 |
+
|
| 731 |
+
Dibuat untuk **Politeknik Negeri Lhokseumawe**
|
| 732 |
+
Program Studi: Teknik Informatika
|
| 733 |
+
""")
|
| 734 |
+
|
| 735 |
+
# ===== Footer =====
|
| 736 |
+
gr.HTML("""
|
| 737 |
+
<div class="footer-section">
|
| 738 |
+
<p><strong>Warkop Intelligence Finder v3.0</strong></p>
|
| 739 |
+
<p>Powered by Hybrid Machine Learning | TF-IDF + Cosine Similarity + Content-Based Filtering</p>
|
| 740 |
+
<p style="font-size: 0.85em; color: #999;">
|
| 741 |
+
Built for <strong>Politeknik Negeri Lhokseumawe</strong> - Informatics Engineering
|
| 742 |
+
</p>
|
| 743 |
+
</div>
|
| 744 |
+
""")
|
| 745 |
+
|
| 746 |
+
return app
|
| 747 |
+
|
| 748 |
+
|
| 749 |
+
# =============================================================================
|
| 750 |
+
# Main Entry Point
|
| 751 |
+
# =============================================================================
|
| 752 |
+
if __name__ == "__main__":
|
| 753 |
+
app = build_app()
|
| 754 |
+
app.queue(max_size=20).launch(
|
| 755 |
+
server_name="0.0.0.0",
|
| 756 |
+
server_port=7860,
|
| 757 |
+
share=False,
|
| 758 |
+
show_error=True,
|
| 759 |
+
inbrowser=True
|
| 760 |
+
)
|
data/warkops.csv
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name,address,wifi_speed_mbps,socket_availability,noise_level,vibe_category,price_range
|
| 2 |
+
Warkop Syarif Delima,Pusong Lama,20,Medium,High,Social/Student,Cheap
|
| 3 |
+
Syeih Nyak Legend,Cunda,30,High,High,Legendary/Student,Cheap
|
| 4 |
+
Menara Kupi,Sp. Buloh Lhokseumawe,30,High,High,Social/Group,Cheap
|
| 5 |
+
Pyramid Kuphi,Jl. Medan - Banda Aceh,35,Medium,High,Social/Nobar,Cheap
|
| 6 |
+
Stadion Kopi,Jl. Stadion Tunas Bangsa,40,Medium,Medium,Sporty/Social,Cheap
|
| 7 |
+
Rumoh Tuha Roastery,Lhokseumawe,45,Medium,Low,Specialty Coffee,Medium
|
| 8 |
+
TB Kupi 24 Jam,Jl. Panglateh,25,Medium,High,Non-stop/Social,Cheap
|
| 9 |
+
CEK BOY KUPI,Lhokseumawe,20,Low,Medium,Local/Chill,Cheap
|
| 10 |
+
KOPIKA. CO,Jl. Merdeka Timur,45,High,Low,Creative/Work,Medium
|
| 11 |
+
Lumoa Coffee Roastery,Jl. Maharaja,45,Medium,Low,Modern/Quiet,Medium
|
| 12 |
+
Coffee Time,Jl. Pusong,35,Medium,High,Social/Sea Breeze,Cheap
|
| 13 |
+
Waroeng Kupi Bahagia,Banda Sakti,20,Medium,High,Social/Chill,Cheap
|
| 14 |
+
D'Royal Coffee Space,Jl. Pase,50,High,Medium,Full Power User,Expensive
|
| 15 |
+
dr. Kupi Espresso,Jl. Simpang Buloh,40,High,High,Social/Espresso,Cheap
|
| 16 |
+
BARA KOPI,Jl. Darussalam,45,High,Medium,Social/Group,Medium
|
| 17 |
+
Station Coffee Premium,Jl. Merdeka Barat,55,High,Medium,Deep Work/Premium,Expensive
|
| 18 |
+
Atra Warkop,Jl. Perdagangan,15,Low,High,Classic/Market,Cheap
|
| 19 |
+
Wahyu Kupi,Banda Sakti,20,Medium,High,Social/Relax,Cheap
|
| 20 |
+
Rex Min Cafe,Lhokseumawe,25,Medium,Medium,Social/Traditional,Cheap
|
| 21 |
+
PETRODOLLAR COFFEEATERY & ROASTERY,Jl. Merdeka No.1,40,High,Medium,Professional/Work,Medium
|
| 22 |
+
Culture Coffee,Alue Awe,60,High,Medium,Creative/Work,Expensive
|
| 23 |
+
Sudut Temu,Jl. Darussalam,50,High,Medium,Creative/Work,Medium
|
| 24 |
+
Kolega Coffee,Jl. Listrik - Hagu Selatan,35,Medium,Low,Deep Work,Medium
|
models/__init__.py
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Warkop Intelligence - Models Package"""
|
| 2 |
+
from .recommender import WarkopRecommender, WarkopRecommenderError
|
| 3 |
+
|
| 4 |
+
__all__ = ['WarkopRecommender', 'WarkopRecommenderError']
|
| 5 |
+
__version__ = '3.0.0'
|
models/__pycache__/__init__.cpython-313.pyc
ADDED
|
Binary file (396 Bytes). View file
|
|
|
models/__pycache__/recommender.cpython-313.pyc
ADDED
|
Binary file (25 kB). View file
|
|
|
models/recommender.py
ADDED
|
@@ -0,0 +1,409 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Warkop Recommender Engine v3.0 - Lhokseumawe Edition
|
| 3 |
+
Content-Based Filtering (Numeric) + Semantic Search (TF-IDF)
|
| 4 |
+
|
| 5 |
+
Schema:
|
| 6 |
+
name, address, wifi_speed_mbps, socket_availability,
|
| 7 |
+
noise_level, vibe_category, price_range
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
import pandas as pd
|
| 11 |
+
import numpy as np
|
| 12 |
+
import logging
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
from typing import Optional, Union, Dict, List
|
| 15 |
+
from dataclasses import dataclass, field
|
| 16 |
+
from functools import lru_cache
|
| 17 |
+
|
| 18 |
+
from sklearn.preprocessing import MinMaxScaler
|
| 19 |
+
from sklearn.metrics.pairwise import cosine_similarity
|
| 20 |
+
from sklearn.feature_extraction.text import TfidfVectorizer
|
| 21 |
+
|
| 22 |
+
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
| 23 |
+
logger = logging.getLogger(__name__)
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
# =============================================================================
|
| 27 |
+
# Configuration
|
| 28 |
+
# =============================================================================
|
| 29 |
+
@dataclass
|
| 30 |
+
class RecommenderConfig:
|
| 31 |
+
"""Configuration for the recommender engine."""
|
| 32 |
+
socket_levels: Dict[str, int] = field(default_factory=lambda: {
|
| 33 |
+
'Low': 1, 'Medium': 2, 'High': 3
|
| 34 |
+
})
|
| 35 |
+
noise_levels: Dict[str, int] = field(default_factory=lambda: {
|
| 36 |
+
'Low': 1, 'Medium': 2, 'High': 3
|
| 37 |
+
})
|
| 38 |
+
price_levels: Dict[str, int] = field(default_factory=lambda: {
|
| 39 |
+
'Cheap': 1, 'Medium': 2, 'Expensive': 3
|
| 40 |
+
})
|
| 41 |
+
|
| 42 |
+
stopwords: List[str] = field(default_factory=lambda: [
|
| 43 |
+
'saya', 'mau', 'yang', 'dan', 'di', 'ke', 'buat', 'cari', 'dengan',
|
| 44 |
+
'untuk', 'dari', 'ini', 'itu', 'atau', 'juga', 'aku', 'gue', 'ada',
|
| 45 |
+
'bisa', 'lagi', 'sama', 'pada', 'akan', 'sudah', 'belum', 'jl',
|
| 46 |
+
'tempat', 'warkop', 'kupi', 'kopi', 'coffee'
|
| 47 |
+
])
|
| 48 |
+
|
| 49 |
+
required_columns: List[str] = field(default_factory=lambda: [
|
| 50 |
+
'name', 'address', 'wifi_speed_mbps', 'socket_availability',
|
| 51 |
+
'noise_level', 'vibe_category', 'price_range'
|
| 52 |
+
])
|
| 53 |
+
|
| 54 |
+
tfidf_ngram_range: tuple = (1, 2)
|
| 55 |
+
tfidf_max_features: int = 3000
|
| 56 |
+
tfidf_min_df: int = 1
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
class WarkopRecommenderError(Exception):
|
| 60 |
+
"""Custom exception for recommender errors."""
|
| 61 |
+
pass
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
# =============================================================================
|
| 65 |
+
# Main Recommender Class
|
| 66 |
+
# =============================================================================
|
| 67 |
+
class WarkopRecommender:
|
| 68 |
+
"""
|
| 69 |
+
Hybrid recommender for warkops in Lhokseumawe.
|
| 70 |
+
|
| 71 |
+
Features:
|
| 72 |
+
- Weight-based content filtering (WiFi, sockets, quiet, value)
|
| 73 |
+
- Semantic search via TF-IDF (vibe + name + address)
|
| 74 |
+
- Hybrid mode (combines both)
|
| 75 |
+
- Filtering by price/vibe/noise
|
| 76 |
+
- Find similar warkops
|
| 77 |
+
"""
|
| 78 |
+
|
| 79 |
+
DEFAULT_WEIGHTS = {
|
| 80 |
+
'wifi_imp': 0.4,
|
| 81 |
+
'socket_imp': 0.3,
|
| 82 |
+
'quiet_imp': 0.2,
|
| 83 |
+
'value_imp': 0.1
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
PRESET_PROFILES = {
|
| 87 |
+
'coding': {'wifi_imp': 0.45, 'socket_imp': 0.30, 'quiet_imp': 0.20, 'value_imp': 0.05},
|
| 88 |
+
'social': {'wifi_imp': 0.15, 'socket_imp': 0.20, 'quiet_imp': 0.05, 'value_imp': 0.60},
|
| 89 |
+
'student': {'wifi_imp': 0.35, 'socket_imp': 0.25, 'quiet_imp': 0.10, 'value_imp': 0.30},
|
| 90 |
+
'premium': {'wifi_imp': 0.40, 'socket_imp': 0.25, 'quiet_imp': 0.30, 'value_imp': 0.05},
|
| 91 |
+
'chill': {'wifi_imp': 0.10, 'socket_imp': 0.15, 'quiet_imp': 0.45, 'value_imp': 0.30},
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
def __init__(
|
| 95 |
+
self,
|
| 96 |
+
csv_path: Union[str, Path, pd.DataFrame],
|
| 97 |
+
config: Optional[RecommenderConfig] = None
|
| 98 |
+
):
|
| 99 |
+
self.config = config or RecommenderConfig()
|
| 100 |
+
self.df = self._load_data(csv_path)
|
| 101 |
+
self._validate_data()
|
| 102 |
+
|
| 103 |
+
self.scaler = MinMaxScaler()
|
| 104 |
+
self.tfidf = TfidfVectorizer(
|
| 105 |
+
stop_words=self.config.stopwords,
|
| 106 |
+
ngram_range=self.config.tfidf_ngram_range,
|
| 107 |
+
max_features=self.config.tfidf_max_features,
|
| 108 |
+
min_df=self.config.tfidf_min_df,
|
| 109 |
+
sublinear_tf=True,
|
| 110 |
+
token_pattern=r'(?u)\b[a-zA-Z]{2,}\b'
|
| 111 |
+
)
|
| 112 |
+
|
| 113 |
+
self.feature_matrix: Optional[np.ndarray] = None
|
| 114 |
+
self.text_matrix = None
|
| 115 |
+
self._feature_names: List[str] = []
|
| 116 |
+
|
| 117 |
+
self._clean_data()
|
| 118 |
+
self._prepare_numeric_features()
|
| 119 |
+
self._prepare_text_features()
|
| 120 |
+
|
| 121 |
+
logger.info(f"Recommender initialized with {len(self.df)} warkops")
|
| 122 |
+
|
| 123 |
+
# -------------------------------------------------------------------------
|
| 124 |
+
# Data Loading & Validation
|
| 125 |
+
# -------------------------------------------------------------------------
|
| 126 |
+
@staticmethod
|
| 127 |
+
def _load_data(source: Union[str, Path, pd.DataFrame]) -> pd.DataFrame:
|
| 128 |
+
if isinstance(source, pd.DataFrame):
|
| 129 |
+
return source.copy()
|
| 130 |
+
path = Path(source)
|
| 131 |
+
if not path.exists():
|
| 132 |
+
raise WarkopRecommenderError(f"File not found: {path}")
|
| 133 |
+
try:
|
| 134 |
+
return pd.read_csv(path)
|
| 135 |
+
except Exception as e:
|
| 136 |
+
raise WarkopRecommenderError(f"Failed to read CSV: {e}") from e
|
| 137 |
+
|
| 138 |
+
def _validate_data(self) -> None:
|
| 139 |
+
missing = set(self.config.required_columns) - set(self.df.columns)
|
| 140 |
+
if missing:
|
| 141 |
+
raise WarkopRecommenderError(f"Missing required columns: {missing}")
|
| 142 |
+
if self.df.empty:
|
| 143 |
+
raise WarkopRecommenderError("Dataset is empty")
|
| 144 |
+
|
| 145 |
+
def _clean_data(self) -> None:
|
| 146 |
+
"""Standardize categorical & numeric columns."""
|
| 147 |
+
categorical_cols = ['socket_availability', 'noise_level', 'price_range']
|
| 148 |
+
for col in categorical_cols:
|
| 149 |
+
self.df[col] = (
|
| 150 |
+
self.df[col].astype(str).str.strip().str.title()
|
| 151 |
+
.replace('Nan', 'Medium').fillna('Medium')
|
| 152 |
+
)
|
| 153 |
+
|
| 154 |
+
self.df['wifi_speed_mbps'] = pd.to_numeric(
|
| 155 |
+
self.df['wifi_speed_mbps'], errors='coerce'
|
| 156 |
+
).fillna(0)
|
| 157 |
+
|
| 158 |
+
for col in ['name', 'address', 'vibe_category']:
|
| 159 |
+
self.df[col] = self.df[col].fillna('').astype(str).str.strip()
|
| 160 |
+
|
| 161 |
+
# -------------------------------------------------------------------------
|
| 162 |
+
# Feature Engineering
|
| 163 |
+
# -------------------------------------------------------------------------
|
| 164 |
+
def _prepare_numeric_features(self) -> None:
|
| 165 |
+
features = pd.DataFrame(index=self.df.index)
|
| 166 |
+
|
| 167 |
+
max_wifi = self.df['wifi_speed_mbps'].max()
|
| 168 |
+
features['wifi'] = (
|
| 169 |
+
self.df['wifi_speed_mbps'] / max_wifi if max_wifi > 0 else 0
|
| 170 |
+
)
|
| 171 |
+
features['sockets'] = (
|
| 172 |
+
self.df['socket_availability'].map(self.config.socket_levels).fillna(2) / 3.0
|
| 173 |
+
)
|
| 174 |
+
noise_num = self.df['noise_level'].map(self.config.noise_levels).fillna(2)
|
| 175 |
+
features['quiet'] = 1 - (noise_num / 3.0)
|
| 176 |
+
price_num = self.df['price_range'].map(self.config.price_levels).fillna(2)
|
| 177 |
+
features['value'] = 1 - (price_num / 3.0)
|
| 178 |
+
|
| 179 |
+
self._feature_names = features.columns.tolist()
|
| 180 |
+
self.feature_matrix = self.scaler.fit_transform(features)
|
| 181 |
+
|
| 182 |
+
def _prepare_text_features(self) -> None:
|
| 183 |
+
"""Build text metadata combining vibe, name, address, and categorical info."""
|
| 184 |
+
vibe_clean = self.df['vibe_category'].str.replace('/', ' ').str.lower()
|
| 185 |
+
|
| 186 |
+
self.df['metadata'] = (
|
| 187 |
+
self.df['name'].str.lower() + ' ' +
|
| 188 |
+
self.df['address'].str.lower() + ' ' +
|
| 189 |
+
vibe_clean + ' ' +
|
| 190 |
+
self.df['noise_level'].str.lower() + ' noise ' +
|
| 191 |
+
self.df['price_range'].str.lower() + ' price ' +
|
| 192 |
+
self.df['socket_availability'].str.lower() + ' socket'
|
| 193 |
+
)
|
| 194 |
+
|
| 195 |
+
self.text_matrix = self.tfidf.fit_transform(self.df['metadata'])
|
| 196 |
+
|
| 197 |
+
# -------------------------------------------------------------------------
|
| 198 |
+
# Recommendation: Weight-Based
|
| 199 |
+
# -------------------------------------------------------------------------
|
| 200 |
+
def recommend_by_weights(
|
| 201 |
+
self,
|
| 202 |
+
wifi_imp: float = 0.4,
|
| 203 |
+
socket_imp: float = 0.3,
|
| 204 |
+
quiet_imp: float = 0.2,
|
| 205 |
+
value_imp: float = 0.1,
|
| 206 |
+
top_n: int = 5,
|
| 207 |
+
filters: Optional[Dict[str, Union[str, List[str]]]] = None
|
| 208 |
+
) -> pd.DataFrame:
|
| 209 |
+
"""Recommendation based on weighted preferences."""
|
| 210 |
+
weights = np.array([wifi_imp, socket_imp, quiet_imp, value_imp], dtype=float)
|
| 211 |
+
|
| 212 |
+
if np.any(weights < 0):
|
| 213 |
+
raise ValueError("Weights must be non-negative")
|
| 214 |
+
total = weights.sum()
|
| 215 |
+
if total == 0:
|
| 216 |
+
raise ValueError("At least one weight must be positive")
|
| 217 |
+
weights = weights / total
|
| 218 |
+
|
| 219 |
+
scores = self.feature_matrix @ weights
|
| 220 |
+
mask = self._build_filter_mask(filters)
|
| 221 |
+
masked_scores = np.where(mask, scores, -np.inf)
|
| 222 |
+
|
| 223 |
+
n_available = int(mask.sum())
|
| 224 |
+
if n_available == 0:
|
| 225 |
+
logger.warning("No warkops match the filters")
|
| 226 |
+
return pd.DataFrame()
|
| 227 |
+
|
| 228 |
+
top_n = min(top_n, n_available)
|
| 229 |
+
top_indices = np.argsort(masked_scores)[::-1][:top_n]
|
| 230 |
+
|
| 231 |
+
results = self.df.iloc[top_indices].copy()
|
| 232 |
+
results['match_score'] = (scores[top_indices] * 100).round(2)
|
| 233 |
+
return self._clean_output(results)
|
| 234 |
+
|
| 235 |
+
# -------------------------------------------------------------------------
|
| 236 |
+
# Recommendation: Story / Semantic Search
|
| 237 |
+
# -------------------------------------------------------------------------
|
| 238 |
+
def recommend_by_story(
|
| 239 |
+
self,
|
| 240 |
+
user_query: str,
|
| 241 |
+
top_n: int = 3,
|
| 242 |
+
min_similarity: float = 0.0,
|
| 243 |
+
filters: Optional[Dict[str, Union[str, List[str]]]] = None
|
| 244 |
+
) -> pd.DataFrame:
|
| 245 |
+
"""Semantic search via natural language query."""
|
| 246 |
+
if not user_query or not user_query.strip():
|
| 247 |
+
return self._clean_output(self.df.head(top_n).copy())
|
| 248 |
+
|
| 249 |
+
similarities = self._compute_query_similarity(user_query.lower().strip())
|
| 250 |
+
mask = self._build_filter_mask(filters)
|
| 251 |
+
masked_sims = np.where(mask, similarities, -1)
|
| 252 |
+
|
| 253 |
+
top_indices = np.argsort(masked_sims)[::-1][:top_n]
|
| 254 |
+
|
| 255 |
+
if masked_sims[top_indices[0]] <= min_similarity:
|
| 256 |
+
logger.info("Low similarity, falling back to weighted defaults")
|
| 257 |
+
return self.recommend_by_weights(top_n=top_n, filters=filters)
|
| 258 |
+
|
| 259 |
+
results = self.df.iloc[top_indices].copy()
|
| 260 |
+
results['match_score'] = (similarities[top_indices] * 100).round(2)
|
| 261 |
+
return self._clean_output(results)
|
| 262 |
+
|
| 263 |
+
# -------------------------------------------------------------------------
|
| 264 |
+
# Recommendation: Hybrid
|
| 265 |
+
# -------------------------------------------------------------------------
|
| 266 |
+
def recommend_hybrid(
|
| 267 |
+
self,
|
| 268 |
+
user_query: str = "",
|
| 269 |
+
weights: Optional[Dict[str, float]] = None,
|
| 270 |
+
text_ratio: float = 0.5,
|
| 271 |
+
top_n: int = 5,
|
| 272 |
+
filters: Optional[Dict[str, Union[str, List[str]]]] = None
|
| 273 |
+
) -> pd.DataFrame:
|
| 274 |
+
"""Hybrid: combines numeric weights + semantic similarity."""
|
| 275 |
+
if not 0 <= text_ratio <= 1:
|
| 276 |
+
raise ValueError("text_ratio must be between 0 and 1")
|
| 277 |
+
|
| 278 |
+
w = {**self.DEFAULT_WEIGHTS, **(weights or {})}
|
| 279 |
+
weight_arr = np.array([w['wifi_imp'], w['socket_imp'], w['quiet_imp'], w['value_imp']])
|
| 280 |
+
weight_arr = weight_arr / weight_arr.sum()
|
| 281 |
+
|
| 282 |
+
numeric_scores = self.feature_matrix @ weight_arr
|
| 283 |
+
text_scores = (
|
| 284 |
+
self._compute_query_similarity(user_query.lower().strip())
|
| 285 |
+
if user_query.strip() else np.zeros(len(self.df))
|
| 286 |
+
)
|
| 287 |
+
|
| 288 |
+
effective_ratio = text_ratio if user_query.strip() else 0.0
|
| 289 |
+
combined = effective_ratio * text_scores + (1 - effective_ratio) * numeric_scores
|
| 290 |
+
|
| 291 |
+
mask = self._build_filter_mask(filters)
|
| 292 |
+
combined_masked = np.where(mask, combined, -np.inf)
|
| 293 |
+
|
| 294 |
+
n_available = int(mask.sum())
|
| 295 |
+
if n_available == 0:
|
| 296 |
+
return pd.DataFrame()
|
| 297 |
+
top_n = min(top_n, n_available)
|
| 298 |
+
top_indices = np.argsort(combined_masked)[::-1][:top_n]
|
| 299 |
+
|
| 300 |
+
results = self.df.iloc[top_indices].copy()
|
| 301 |
+
results['match_score'] = (combined[top_indices] * 100).round(2)
|
| 302 |
+
return self._clean_output(results)
|
| 303 |
+
|
| 304 |
+
# -------------------------------------------------------------------------
|
| 305 |
+
# Find Similar
|
| 306 |
+
# -------------------------------------------------------------------------
|
| 307 |
+
def find_similar(self, warkop_name: str, top_n: int = 3) -> pd.DataFrame:
|
| 308 |
+
"""Find warkops similar to a given one."""
|
| 309 |
+
matches = self.df[self.df['name'].str.contains(warkop_name, case=False, na=False, regex=False)]
|
| 310 |
+
if matches.empty:
|
| 311 |
+
raise WarkopRecommenderError(f"Warkop '{warkop_name}' not found")
|
| 312 |
+
|
| 313 |
+
idx = matches.index[0]
|
| 314 |
+
sim_numeric = cosine_similarity(
|
| 315 |
+
self.feature_matrix[idx].reshape(1, -1), self.feature_matrix
|
| 316 |
+
).flatten()
|
| 317 |
+
sim_text = cosine_similarity(self.text_matrix[idx], self.text_matrix).flatten()
|
| 318 |
+
combined = (sim_numeric + sim_text) / 2
|
| 319 |
+
combined[idx] = -1 # exclude self
|
| 320 |
+
|
| 321 |
+
top_indices = np.argsort(combined)[::-1][:top_n]
|
| 322 |
+
results = self.df.iloc[top_indices].copy()
|
| 323 |
+
results['similarity_score'] = (combined[top_indices] * 100).round(2)
|
| 324 |
+
return self._clean_output(results)
|
| 325 |
+
|
| 326 |
+
# -------------------------------------------------------------------------
|
| 327 |
+
# Preset Recommendations
|
| 328 |
+
# -------------------------------------------------------------------------
|
| 329 |
+
def recommend_by_preset(self, preset: str, top_n: int = 5,
|
| 330 |
+
filters: Optional[Dict] = None) -> pd.DataFrame:
|
| 331 |
+
"""Quick recommendation using preset profile."""
|
| 332 |
+
if preset not in self.PRESET_PROFILES:
|
| 333 |
+
raise ValueError(f"Unknown preset: {preset}. Choose from {list(self.PRESET_PROFILES)}")
|
| 334 |
+
weights = self.PRESET_PROFILES[preset]
|
| 335 |
+
return self.recommend_by_weights(**weights, top_n=top_n, filters=filters)
|
| 336 |
+
|
| 337 |
+
# -------------------------------------------------------------------------
|
| 338 |
+
# Filter Helpers
|
| 339 |
+
# -------------------------------------------------------------------------
|
| 340 |
+
def _build_filter_mask(
|
| 341 |
+
self, filters: Optional[Dict[str, Union[str, List[str]]]]
|
| 342 |
+
) -> np.ndarray:
|
| 343 |
+
mask = np.ones(len(self.df), dtype=bool)
|
| 344 |
+
if not filters:
|
| 345 |
+
return mask
|
| 346 |
+
for col, value in filters.items():
|
| 347 |
+
if col not in self.df.columns:
|
| 348 |
+
logger.warning(f"Filter column '{col}' not found, skipping")
|
| 349 |
+
continue
|
| 350 |
+
values = [value] if isinstance(value, str) else list(value)
|
| 351 |
+
|
| 352 |
+
# Special handling for vibe_category (substring match)
|
| 353 |
+
if col == 'vibe_category':
|
| 354 |
+
col_mask = self.df[col].str.lower().apply(
|
| 355 |
+
lambda x: any(v.lower() in x for v in values)
|
| 356 |
+
).values
|
| 357 |
+
else:
|
| 358 |
+
col_mask = self.df[col].isin(values).values
|
| 359 |
+
mask &= col_mask
|
| 360 |
+
return mask
|
| 361 |
+
|
| 362 |
+
@staticmethod
|
| 363 |
+
def _clean_output(df: pd.DataFrame) -> pd.DataFrame:
|
| 364 |
+
cols_to_drop = [c for c in ['metadata'] if c in df.columns]
|
| 365 |
+
return df.drop(columns=cols_to_drop).reset_index(drop=True)
|
| 366 |
+
|
| 367 |
+
@lru_cache(maxsize=128)
|
| 368 |
+
def _compute_query_similarity(self, query: str) -> np.ndarray:
|
| 369 |
+
query_vec = self.tfidf.transform([query])
|
| 370 |
+
return cosine_similarity(query_vec, self.text_matrix).flatten()
|
| 371 |
+
|
| 372 |
+
# -------------------------------------------------------------------------
|
| 373 |
+
# Statistics
|
| 374 |
+
# -------------------------------------------------------------------------
|
| 375 |
+
def get_quick_stats(self) -> dict:
|
| 376 |
+
"""Return summary statistics for dashboard."""
|
| 377 |
+
all_vibes = []
|
| 378 |
+
for v in self.df['vibe_category'].dropna():
|
| 379 |
+
all_vibes.extend([t.strip() for t in v.split('/')])
|
| 380 |
+
vibe_counts = pd.Series(all_vibes).value_counts().to_dict()
|
| 381 |
+
|
| 382 |
+
return {
|
| 383 |
+
'Total Warkop': int(len(self.df)),
|
| 384 |
+
'WiFi Tercepat (Mbps)': float(self.df['wifi_speed_mbps'].max()),
|
| 385 |
+
'WiFi Rata-rata (Mbps)': round(float(self.df['wifi_speed_mbps'].mean()), 2),
|
| 386 |
+
'WiFi Terlambat (Mbps)': float(self.df['wifi_speed_mbps'].min()),
|
| 387 |
+
'Spot Paling Tenang': int((self.df['noise_level'] == 'Low').sum()),
|
| 388 |
+
'Spot Bising': int((self.df['noise_level'] == 'High').sum()),
|
| 389 |
+
'Budget Friendly': int((self.df['price_range'] == 'Cheap').sum()),
|
| 390 |
+
'Premium': int((self.df['price_range'] == 'Expensive').sum()),
|
| 391 |
+
'Socket Melimpah': int((self.df['socket_availability'] == 'High').sum()),
|
| 392 |
+
'Socket Sedikit': int((self.df['socket_availability'] == 'Low').sum()),
|
| 393 |
+
'Top Vibe Tags': dict(list(vibe_counts.items())[:10]),
|
| 394 |
+
'Total Unique Vibes': len(vibe_counts),
|
| 395 |
+
}
|
| 396 |
+
|
| 397 |
+
def get_all_vibe_tags(self) -> List[str]:
|
| 398 |
+
"""Get all unique vibe tags (split by /)."""
|
| 399 |
+
all_vibes = set()
|
| 400 |
+
for v in self.df['vibe_category'].dropna():
|
| 401 |
+
for tag in v.split('/'):
|
| 402 |
+
all_vibes.add(tag.strip())
|
| 403 |
+
return sorted(all_vibes)
|
| 404 |
+
|
| 405 |
+
def __repr__(self) -> str:
|
| 406 |
+
return f"<WarkopRecommender(n={len(self.df)}, features={self._feature_names})>"
|
| 407 |
+
|
| 408 |
+
def __len__(self) -> int:
|
| 409 |
+
return len(self.df)
|
requirements.txt
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
gradio>=4.0.0
|
| 2 |
+
pandas>=2.0.0
|
| 3 |
+
numpy>=1.24.0
|
| 4 |
+
scikit-learn>=1.3.0
|
| 5 |
+
tabulate>=0.9.0
|