Bangkah commited on
Commit
46d58de
·
1 Parent(s): 8289280

Initial deployment

Browse files
README.md CHANGED
@@ -1,13 +1,30 @@
1
  ---
2
- title: Warkop Intelligence
3
- emoji: 🦀
4
- colorFrom: purple
5
- colorTo: gray
6
  sdk: gradio
7
- sdk_version: 6.14.0
8
  app_file: app.py
9
  pinned: false
10
- license: mit
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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