DYmazeh commited on
Commit
03b34b2
·
verified ·
1 Parent(s): dd4a83e

deploy: 59cac6b1dd28

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .dockerignore +43 -0
  2. .gitattributes +1 -0
  3. .gitignore +118 -0
  4. Dockerfile +106 -0
  5. LAPORAN.md +722 -0
  6. LICENSE +21 -0
  7. README.md +231 -5
  8. backend/.env.example +6 -0
  9. backend/alembic.ini +49 -0
  10. backend/alembic/env.py +78 -0
  11. backend/alembic/script.py.mako +26 -0
  12. backend/alembic/versions/001_initial_schema.py +129 -0
  13. backend/app/__init__.py +1 -0
  14. backend/app/api/__init__.py +1 -0
  15. backend/app/api/eval.py +170 -0
  16. backend/app/api/health.py +138 -0
  17. backend/app/api/insights.py +126 -0
  18. backend/app/api/listings.py +62 -0
  19. backend/app/api/search.py +232 -0
  20. backend/app/core/__init__.py +1 -0
  21. backend/app/core/config.py +65 -0
  22. backend/app/core/db.py +76 -0
  23. backend/app/evaluation/README.md +172 -0
  24. backend/app/evaluation/__init__.py +37 -0
  25. backend/app/evaluation/kappa.py +135 -0
  26. backend/app/evaluation/metrics.py +197 -0
  27. backend/app/evaluation/runner.py +195 -0
  28. backend/app/evaluation/statistical.py +171 -0
  29. backend/app/indexing/README.md +189 -0
  30. backend/app/indexing/__init__.py +30 -0
  31. backend/app/indexing/base.py +64 -0
  32. backend/app/indexing/bm25.py +84 -0
  33. backend/app/indexing/build.py +119 -0
  34. backend/app/indexing/hybrid.py +127 -0
  35. backend/app/indexing/indobert.py +178 -0
  36. backend/app/indexing/loader.py +68 -0
  37. backend/app/indexing/tfidf.py +94 -0
  38. backend/app/main.py +226 -0
  39. backend/app/models/__init__.py +11 -0
  40. backend/app/models/eval.py +144 -0
  41. backend/app/models/listing.py +98 -0
  42. backend/app/models/search.py +28 -0
  43. backend/app/preprocessing/README.md +161 -0
  44. backend/app/preprocessing/__init__.py +36 -0
  45. backend/app/preprocessing/jargon.py +205 -0
  46. backend/app/preprocessing/normalizer.py +74 -0
  47. backend/app/preprocessing/pipeline.py +187 -0
  48. backend/app/preprocessing/spelling.py +68 -0
  49. backend/app/preprocessing/stemmer.py +41 -0
  50. backend/app/preprocessing/stopwords.py +68 -0
.dockerignore ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Hindari bloat build context (yang Docker daemon kirim ke builder)
2
+
3
+ # Python
4
+ **/__pycache__
5
+ **/*.pyc
6
+ **/*.pyo
7
+ **/.venv
8
+ **/venv
9
+ **/.pytest_cache
10
+ **/.coverage
11
+ **/htmlcov
12
+ **/coverage.xml
13
+
14
+ # Node
15
+ **/node_modules
16
+ **/dist
17
+ **/.vite
18
+
19
+ # Env files (NEVER ship)
20
+ **/.env
21
+ **/.env.local
22
+
23
+ # Git
24
+ .git
25
+ .gitignore
26
+ **/.gitkeep
27
+
28
+ # IDE / OS
29
+ **/.vscode
30
+ **/.idea
31
+ **/*.swp
32
+ **/.DS_Store
33
+ **/Thumbs.db
34
+
35
+ # Dev artifacts
36
+ **/.scrape_cache
37
+ **/notebooks/.ipynb_checkpoints
38
+ **/*.log
39
+
40
+ # Tests + docs (gak perlu di runtime image)
41
+ docs/
42
+ .github/
43
+ **/tests
.gitattributes CHANGED
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ data/indexes/indobert/faiss.index filter=lfs diff=lfs merge=lfs -text
.gitignore ADDED
@@ -0,0 +1,118 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ============================================================================
2
+ # Python
3
+ # ============================================================================
4
+ __pycache__/
5
+ *.py[cod]
6
+ *$py.class
7
+ *.so
8
+ .Python
9
+ build/
10
+ develop-eggs/
11
+ dist/
12
+ downloads/
13
+ eggs/
14
+ .eggs/
15
+ lib/
16
+ lib64/
17
+ parts/
18
+ sdist/
19
+ var/
20
+ wheels/
21
+ *.egg-info/
22
+ .installed.cfg
23
+ *.egg
24
+ MANIFEST
25
+
26
+ # Virtual environments
27
+ .venv/
28
+ venv/
29
+ env/
30
+ ENV/
31
+
32
+ # Jupyter
33
+ .ipynb_checkpoints/
34
+
35
+ # pytest / coverage
36
+ .coverage
37
+ htmlcov/
38
+ .pytest_cache/
39
+ .tox/
40
+ .nox/
41
+ coverage.xml
42
+ *.cover
43
+
44
+ # ============================================================================
45
+ # Node / Frontend
46
+ # ============================================================================
47
+ node_modules/
48
+ dist/
49
+ dist-ssr/
50
+ *.local
51
+ .vite/
52
+
53
+ # ============================================================================
54
+ # Environment variables (NEVER COMMIT)
55
+ # ============================================================================
56
+ .env
57
+ .env.local
58
+ .env.*.local
59
+ !.env.example
60
+
61
+ # ============================================================================
62
+ # Data files (large, re-buildable)
63
+ # ============================================================================
64
+ data/raw/*.json
65
+ data/raw/*.csv
66
+ data/raw/*.html
67
+ data/raw/*.jsonl
68
+ data/processed/*.json
69
+ data/processed/*.csv
70
+ data/processed/*.parquet
71
+ data/indexes/*.pkl
72
+ data/indexes/*.index
73
+ data/indexes/*.bin
74
+ data/indexes/*.faiss
75
+ data/indexes/*.npy
76
+ !data/raw/.gitkeep
77
+ !data/processed/.gitkeep
78
+ !data/indexes/.gitkeep
79
+ # Whitelist: real Mamikos corpus (source of truth) + built indexes untuk Docker COPY
80
+ !data/raw/mamikos_real_v2.jsonl
81
+ !data/raw/_discovered_slugs.txt
82
+ !data/processed/corpus.json
83
+ !data/indexes/tfidf.pkl
84
+ !data/indexes/bm25.pkl
85
+ !data/indexes/indobert/
86
+
87
+ # Scrape cache (during dev)
88
+ .scrape_cache/
89
+
90
+ # Database dumps
91
+ *.db
92
+ *.sqlite
93
+ *.sqlite3
94
+ *.dump
95
+
96
+ # ============================================================================
97
+ # Logs
98
+ # ============================================================================
99
+ *.log
100
+ logs/
101
+
102
+ # ============================================================================
103
+ # IDE / OS
104
+ # ============================================================================
105
+ .vscode/
106
+ .idea/
107
+ *.swp
108
+ *.swo
109
+ *~
110
+ .DS_Store
111
+ Thumbs.db
112
+ desktop.ini
113
+
114
+ # ============================================================================
115
+ # Misc
116
+ # ============================================================================
117
+ .render-buildlog
118
+ *.pid
Dockerfile ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ============================================================================
2
+ # KozyNear single-container deploy
3
+ # React frontend + FastAPI backend serve dari 1 service
4
+ #
5
+ # Multi-stage build:
6
+ # Stage 1 (Node) -> npm install + vite build -> /app/frontend/dist
7
+ # Stage 2 (Python) -> copy frontend dist + install Python deps + uvicorn
8
+ #
9
+ # Routes di runtime:
10
+ # GET / -> React SPA (index.html)
11
+ # GET /assets/* -> built JS/CSS chunks
12
+ # GET /favicon.ico, dll -> static files
13
+ # GET /health -> liveness probe (Render uses this)
14
+ # GET /api/search, /api/listings/{id}, /api/eval/* -> FastAPI endpoints
15
+ # GET /api/docs -> Swagger UI
16
+ # ============================================================================
17
+
18
+ # ----------------------------------------------------------------------------
19
+ # Stage 1: Build React frontend
20
+ # ----------------------------------------------------------------------------
21
+ FROM node:20-alpine AS frontend-builder
22
+
23
+ WORKDIR /app/frontend
24
+
25
+ # Layer caching: install deps dulu (gak invalidate kalau code berubah)
26
+ COPY frontend/package*.json ./
27
+ RUN npm install
28
+
29
+ # Build
30
+ COPY frontend ./
31
+ # VITE_API_URL kosong -> frontend pakai relative URL (same origin sebagai API)
32
+ ENV VITE_API_URL=""
33
+ RUN npm run build
34
+
35
+
36
+ # ----------------------------------------------------------------------------
37
+ # Stage 2: Python runtime
38
+ # ----------------------------------------------------------------------------
39
+ FROM python:3.11-slim
40
+
41
+ # System deps untuk lxml (parsing HTML scraper) + psycopg2 + faiss (libgomp1:
42
+ # wheel faiss-cpu link ke libgomp yang tidak ada di python-slim)
43
+ RUN apt-get update && apt-get install -y --no-install-recommends \
44
+ build-essential \
45
+ libxml2-dev \
46
+ libxslt-dev \
47
+ libpq-dev \
48
+ libgomp1 \
49
+ curl \
50
+ && rm -rf /var/lib/apt/lists/*
51
+
52
+ WORKDIR /app
53
+
54
+ # Install RUNTIME deps + NEURAL deps. Satu image untuk dua platform:
55
+ # - Render free 512MB: ENABLE_NEURAL=false -> fastembed/faiss tidak di-import,
56
+ # RAM aman (paket cuma duduk di disk).
57
+ # - HF Spaces 16GB ($0): ENABLE_NEURAL=true -> MiniLM + Hybrid live.
58
+ # Full set dev (scraping, pandas, dev tools) ada di requirements.txt.
59
+ COPY backend/requirements-runtime.txt backend/requirements-neural.txt ./
60
+ RUN pip install --no-cache-dir --upgrade pip && \
61
+ pip install --no-cache-dir -r requirements-runtime.txt \
62
+ -r requirements-neural.txt
63
+
64
+ # Pre-download model ONNX MiniLM ke cache yang dipakai runtime
65
+ # (FASTEMBED_CACHE_PATH) supaya startup dengan ENABLE_NEURAL=true tidak
66
+ # download ~120MB tiap cold start (HF Spaces disk-nya ephemeral).
67
+ ENV FASTEMBED_CACHE_PATH=/app/.fastembed_cache
68
+ RUN python -c "import os; from fastembed import TextEmbedding; \
69
+ TextEmbedding('sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2', \
70
+ cache_dir=os.environ['FASTEMBED_CACHE_PATH'])"
71
+
72
+ # Copy backend code
73
+ COPY backend ./backend
74
+ # Copy data folder (mamikos_real_v2 source + corpus.json + pre-built indexes)
75
+ COPY data ./data
76
+ # Copy hasil evaluasi (CSV) — dibaca /api/eval/* (file-based dashboard)
77
+ COPY eval ./eval
78
+
79
+ # Copy built frontend dari stage 1
80
+ COPY --from=frontend-builder /app/frontend/dist ./static
81
+
82
+ # Working dir backend supaya alembic + relative imports berfungsi
83
+ WORKDIR /app/backend
84
+
85
+ # Render expose $PORT via env (default 10000 kalau gak set)
86
+ ENV PORT=10000
87
+ EXPOSE 10000
88
+
89
+ # Startup pipeline:
90
+ # 1. alembic upgrade head -- create/update schema (idempotent)
91
+ # 2. seed_db.py --truncate --skip-if-synced: reconcile listings table ke
92
+ # mamikos_real_v2.jsonl (227 real, sesudah filter deskripsi kosong).
93
+ # --skip-if-synced: kalau count + digest id DB == source, seed di-skip
94
+ # (cold start lebih cepat); kalau beda, TRUNCATE CASCADE + reseed atomic.
95
+ # 3. uvicorn start FastAPI -- lifespan load TF-IDF + BM25 + gazetteer.
96
+ # Neural (MiniLM) ikut di-load HANYA kalau ENABLE_NEURAL=true
97
+ # (HF Spaces 16GB); di Render free biarkan false. Default search
98
+ # model = "smart" (query understanding + geo + BM25).
99
+ # Catatan resiliency: alembic/seed TIDAK boleh mematikan container kalau
100
+ # DB unreachable (mis. Space baru yang DATABASE_URL-nya belum di-set).
101
+ # App tetap up: /health, frontend, /api/stats (fallback corpus), /api/eval,
102
+ # /api/preprocess jalan; /api/search yang butuh DB akan error jelas dan
103
+ # /api/status menampilkan database.connected=false untuk debugging.
104
+ CMD (alembic upgrade head || echo "[startup] alembic SKIPPED (DB unreachable? cek DATABASE_URL; continuing)") \
105
+ && (python -m scripts.seed_db --input ../data/raw/mamikos_real_v2.jsonl --truncate --skip-if-synced || echo "[startup] seed_db SKIPPED (continuing)") \
106
+ && uvicorn app.main:app --host 0.0.0.0 --port $PORT
LAPORAN.md ADDED
@@ -0,0 +1,722 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # LAPORAN AKHIR — KozyNear
2
+
3
+ **Mata Kuliah**: Temu Kembali Informasi (STKI, COM620321, 3 SKS)
4
+ **Universitas**: Lampung (UNILA), FMIPA
5
+ **Project**: Indonesian Kos-Kosan Search Engine — **seluruh Bandar Lampung untuk semua universitas**
6
+ **Submission**: 17 Juni 2026 (vClass)
7
+ **Repository**: https://github.com/DYmazeh/KozyNear
8
+ **Live Demo**: https://dymazeh-kozynear.hf.space (HF Spaces, neural ON) | backup: https://kozynear.onrender.com (Render, smart-only)
9
+
10
+ ---
11
+
12
+ ## 1. Abstract
13
+
14
+ KozyNear adalah Information Retrieval (IR) system untuk listing kos-kosan
15
+ di **Bandar Lampung** (18 kecamatan dengan data real, 9 universitas: UNILA,
16
+ ITERA, Darmajaya, UBL, UIN Raden Intan, Teknokrat, Polinela, Malahayati, Saburai).
17
+ Sistem mendukung natural language query bahasa Indonesia dengan
18
+ domain-specific preprocessing pipeline dan membandingkan **lima model
19
+ retrieval**: TF-IDF, BM25, neural semantic (MiniLM multilingual, label
20
+ kode "IndoBERT"), hybrid (BM25 → MiniLM rerank), dan **Smart** — pipeline
21
+ produksi berbasis query understanding (parser rule-based) + geo ranking
22
+ (gazetteer kampus terverifikasi + haversine) + BM25 fusion.
23
+
24
+ Corpus terdiri dari **227 listing kos REAL** hasil scrape Mamikos.com
25
+ (deskripsi pemilik asli + koordinat asli, 18 kecamatan), bukan synthetic.
26
+ Evaluasi pada **30 query × 900 ground truth annotations** (AI-assisted
27
+ 3-annotator simulation, Cohen's Kappa 0.58-0.87) menempatkan **Smart
28
+ sebagai best performer standard top-K by MAP** (MAP 0.301, P@5 0.580,
29
+ MRR 0.868) diikuti BM25 (MAP 0.270, P@5 0.553); signifikansi pairwise
30
+ diuji Wilcoxon signed-rank dengan koreksi **Holm-Bonferroni** (4 dari 10
31
+ pasangan signifikan; semua melibatkan model neural pada standard eval).
32
+ Pada lensa **Constraint Satisfaction@5** (proporsi top-5 yang memenuhi
33
+ semua kebutuhan eksplisit user: gender, budget, fasilitas, radius 3 km
34
+ dari kampus), Smart unggul jauh atas BM25 (**0.86 vs 0.45**, Wilcoxon
35
+ p<0.001). Hyperparameter dipilih lewat grid search: bobot fusion Smart
36
+ 0.2/0.4/0.4 (teks/geo/atribut) dan alpha Hybrid 0.9.
37
+
38
+ Finding penting: **model neural terlihat underperform di standard top-K
39
+ eval (P@5 0.160)** semata-mata karena *pooling bias* — ground truth
40
+ di-pool dari kandidat BM25, sehingga hasil semantic yang unik tidak
41
+ ter-judge. Pada **pool-restricted evaluation** (fair, hanya judged docs),
42
+ neural justru kompetitif (MAP 0.557 vs BM25 0.583). Ini menegaskan
43
+ pentingnya melaporkan kedua angka untuk corpus IR yang di-pool secara
44
+ lexical. Koordinat real 226/227 listing dipakai untuk geo ranking Smart
45
+ dan integrasi Google Maps.
46
+
47
+ ---
48
+
49
+ ## 2. Latar Belakang
50
+
51
+ ### 2.1 Motivasi
52
+
53
+ Mahasiswa di Bandar Lampung — baik dari UNILA, ITERA, Darmajaya, UBL,
54
+ UIN, Teknokrat, Polinela, Malahayati, maupun Saburai — membutuhkan
55
+ informasi kos-kosan strategis sekitar kampus mereka. Platform existing
56
+ seperti Mamikos kurang optimal untuk Indonesian natural language karena
57
+ tidak mendukung sinonim domain (e.g., "kmd" vs "kamar mandi dalam") dan
58
+ tidak fokus geografis ke Bandar Lampung.
59
+
60
+ ### 2.2 Tujuan
61
+
62
+ 1. Membangun search engine khusus kos-kosan **seluruh Bandar Lampung**
63
+ untuk **semua universitas** (universitas-agnostic)
64
+ 2. Membandingkan tiga paradigma IR utama + hybrid: lexical (TF-IDF),
65
+ probabilistic (BM25), neural/semantic (IndoBERT+FAISS)
66
+ 3. Mengevaluasi performa quantitative via metric standar IR dengan
67
+ inter-annotator agreement protocol
68
+ 4. Deploy sebagai web app yang accessible publik
69
+
70
+ ### 2.3 Ruang Lingkup
71
+
72
+ **In scope**: text retrieval atas data REAL Mamikos, 18 kecamatan Bandar
73
+ Lampung, 9 universitas target, deskripsi pemilik asli, koordinat real
74
+ untuk Google Maps, deployment Render.com free tier.
75
+
76
+ **Out of scope**: image retrieval, real-time price updates, user auth,
77
+ booking, multi-city (kabupaten lain). Geo-spatial distance kini masuk
78
+ scope sebagai SALAH SATU sinyal fusion di model Smart (bobot 0.4
79
+ bersama teks 0.4 + atribut 0.2), bukan primary signal tunggal. Tipe
80
+ `pasutri` di luar scope — Mamikos hanya
81
+ mengklasifikasi gender 0/1/2 (campur/putra/putri); "pasutri" adalah
82
+ kategori marketing (campur yang boleh berdua), bukan tipe gender.
83
+
84
+ ---
85
+
86
+ ## 3. Dataset
87
+
88
+ ### 3.1 Acquisition Methodology — Real Mamikos Scrape
89
+
90
+ Corpus adalah **227 listing kos REAL** dari Mamikos.com, di-scrape via
91
+ pipeline 3-tahap (sumber: `data/raw/mamikos_real_v2.jsonl`):
92
+
93
+ 1. **Discovery** (`scripts/discover_mamikos_slugs.py` + WebSearch): kumpulkan
94
+ URL halaman detail `/room/`. Halaman kategori Mamikos JS-rendered dengan
95
+ API listing ter-enkripsi (AES), jadi discovery dilakukan via ~50 query
96
+ `site:mamikos.com inurl:/room/` (per-kecamatan, per-universitas,
97
+ per-fasilitas, per-harga, street-level) → **312 URL unik** terkumpul
98
+ dalam 5 ronde.
99
+ 2. **Detail extraction** (`scripts/extract_mamikos_detail.py`): setiap
100
+ halaman detail Mamikos meng-*inject* `var detail = {...}` (JSON 146
101
+ field) di static HTML — bisa di-fetch HTTP-only tanpa browser. Parse
102
+ field penting: `_id`, `room_title`, `description` (cerita pemilik asli),
103
+ `latitude`/`longitude`, `price_monthly`, `gender`, fasilitas
104
+ (fac_room/share/bath/park), booking_rules, owner_name, verification.
105
+ 3. **Canonical build** (`scripts/rebuild_v2.py`):
106
+ normalisasi kecamatan, hitung `jarak_kampus_km` via haversine ke 9
107
+ universitas, drop deskripsi kosong → `kozynear_combined.jsonl`.
108
+
109
+ Success rate ekstraksi ~76% (sisanya listing inactive/removed). Field
110
+ gender Mamikos hanya 0/1/2 → tipe **campur/putra/putri** (tidak ada pasutri).
111
+
112
+ ### 3.2 Honest Methodology Disclosure
113
+
114
+ Iterasi awal project memakai synthetic corpus (template-generated) untuk
115
+ mengejar volume. Namun karena web app ditujukan **production** (pengguna
116
+ mencari kos nyata + tampilan Google Maps), synthetic dibuang total:
117
+ koordinat synthetic yang fabricated akan menaruh pin di lokasi salah.
118
+
119
+ **Trade-off yang diterima secara sadar**: corpus real lebih kecil (227 vs
120
+ 2000 synthetic) karena Mamikos Bandar Lampung hanya memiliki ~300-400
121
+ listing unik dan WebSearch hanya meng-surface yang ter-index Google. Kami
122
+ memilih **kualitas + keaslian** di atas volume. Semua deskripsi adalah
123
+ tulisan asli pemilik kos (terverifikasi 0 template-leak), bukan generated.
124
+ Setiap listing punya `url_source` untuk traceability balik ke Mamikos.
125
+
126
+ ### 3.3 Statistik Corpus
127
+
128
+ | Metric | Value |
129
+ |--------|-------|
130
+ | Total listings | **227 (100% real Mamikos)** |
131
+ | Kecamatan covered | 18 unique (Bandar Lampung) |
132
+ | Tipe | campur 114 (50%), putri 85 (37%), putra 28 (13%) |
133
+ | Harga (per bulan) | min Rp 300k, median Rp 800k, max Rp 6jt |
134
+ | Deskripsi (kata) | median ~23 (cerita pemilik, terse — bukan padded) |
135
+ | Koordinat valid (Google Maps) | 226/227 (99.6%) |
136
+ | url_source / Mamikos-verified | 100% / 100% |
137
+ | owner_name present | 96% |
138
+ | File size | ~0.3 MB raw / ~0.6 MB processed |
139
+
140
+ **Distribusi listings per kecamatan** (top: Sukarame 40, Kedaton 37,
141
+ Rajabasa 32, Tanjung Karang Timur 14, Sukabumi 13):
142
+
143
+ ![Distribusi Kecamatan](notebooks/charts/01_kecamatan.png)
144
+
145
+ **Karakteristik data real vs synthetic**: deskripsi pemilik asli jauh lebih
146
+ *terse* (median 23 kata) dibanding synthetic (110+ kata padded). Contoh
147
+ nyata: *"belakang UNILA (sangat dekat, jalan kaki 5mnt), dekat rel kereta"*.
148
+ Ini menurunkan absolute IR metric (lebih sedikit term overlap) tetapi
149
+ mencerminkan **realita yang sebenarnya** dihadapi sistem production.
150
+
151
+ **Distribusi tipe + harga** (lihat notebook 01_eda.ipynb untuk chart lain):
152
+
153
+ ![Distribusi Tipe Kos](notebooks/charts/01_tipe.png)
154
+
155
+ ![Distribusi Harga](notebooks/charts/01_price.png)
156
+
157
+ ---
158
+
159
+ ## 4. Preprocessing
160
+
161
+ ### 4.1 Pipeline 9-Stage
162
+
163
+ ```
164
+ raw text
165
+ → 1. HTML strip (BeautifulSoup)
166
+ → 2. Whitespace normalize
167
+ → 3. Price extraction (SEBELUM lowercase — preserve Rp)
168
+ → 4. Lowercase
169
+ → 5. Custom jargon dictionary substitution (longest-first regex)
170
+ → 6. Spelling correction (typo dictionary)
171
+ → 7. Tokenize (whitespace + punctuation)
172
+ → 8. Stopword removal (Sastrawi default + custom domain)
173
+ → 9. Stem (Sastrawi StemmerFactory, LRU-cached)
174
+ ```
175
+
176
+ **Anti-pattern**: lowercase sebelum extract harga → regex `[Rr][Pp]` masih
177
+ match, tapi edge case `Rp850k` (tanpa space) jadi rapuh.
178
+
179
+ ### 4.2 Custom Jargon Dictionary
180
+
181
+ **~106 entries** (course requirement ≥100):
182
+ - ABBREVIATIONS: 43 (kmd, wc dlm, jt, rb, yg, krn, ...)
183
+ - LOCATIONS: 24 (gdg meneng, sumbro, unyila, ...)
184
+ - TYPE_SLANG: 11 (cowo, cewe, pria, wanita, ...) — entri `pasutri` dihapus
185
+ (bukan tipe gender valid di Mamikos)
186
+ - RULES: 5 (jamal, no smoking, ...)
187
+ - PAYMENT: 8 (dp, deposit, all in, ...)
188
+ - KOS_FORM: 15 (kostan, kontrakan, eksklusif, ...)
189
+
190
+ ### 4.3 Pipeline Performance + Stage Impact
191
+
192
+ Run pada 227 listing real:
193
+ - **Latency**: ~5 detik (Sastrawi LRU cache, deskripsi real lebih pendek)
194
+ - **Token reduction**: deskripsi terse pemilik → median ~20 token setelah
195
+ stemming (real owner text jauh lebih ringkas dari synthetic padded)
196
+
197
+ **Benchmark MAP per pipeline variant** (notebook 02_preprocessing.ipynb):
198
+
199
+ ![Preprocessing Stage Impact](notebooks/charts/02_preprocessing_impact.png)
200
+
201
+ Setiap stage contribution incremental dari BASELINE → FULL pipeline.
202
+
203
+ ### 4.4 Real-World Insight
204
+
205
+ 1. **"gedong meneng" → "gedong neng"**: Sastrawi treats "meneng" punya
206
+ prefix "me-" yang invalid (false positive stemming)
207
+ 2. **"kamar mandi dalam" → "mandi"**: "kamar" jadi custom domain stopword,
208
+ sehingga sebagian besar signal hilang
209
+ 3. **Jargon substitution sukses**: "kmd" → "kamar mandi dalam" → "mandi"
210
+ setelah stemming (signal preserved walau partial)
211
+
212
+ ---
213
+
214
+ ## 5. Model Comparison
215
+
216
+ ### 5.1 Implementasi
217
+
218
+ | Model | Library | Hyperparameter |
219
+ |-------|---------|----------------|
220
+ | TF-IDF | scikit-learn TfidfVectorizer | ngram_range=(1,2), min_df=2, max_features=10k, sublinear_tf=True |
221
+ | BM25 | rank_bm25 BM25Okapi | k1=1.5, b=0.75 (literature standard) |
222
+ | Neural "IndoBERT" | fastembed (ONNX) + FAISS | paraphrase-multilingual-MiniLM-L12-v2, IndexFlatIP, normalize_embeddings. Catatan kejujuran: label kode `indobert` historis; model sebenarnya MiniLM multilingual |
223
+ | Hybrid | BM25 top-50 → MiniLM rerank | min-max normalize + alpha=0.9 (grid search pool-restricted, lihat §8.3; default lama 0.3 terbukti merugikan) |
224
+ | **Smart (live)** | parser rule-based + gazetteer + BM25 | fusion 0.2 teks + 0.4 geo (1/(1+km), anchor terverifikasi OSM/Wikipedia) + 0.4 fasilitas (grid search CS@5, lihat §8.3b); hard filter gender/harga dengan relaxation bertahap; geo-augment kandidat radius 3 km; filter UI = constraint eksplisit yang tidak pernah di-relax |
225
+
226
+ ### 5.2 Index Build Performance
227
+
228
+ | Index | Build Time | Disk Size |
229
+ |-------|-----------|-----------|
230
+ | TF-IDF | 0.2s | 125 KB |
231
+ | BM25 | 0.1s | 53 KB |
232
+ | IndoBERT (227 docs encode) | ~10s | ~0.7 MB (embeddings + FAISS + meta) |
233
+ | Hybrid | runtime composition (no build) | — |
234
+
235
+ **Total**: <1 MB indexes committed ke repo. Corpus real yang lebih kecil
236
+ (227 docs) membuat footprint memory ringan — cocok untuk Render free tier
237
+ 512MB RAM (encoding via `fastembed` ONNX ~150MB, bukan torch ~500MB).
238
+
239
+ ---
240
+
241
+ ## 6. Evaluation Protocol
242
+
243
+ ### 6.1 Query Set (v1.1 — 30 queries)
244
+
245
+ **30 queries** (eval/queries.json): q01-q15 dari v1.0 (didesain untuk data
246
+ real), q16-q30 ditambah untuk menaikkan power statistik (n=15 underpowered
247
+ untuk Wilcoxon) + memperluas coverage: kampus Darmajaya/UBL, kecamatan
248
+ Tanjung Senang/Gedong Meneng/Labuhan Ratu, fasilitas kipas angin/tv/kasur,
249
+ landmark Mall Boemi Kedaton. Setiap query diverifikasi *answerable* oleh
250
+ corpus real (avg 14.4 relevant/query; 2 draft q16-q30 yang mati diganti
251
+ sebelum dipakai). Berbasis vocabulary yang benar-benar muncul di deskripsi
252
+ pemilik. Sampel pola v1.0:
253
+
254
+ - **Per-universitas**: q01 (UNILA+putra+wifi+ac), q04 (ITERA+sukarame),
255
+ q05 (Teknokrat+kedaton), q06 (Polinela+rajabasa), q11 (UNILA+putra+rajabasa),
256
+ q12 (UNILA+putri), q13 (UIN raden intan)
257
+ - **Per-tipe + fasilitas**: q02 (putri+kedaton), q03 (campur+kmd+parkir),
258
+ q07 (eksklusif+ac+wifi+shower), q14 (putri+way halim)
259
+ - **Kualitas / use-case**: q08 (putri+aman+cctv), q09 (dapur+parkir mobil),
260
+ q10 (transmart+way halim), q15 (campur+nyaman+strategis)
261
+
262
+ **Catatan desain penting**: BM25/IR meng-index **teks deskripsi**;
263
+ filter kecamatan/harga/tipe dilayani **metadata** terpisah di `/search`
264
+ endpoint. Karena itu query eval fokus pada konsep yang muncul di teks
265
+ (universitas, fasilitas, kualitas). Dari versi awal (18 query untuk corpus
266
+ synthetic), **8 query di-drop karena mati di data real** — yaitu yang
267
+ menguji karakteristik khas synthetic: `pasutri` (corpus real 0 pasutri),
268
+ "jam malam aturan ketat", "kerja dari rumah", "warung makan", "tahunan
269
+ bayar di muka", street-name spesifik. Deskripsi pemilik asli yang terse
270
+ tidak memuat frasa-frasa template tersebut.
271
+
272
+ ### 6.2 Ground Truth — AI-Assisted 3-Annotator Simulation
273
+
274
+ **Pool**: BM25 top-30 per query = **900 (query, doc) pairs** (30 × 30).
275
+
276
+ **Annotator simulation** (rule-based, `scripts/generate_ground_truth.py`):
277
+ - A (strict): tipe match +1.5 / mismatch -2.0; kecamatan +1.5;
278
+ fasilitas +0.5; harga "murah" +1.0
279
+ - B (lenient): A + 0.5 score boost
280
+ - C (noisy): A + uniform(-0.5, 0.5) jitter (seed 42)
281
+
282
+ Score → relevance: ≥2.5 → 2, ≥1.0 → 1, else 0. Consensus majority vote
283
+ → `eval/ground_truth.csv`.
284
+
285
+ **Disclosure**: AI-assisted bootstrap GT, BUKAN human annotation — di-pool
286
+ dari BM25 (lihat implikasi *pooling bias* di §7/§8). Untuk production-grade,
287
+ 3-human annotator pass tetap recommended (`docs/ground_truth_protocol.md`).
288
+
289
+ ### 6.3 Inter-Annotator Agreement
290
+
291
+ | Pair | Cohen's Kappa | Interpretation |
292
+ |------|---------------|----------------|
293
+ | A vs B | 0.700 | substantial |
294
+ | A vs C | 0.871 | almost perfect |
295
+ | B vs C | 0.578 | moderate |
296
+
297
+ Kappa pada data real sedikit lebih rendah dari iterasi synthetic karena
298
+ deskripsi terse memberi sinyal lebih ambigu untuk heuristik. Pasangan B-C
299
+ (lenient vs noisy) turun ke *moderate* (0.572); A-B dan A-C tetap
300
+ substantial–almost perfect.
301
+
302
+ ### 6.4 Distribusi Consensus
303
+
304
+ | Relevance | Count | % |
305
+ |-----------|-------|---|
306
+ | 0 (not relevant) | 467 | 51.9% |
307
+ | 1 (somewhat) | 361 | 40.1% |
308
+ | 2 (highly) | 72 | 8.0% |
309
+
310
+ Proporsi rel=2 lebih kecil dari iterasi synthetic (template membuat banyak
311
+ "highly relevant" semu); data real lebih realistis — sedikit yang benar-benar
312
+ sangat relevan per query.
313
+
314
+ ---
315
+
316
+ ## 7. Results
317
+
318
+ Tiga lensa evaluasi dilaporkan karena GT di-pool secara lexical (BM25):
319
+ **(A) Standard top-K** — IR query atas seluruh corpus 227; **(B)
320
+ Pool-restricted** — model hanya me-ranking ulang dokumen yang sudah
321
+ ter-judge (fair untuk model non-lexical); **(C) Constraint
322
+ Satisfaction@5** — proporsi top-5 yang memenuhi semua kebutuhan eksplisit
323
+ user, tanpa qrels (bebas pooling bias). Eval Smart memakai fungsi
324
+ `smart_rank()` yang persis sama dengan serving (`scripts/eval_smart.py`).
325
+
326
+ ### 7.1a Standard Top-K (30 queries × 5 models)
327
+
328
+ | Model | P@5 | P@10 | MAP | NDCG@10 | MRR |
329
+ |-------|-----|------|-----|---------|-----|
330
+ | **Smart (live)** | **0.580** | 0.480 | **0.301** | 0.548 | **0.868** |
331
+ | BM25 | 0.553 | **0.530** | 0.270 | **0.553** | 0.802 |
332
+ | Hybrid (α=0.9) | 0.553 | 0.527 | 0.267 | 0.551 | 0.804 |
333
+ | TF-IDF | 0.547 | 0.510 | 0.239 | 0.523 | 0.727 |
334
+ | Neural MiniLM | 0.160 | 0.153 | 0.057 | 0.155 | 0.280 |
335
+
336
+ **Ranking by MAP**: Smart > BM25 > Hybrid > TF-IDF > Neural. Catatan
337
+ pembacaan jujur: Smart unggul di MAP, P@5, dan terutama MRR (0.868:
338
+ hasil pertama hampir selalu relevan), tapi P@10-nya DI BAWAH BM25.
339
+ Penyebabnya struktural: hard filter memangkas kandidat yang melanggar
340
+ constraint dan dokumen hasil geo-augment yang belum ter-annotate dihitung
341
+ rel=0 (pooling bias juga menekan Smart). Selisih MAP Smart vs BM25 belum
342
+ signifikan (p=0.69, lihat §7.2); klaim utama Smart ada di lensa CS@5
343
+ (§7.4) yang bebas qrels.
344
+
345
+ ### 7.1b Pool-Restricted (fair across models)
346
+
347
+ | Model | P@5 | P@10 | MAP | NDCG@10 | MRR |
348
+ |-------|-----|------|-----|---------|-----|
349
+ | **Smart (live)** | **0.927** | **0.800** | **0.865** | **0.857** | **1.000** |
350
+ | Hybrid (α=0.9) | 0.553 | 0.530 | 0.591 | 0.553 | 0.804 |
351
+ | BM25 | 0.553 | 0.520 | 0.583 | 0.546 | 0.802 |
352
+ | TF-IDF | 0.560 | 0.530 | 0.576 | 0.541 | 0.751 |
353
+ | Neural MiniLM | 0.487 | 0.493 | 0.557 | 0.487 | 0.708 |
354
+
355
+ **Insight kunci #1 (pooling bias)**: Neural melompat dari MAP 0.057
356
+ (standard) → **0.557** (pool-restricted). Gap besar ini adalah artefak
357
+ *pooling bias*: GT di-pool dari BM25 top-30, jadi dokumen yang dipilih
358
+ model semantic tapi tidak masuk pool otomatis dihitung tidak-relevan
359
+ (rel=0) di standard eval — bukan karena modelnya buruk, melainkan karena
360
+ belum ter-judge. Pada perbandingan fair, neural kompetitif (~0.03 MAP di
361
+ bawah BM25).
362
+
363
+ **Insight kunci #2 (smart di pool)**: MAP Smart 0.865 (MRR 1.000) di
364
+ pool-restricted sebagian besar datang dari **alignment constraint dengan
365
+ judgment**: annotator (simulasi) menghukum gender/harga mismatch, dan hard
366
+ filter Smart membuang dokumen yang sama sehingga dokumen relevan naik ke
367
+ atas. Ini by-design (sistem mengoptimalkan kebutuhan user yang sama), tapi
368
+ karena heuristik annotator dan filter Smart melihat sinyal yang mirip,
369
+ angka ini dibaca sebagai *upper bound* yang optimistic, bukan bukti
370
+ mutlak superioritas; karena itu lensa CS@5 yang bebas qrels dilaporkan
371
+ juga (§7.4).
372
+
373
+ ![MAP per Model](notebooks/charts/04_map_bars.png)
374
+
375
+ ![All Metrics Comparison](notebooks/charts/04_all_metrics.png)
376
+
377
+ ### 7.2 Pairwise Statistical Significance (Wilcoxon Signed-Rank + Holm, n=30, α=0.05)
378
+
379
+ Pada AP standard top-K, 5 model = 10 pasangan diuji sekaligus. Menguji
380
+ banyak hipotesis pada α=0.05 tanpa koreksi menggelembungkan peluang false
381
+ positive (10 uji: ~40%), jadi signifikansi final memakai koreksi
382
+ **Holm-Bonferroni** (kontrol family-wise error rate); p-value mentah tetap
383
+ dilaporkan untuk transparansi. (Pada iterasi n=15 sebelumnya, BM25 vs
384
+ Hybrid lolos raw tapi gugur setelah Holm; pelajaran kenapa koreksi wajib.)
385
+
386
+ | Pair | Statistic | p-value | p-Holm | Sig (raw) | Sig (Holm) |
387
+ |------|-----------|---------|--------|-----------|------------|
388
+ | BM25 vs Hybrid | 108.0 | 0.5481 | 1.0000 | no | no |
389
+ | BM25 vs Neural | 31.0 | <0.0001 | <0.001 | yes | **YES** |
390
+ | BM25 vs Smart | 199.0 | 0.6891 | 1.0000 | no | no |
391
+ | BM25 vs TF-IDF | 176.0 | 0.2534 | 1.0000 | no | no |
392
+ | Hybrid vs Neural | 29.0 | <0.0001 | <0.001 | yes | **YES** |
393
+ | Hybrid vs Smart | 208.0 | 0.6263 | 1.0000 | no | no |
394
+ | Hybrid vs TF-IDF | 190.0 | 0.3931 | 1.0000 | no | no |
395
+ | Neural vs Smart | 17.0 | <0.0001 | 0.0001 | yes | **YES** |
396
+ | Neural vs TF-IDF | 24.0 | <0.0001 | <0.001 | yes | **YES** |
397
+ | Smart vs TF-IDF | 159.0 | 0.2059 | 1.0000 | no | no |
398
+
399
+ **4/10 signifikan, dan keempatnya BERTAHAN setelah Holm** (p sangat kecil,
400
+ semuanya pasangan vs Neural pada standard eval) — konsisten dengan cerita
401
+ pooling bias, bukan ranking model. Empat model non-neural (Smart, BM25,
402
+ Hybrid, TF-IDF) tidak saling berbeda signifikan pada n=30; hasil
403
+ non-signifikan dibaca *inconclusive*, bukan bukti dua model setara.
404
+ Detail: `eval/significance_map.csv`.
405
+
406
+ ### 7.3 Per-Query Analysis Insights
407
+
408
+ - **BM25 dominan di queries lexical-heavy**: q01 (unila+putra+wifi+ac),
409
+ q03 (campur+kmd+parkir), q13 (uin raden intan) — exact-match nama
410
+ universitas + fasilitas yang muncul di deskripsi.
411
+ - **TF-IDF kompetitif**: pada corpus kecil 227 dengan term yang jarang,
412
+ TF-IDF sublinear_tf cukup dekat dengan BM25 (selisih tidak signifikan).
413
+ - **Query landmark lemah**: q10 (transmart way halim) hanya ~2 relevant —
414
+ sedikit deskripsi pemilik yang menyebut landmark eksplisit.
415
+ - **Neural MiniLM**: kuat di pool-restricted (semantic mengelompokkan kos
416
+ sejenis) tapi tertekan di standard karena GT lexical-pooled.
417
+
418
+ ### 7.4 Constraint Satisfaction @5 (lensa bebas qrels)
419
+
420
+ CS@5 = proporsi top-5 yang memenuhi **semua** constraint query: gender
421
+ benar + harga ≤ budget + fasilitas ada + jarak ≤ 3 km dari anchor kampus/
422
+ landmark (30 query di `eval/queries_constraints.json`, koordinat anchor
423
+ terverifikasi OSM/Wikipedia). Metric ini mengukur langsung kebutuhan user
424
+ dan tidak bergantung ground truth (bebas pooling bias).
425
+
426
+ | Model | mean CS@5 (n=30) |
427
+ |-------|------------------|
428
+ | **Smart (live)** | **0.863** |
429
+ | BM25 | 0.447 |
430
+
431
+ Wilcoxon signed-rank smart vs BM25: **p<0.001 (signifikan)** — satu uji
432
+ terencana, tidak butuh koreksi keluarga. Interpretasi praktis: dari 5
433
+ hasil teratas, rata-rata 4.3 memenuhi semua kebutuhan user di Smart vs
434
+ 2.2 di BM25. Inilah justifikasi kuantitatif Smart sebagai model live.
435
+ Detail per-query: `eval/results_constraints.csv`.
436
+
437
+ ---
438
+
439
+ ## 8. Discussion
440
+
441
+ ### 8.1 Mengapa BM25 Dominan?
442
+
443
+ 1. **Lexical-heavy query**: kebanyakan query mengandung exact terminology
444
+ (nama kecamatan, tipe kos, nama universitas, fasilitas). BM25 term
445
+ saturation handling lebih appropriate dari TF-IDF (sublinear_tf).
446
+ 2. **Short documents**: deskripsi pemilik sangat pendek (median ~23 kata,
447
+ ~20 token setelah stemming) — BM25 length normalization (b=0.75) efektif
448
+ untuk dokumen pendek.
449
+ 3. **Domain-specific corpus**: minim ambiguity semantic — tidak ada
450
+ keuntungan dari embedding-based representation.
451
+ 4. **GT lexical-pooled**: ground truth di-pool dari BM25, sehingga metric
452
+ standar memang menguntungkan model lexical (lihat §8.2 pooling bias).
453
+
454
+ ### 8.2 Mengapa IndoBERT "Underperform" — Pooling Bias, Bukan Model
455
+
456
+ Temuan paling penting dari iterasi real-data: model neural **tidak**
457
+ benar-benar buruk. Gap MAP 0.057 (standard) vs 0.557 (pool-restricted)
458
+ hampir seluruhnya disebabkan **pooling bias**:
459
+
460
+ 1. **Ground truth di-pool dari BM25**: hanya BM25 top-30 yang di-judge.
461
+ Dokumen yang IndoBERT ranking tinggi tapi tidak masuk pool BM25 otomatis
462
+ dihitung rel=0 — *unjudged ≠ irrelevant*. Ini bias klasik TREC-style
463
+ pooling ketika pool dibangun dari satu jenis model.
464
+ 2. **Bukti pool-restricted**: saat semua model hanya me-ranking dokumen
465
+ yang sama-sama ter-judge, neural kompetitif (MAP 0.557, hanya ~0.03
466
+ di bawah BM25).
467
+ 3. **Faktor sekunder** (tetap relevan): deskripsi pemilik sangat pendek
468
+ (median 23 kata) memberi sedikit konteks untuk embedding; MiniLM
469
+ multilingual kurang terspesialisasi untuk nuansa Indonesia.
470
+
471
+ **Implikasi metodologis**: untuk corpus yang di-pool lexical, melaporkan
472
+ HANYA standard top-K akan menyesatkan tentang kemampuan model semantic.
473
+ Karena itu kedua angka dilaporkan (§7.1a vs §7.1b).
474
+
475
+ ### 8.3 Hybrid: Grid Search Alpha (0.3 → 0.9)
476
+
477
+ Iterasi awal memakai `alpha=0.3` (70% weight MiniLM) dan hybrid terseret
478
+ jatuh di standard eval (MAP 0.188 di n=30) karena skor neural tertekan
479
+ pooling bias. Grid search alpha ∈ [0, 1] step 0.1 dijalankan di dua lensa
480
+ (`eval/hybrid_alpha_grid.csv` + `hybrid_alpha_grid_pool.csv`):
481
+
482
+ - **Standard**: monoton naik sampai alpha=1.0 (murni BM25). Ini bukan
483
+ bukti MiniLM tidak berguna, melainkan artefak GT lexical-pooled: bobot
484
+ neural berapa pun "merusak" metric yang di-pool dari BM25.
485
+ - **Pool-restricted (fair, dasar keputusan)**: kurva nyaris datar di
486
+ 0.6-0.9 dengan puncak **alpha=0.9** (MAP 0.5909 vs BM25 murni 0.5836,
487
+ alpha lama 0.3 = 0.5836). Selisihnya kecil dan tidak signifikan
488
+ (inconclusive), tapi alpha tinggi juga menghapus kerugian besar di
489
+ standard (MAP 0.267 vs 0.188).
490
+
491
+ Default produksi kini `alpha=0.9`: hybrid = BM25 + tie-breaking semantic
492
+ tipis. Interpretasi jujur: pada corpus 227 dengan deskripsi terse, sinyal
493
+ MiniLM hanya membantu di margin; nilai utamanya teredam pooling bias
494
+ (lihat pool-restricted: hybrid MAP 0.591, tertinggi di antara model
495
+ non-smart).
496
+
497
+ Mitigasi eval yang tersisa (future work):
498
+ - Pool GT dari union multi-model (BM25 ∪ MiniLM ∪ TF-IDF ∪ Smart) untuk
499
+ hilangkan pooling bias — perbaikan eval paling berdampak
500
+ - Human annotation pass untuk GT yang truly model-agnostic
501
+
502
+ ### 8.3b Mengapa Smart Dipilih sebagai Model Live?
503
+
504
+ Smart menjawab kelemahan praktis semua model murni-teks: query kos riil
505
+ ("kos putri deket unila max 800rb ada wifi") membawa **constraint
506
+ terstruktur** yang tidak terbaca BM25/TF-IDF sebagai constraint, hanya
507
+ sebagai token. Smart memecah query: parser menarik gender/harga/fasilitas/
508
+ anchor; BM25 menangani sisa teks (setelah preprocessing yang sama dengan
509
+ corpus); skor akhir = fusi teks + kedekatan geografis + kecocokan
510
+ fasilitas; hard filter dengan relaxation bertahap menjamin hasil tidak
511
+ kosong dan pelonggaran dilaporkan jujur ke user (chip "filter
512
+ dilonggarkan").
513
+
514
+ Tiga detail desain penting:
515
+ 1. **Geo-augment kandidat**: saat anchor terdeteksi, kandidat BM25
516
+ di-union dengan semua listing radius 3 km. Tanpa ini, listing dekat
517
+ ITERA yang deskripsinya tidak menulis "itera" mustahil muncul (skor geo
518
+ hanya bisa menata ulang kandidat yang ada, tidak bisa menambah).
519
+ 2. **Filter UI = constraint eksplisit**: tidak pernah di-relax. Relaxation
520
+ hanya untuk constraint hasil inferensi teks.
521
+ 3. **Bobot fusion dari grid search, bukan tebakan**: simplex step 0.1
522
+ (55 kombinasi, `eval/smart_weights_grid.csv`) memilih **0.2 teks /
523
+ 0.4 geo / 0.4 atribut**, menang atas default awal 0.4/0.4/0.2 di kedua
524
+ lensa (CS@5 0.863 vs 0.810, Wilcoxon p=0.023; MAP standard 0.301 vs
525
+ 0.287). Kombinasi ber-CS tertinggi (mis. teks 0.1 / atribut 0.7)
526
+ ditolak sadar: CS@5 memang menghitung atribut, jadi bobot atribut
527
+ ekstrem = overfit metric; teks tetap dibutuhkan untuk query kualitas
528
+ ("nyaman", "bersih") di luar pola constraint.
529
+
530
+ Bukti kuantitatif: CS@5 0.86 vs 0.45 (p<0.001, §7.4); standard MAP juga
531
+ tertinggi (0.301) meski selisihnya terhadap BM25 belum signifikan (n=30).
532
+ Biaya: nol model neural di runtime — aman untuk free tier 512MB.
533
+
534
+ ### 8.4 Karakteristik Data Real vs Coverage
535
+
536
+ - **Deskripsi terse**: median 23 kata (vs synthetic 110+). Menurunkan
537
+ absolute IR metric tapi mencerminkan realita — pemilik kos menulis singkat
538
+ ("dekat UNILA, jalan kaki 5 menit"). Sistem production harus handle ini.
539
+ - **Distribusi kecamatan tidak merata**: Sukarame/Kedaton/Rajabasa dominan
540
+ (dekat UNILA/UIN/ITERA/Polinela) karena memang di situ density kos Mamikos
541
+ tertinggi. Mencerminkan pasar nyata, bukan artefak sampling.
542
+ - **Koordinat real**: 226/227 listing punya lat/lng asli Mamikos →
543
+ langsung dipakai untuk pin Google Maps di web app. 1 listing dengan
544
+ koordinat korup (geocoding fallback ke Jakarta) di-null secara eksplisit.
545
+
546
+ ### 8.5 Limitations
547
+
548
+ 1. **Corpus size 227**: dibatasi ketersediaan listing Mamikos Bandar Lampung
549
+ yang ter-index Google (~300-400 total) + success rate ekstraksi ~76%.
550
+ 2. **Pooling bias**: GT di-pool dari BM25 → bias terhadap model lexical
551
+ (dimitigasi sebagian via pool-restricted eval, belum via human/multi-model pool).
552
+ 3. **AI-assisted GT**: heuristic scoring, bukan true human judgment.
553
+ 4. **Sample size n=30 query** (dinaikkan dari 15): power lebih baik tapi
554
+ tetap moderat; semua hasil non-signifikan dibaca inconclusive, bukan
555
+ "setara". Hyperparameter (alpha, bobot fusion) dipilih pada data eval
556
+ yang sama dengan yang dilaporkan (tanpa held-out set) — di-disclose
557
+ sebagai mild selection bias.
558
+ 5. **Single embedding model**: hanya MiniLM multilingual (fastembed ONNX);
559
+ `indobert-base-p2` belum diuji (RAM constraint Render free tier;
560
+ deploy HF Spaces 16GB membuka jalan untuk menguji ini).
561
+ 6. **Sinyal annotator vs filter Smart tumpang tindih**: heuristik
562
+ annotator simulasi menghukum mismatch gender/harga, yang juga
563
+ merupakan hard filter Smart — angka pool-restricted Smart (MAP 0.865)
564
+ karenanya optimistic; lensa CS@5 yang bebas qrels dilaporkan sebagai
565
+ pengimbang.
566
+
567
+ ---
568
+
569
+ ## 9. System Architecture
570
+
571
+ ### 9.1 Stack
572
+
573
+ | Layer | Tech |
574
+ |-------|------|
575
+ | Frontend | React 18 + Vite 5 + TypeScript — 4 tab: Pencarian, Evaluasi Model, Preprocessing (visualisasi 9-stage), Statistik |
576
+ | Backend | Python 3.11 + FastAPI + Uvicorn |
577
+ | Database | PostgreSQL 16 (asyncpg + SQLAlchemy 2.0 + Alembic) |
578
+ | IR Libraries | Sastrawi, rank_bm25, fastembed (ONNX MiniLM), faiss-cpu, scikit-learn |
579
+ | Deploy | Docker single-container: HF Spaces (16GB, neural ON) + Render.com backup (512MB, smart-only) |
580
+
581
+ ### 9.2 API Endpoints
582
+
583
+ | Method | Path | Description |
584
+ |--------|------|-------------|
585
+ | GET | `/health` | Liveness probe |
586
+ | GET | `/api/search` | IR search dengan model & filter (`q`, `model`, `top_k`, `harga_min`, `harga_max`, `tipe`, `kecamatan`); filter berlaku untuk SEMUA model termasuk smart |
587
+ | GET | `/api/listings/{id}` | Listing detail |
588
+ | GET | `/api/eval/summary` | Aggregate eval (standard + pool-restricted + CS@5 + Wilcoxon/Holm), file-based dari CSV hasil eval |
589
+ | GET | `/api/eval/query/{query_id}` | Per-query breakdown |
590
+ | GET | `/api/preprocess` | Trace pipeline 9-stage per langkah (tab Preprocessing) |
591
+ | GET | `/api/stats` | Statistik corpus live (distribusi kecamatan/tipe, harga, vocab) |
592
+ | GET | `/api/status` | Diagnostics lengkap (index loaded, RAM, DB) |
593
+ | GET | `/api/docs` | Swagger UI |
594
+ | GET | `/` (catch-all) | React SPA index.html |
595
+
596
+ ### 9.3 Deployment
597
+
598
+ Satu Dockerfile untuk dua platform (neural di-gate env `ENABLE_NEURAL`):
599
+
600
+ - **HF Spaces** (primary demo) → `https://dymazeh-kozynear.hf.space` —
601
+ CPU basic free 16GB RAM, `ENABLE_NEURAL=true`: kelima model live
602
+ termasuk Neural MiniLM + Hybrid. Model ONNX di-pre-download saat build.
603
+ - **Render.com** (backup) → `https://kozynear.onrender.com` — free 512MB,
604
+ `ENABLE_NEURAL=false`: smart/BM25/TF-IDF. `kozynear-db` PostgreSQL
605
+ managed dipakai kedua deploy.
606
+ - Cold start: `seed_db --truncate --skip-if-synced` (skip reseed kalau DB
607
+ sudah sinkron); GitHub Actions `keepalive.yml` ping /health tiap 12
608
+ menit supaya tidak spin-down saat demo.
609
+
610
+ ---
611
+
612
+ ## 10. Conclusion & Future Work
613
+
614
+ ### 10.1 Conclusion
615
+
616
+ KozyNear berhasil membangun end-to-end IR system khusus kos-kosan
617
+ **seluruh Bandar Lampung** dengan corpus **227 listing REAL Mamikos**
618
+ (deskripsi pemilik + koordinat asli) dan 5 model retrieval. Finding utama:
619
+
620
+ 1. **Smart (query understanding + geo + BM25 fusion) sebagai model live**:
621
+ MAP standard tertinggi (0.301, MRR 0.868) dan CS@5 0.86 vs BM25 0.45
622
+ (Wilcoxon p<0.001, n=30). Memenuhi kebutuhan terstruktur user (gender/
623
+ budget/fasilitas/jarak kampus) yang tidak terbaca model murni-teks,
624
+ tanpa model neural di runtime. Bobot fusion 0.2/0.4/0.4 hasil grid
625
+ search dua-lensa.
626
+ 2. **BM25 best lexical baseline** (MAP 0.270, P@5 0.553) — setelah koreksi
627
+ Holm-Bonferroni, unggul signifikan hanya vs Neural (p-Holm<0.001);
628
+ selisih antar model non-neural (vs TF-IDF/Hybrid/Smart) inconclusive
629
+ pada n=30.
630
+ 3. **Neural MiniLM bukan buruk — pooling bias**: MAP melompat 0.057 →
631
+ 0.557 saat pool-restricted. Pelajaran metodologis tentang pelaporan
632
+ multi-lensa (standard + pool-restricted + CS@5).
633
+ 4. **Hybrid butuh alpha tinggi** (grid search → 0.9): pada GT
634
+ lexical-pooled, bobot neural besar hanya merugikan; dengan alpha 0.9
635
+ hybrid jadi model non-smart terbaik di pool-restricted (MAP 0.591).
636
+ 5. **Data real menuntut kejujuran**: deskripsi terse menurunkan absolute
637
+ metric, tapi sistem siap production (koordinat Maps terverifikasi,
638
+ traceable url_source, model identity disclosed).
639
+
640
+ ### 10.2 Rubric Coverage
641
+
642
+ | Aspect | Weight | Coverage |
643
+ |--------|--------|----------|
644
+ | Theme | 15% | Kos search Bandar Lampung multi-universitas + integrasi Google Maps, under-represented di IR Indonesia |
645
+ | Dataset | 15% | **227 listing REAL Mamikos** (scrape detail-page, 18 kecamatan, koordinat asli), methodology + pipeline disclosed |
646
+ | Preprocessing | 15% | 9-stage pipeline + ~110 jargon entries (>100 req) + benchmark + **visualisasi live per-stage di tab Preprocessing** |
647
+ | Model | 20% | 5 model unified (TF-IDF/BM25/Neural MiniLM/Hybrid/**Smart live**) + honest comparison 3 lensa + hyperparameter via grid search (alpha 0.9, fusion 0.2/0.4/0.4) |
648
+ | Evaluation | 10% | n=30 query, 900 annotations; P@K + MAP + NDCG + MRR + Cohen's Kappa + Wilcoxon **dengan koreksi Holm-Bonferroni** (10 pasangan, 4 signifikan) + Constraint-Satisfaction@5 + dashboard eval live di UI |
649
+ | System | 25% | FastAPI + React 4-tab + PG + Docker; deploy ganda HF Spaces (neural ON) + Render backup + keepalive |
650
+
651
+ ### 10.3 Future Work
652
+
653
+ 1. **Perluas corpus real**: scrape kompetitor (Cove, Rukita, sewakost.com)
654
+ atau Google Places untuk listing di luar Mamikos → tembus >300 real.
655
+ 2. **Hilangkan pooling bias**: pool GT dari union multi-model (BM25 ∪
656
+ MiniLM ∪ TF-IDF ∪ Smart) atau human annotation — perbaikan eval paling
657
+ berdampak.
658
+ 3. **Indonesian-specialized embedding**: comparison `indobenchmark/indobert-base-p2`
659
+ (kini feasible di HF Spaces 16GB).
660
+ 4. **Human annotation pass**: replace AI-assisted GT (protocol di docs).
661
+ 5. **Held-out query set**: hyperparameter (alpha, bobot fusion) saat ini
662
+ dipilih di data eval yang sama; pisahkan dev/test set di iterasi
663
+ berikutnya. (Selesai di iterasi ini: query 15→30, grid search alpha
664
+ dan bobot fusion.)
665
+
666
+ ---
667
+
668
+ ## 11. Repository Structure
669
+
670
+ ```
671
+ KozyNear/
672
+ ├── LAPORAN.md # ini, single-doc report
673
+ ├── README.md # quick start + project overview
674
+ ├── Dockerfile # multi-stage build (Node + Python)
675
+ ├── render.yaml # Render infra-as-code
676
+ ├── docker-compose.yml # local PG dev
677
+ ├── docs/
678
+ │ ├── architecture.md
679
+ │ ├── deploy_guide.md
680
+ │ └── ground_truth_protocol.md
681
+ ├── data/
682
+ │ ├── raw/mamikos_real_v2.jsonl # 227 listing REAL Mamikos (committed)
683
+ │ ├── raw/_discovered_slugs.txt # 312 URL discovery (committed)
684
+ │ ├── processed/corpus.json # post-preprocessing (committed)
685
+ │ └── indexes/ # TF-IDF + BM25 + IndoBERT (committed)
686
+ ├── eval/
687
+ │ ├── queries.json # 15 test queries (v1.0 real-data)
688
+ │ ├── ground_truth.csv # 900 consensus annotations
689
+ │ ├── kappa_report.md # inter-annotator agreement
690
+ │ ├── annotations_annotator_{A,B,C}.csv
691
+ │ ├── results.csv # per-model per-query (standard)
692
+ │ ├── results_pool_restricted.csv # pool-restricted (fair) eval
693
+ │ ├── results.withsynth.csv # arsip: eval era synthetic (perbandingan)
694
+ │ ├── _audit_dataset.py # data quality audit (reusable)
695
+ │ └── eval_summary.md # aggregate + Wilcoxon + analysis
696
+ ├── backend/
697
+ │ ├── app/ # FastAPI app
698
+ │ ├── alembic/ # DB migrations
699
+ │ ├── scripts/ # generate, preprocess, gen_GT, eval_report
700
+ │ └── tests/ # pytest suite
701
+ └── frontend/
702
+ └── src/
703
+ ├── api/client.ts # typed API client
704
+ ├── components/ # FilterPanel, ResultCard
705
+ └── App.tsx # main SPA
706
+ ```
707
+
708
+ ---
709
+
710
+ ## 12. Acknowledgments
711
+
712
+ Project ini didevelop dengan AI agent assistance (Claude) sebagai
713
+ implementation executor, dengan user sebagai project manager / reviewer.
714
+ Methodology disclosure jujur tentang scrape data real Mamikos, pooling
715
+ bias pada GT yang AI-assisted, dan keputusan drop synthetic demi keaslian
716
+ data — untuk maintain integrity research findings. Source code + data
717
+ public di GitHub untuk reproducibility.
718
+
719
+ ---
720
+
721
+ **Submitted**: 17 Juni 2026 (vClass) — *placeholder, submission setelah final review*
722
+ **License**: MIT (lihat `LICENSE`)
LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2026 TKI-KOS Team — Universitas Lampung
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
README.md CHANGED
@@ -1,11 +1,237 @@
1
  ---
2
- title: Kozynear
3
- emoji: 👀
4
- colorFrom: indigo
5
- colorTo: green
6
  sdk: docker
 
7
  pinned: false
8
  license: mit
 
9
  ---
10
 
11
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: KozyNear
3
+ emoji: 🏠
4
+ colorFrom: blue
5
+ colorTo: indigo
6
  sdk: docker
7
+ app_port: 10000
8
  pinned: false
9
  license: mit
10
+ short_description: Indonesian Kos-Kosan Search Engine Bandar Lampung
11
  ---
12
 
13
+ # KozyNear &mdash; Indonesian Kos-Kosan Search Engine
14
+
15
+ > Final project Mata Kuliah **Temu Kembali Informasi** (COM620321, 3 SKS) &mdash; Universitas Lampung (UNILA)
16
+ > Search engine kos-kosan **seluruh Bandar Lampung** &mdash; 20 kecamatan, 9 universitas (UNILA, ITERA, Darmajaya, UBL, UIN, Teknokrat, Polinela, Malahayati, Saburai)
17
+
18
+ 🌐 **Live**: deploy via [HuggingFace Spaces](https://huggingface.co/spaces) (lihat [docs/deploy_huggingface.md](docs/deploy_huggingface.md))
19
+ 📖 **Laporan**: [LAPORAN.md](LAPORAN.md)
20
+
21
+ ![Status](https://img.shields.io/badge/status-deployed-green)
22
+ ![Deadline](https://img.shields.io/badge/deadline-17%20Jun%202026-red)
23
+ ![License](https://img.shields.io/badge/license-MIT-blue)
24
+ ![Python](https://img.shields.io/badge/python-3.11-blue)
25
+ ![React](https://img.shields.io/badge/react-18-61dafb)
26
+
27
+ ## Overview
28
+
29
+ KozyNear adalah Information Retrieval system untuk listing kos-kosan di **seluruh Bandar Lampung** &mdash; berguna untuk mahasiswa dari **9 universitas** (UNILA, ITERA, Darmajaya, UBL, UIN Raden Intan, Teknokrat, Polinela, Malahayati, Saburai). User cari kos dengan natural language query bahasa Indonesia, dengan **5 model retrieval**:
30
+
31
+ 1. **Lexical baseline** &mdash; TF-IDF + cosine similarity (scikit-learn)
32
+ 2. **Probabilistic lexical** &mdash; BM25 (`rank_bm25`)
33
+ 3. **Neural / semantic** &mdash; MiniLM multilingual sentence embeddings (via fastembed ONNX; label kode `indobert` historis) + FAISS
34
+ 4. **Hybrid** &mdash; BM25 top-50 candidate &rarr; MiniLM rerank top-10
35
+ 5. **Smart (default live)** &mdash; query understanding (parser gender/harga/fasilitas/anchor) + geo ranking (gazetteer kampus terverifikasi + haversine) + BM25 fusion; hard filter dengan relaxation jujur
36
+
37
+ UI punya 4 tab demo: **Pencarian**, **Evaluasi Model** (metrik live + signifikansi), **Preprocessing** (visualisasi 9-stage per langkah), **Statistik** (distribusi corpus).
38
+
39
+ ## Tech Stack
40
+
41
+ | Layer | Tech |
42
+ |-------|------|
43
+ | Frontend | React 18 + Vite 5 + TypeScript |
44
+ | Backend | Python 3.11 + FastAPI + Uvicorn |
45
+ | Database | PostgreSQL 16 (asyncpg + SQLAlchemy 2.0) |
46
+ | Migrations | Alembic |
47
+ | IR Libraries | Sastrawi, rank_bm25, fastembed (ONNX MiniLM), faiss-cpu, scikit-learn |
48
+ | Eval | scipy (Wilcoxon + Holm-Bonferroni), custom Kappa + IR metrics + CS@K |
49
+ | Deploy | HF Spaces (16GB, neural ON) + Render.com backup; satu Dockerfile, gated `ENABLE_NEURAL` |
50
+
51
+ ## Quick Start (Local Dev)
52
+
53
+ ### Prerequisites
54
+
55
+ - Python 3.11+, Node 20+, Docker Desktop, Git
56
+
57
+ ### Setup
58
+
59
+ ```bash
60
+ # 1. Clone repo
61
+ git clone https://github.com/DYmazeh/TKI-KOS.git
62
+ cd TKI-KOS
63
+
64
+ # 2. Start local PostgreSQL via Docker
65
+ docker compose up -d
66
+
67
+ # 3. Backend setup
68
+ cd backend
69
+ python -m venv .venv
70
+ .venv\Scripts\activate # Windows
71
+ # source .venv/bin/activate # macOS/Linux
72
+ pip install -r requirements.txt
73
+ cp .env.example .env
74
+
75
+ # 4. Run migrations
76
+ alembic upgrade head
77
+
78
+ # 5. (Optional, kalau scrape data sudah ada) Seed DB + preprocess
79
+ python -m scripts.seed_db --input ../data/raw/mamikos.jsonl \
80
+ --preprocess --corpus-output ../data/processed/corpus.json
81
+
82
+ # 6. (Optional, kalau corpus.json sudah ada) Build indexes
83
+ python -m app.indexing.build --corpus ../data/processed/corpus.json \
84
+ --output-dir ../data/indexes
85
+
86
+ # 7. Run backend
87
+ uvicorn app.main:app --reload --port 8000
88
+ # Backend: http://localhost:8000 -- docs: http://localhost:8000/docs
89
+
90
+ # 8. Frontend (terminal baru)
91
+ cd frontend
92
+ npm install
93
+ cp .env.example .env
94
+ npm run dev
95
+ # Frontend: http://localhost:5173
96
+ ```
97
+
98
+ ## API Endpoints
99
+
100
+ | Method | Path | Description |
101
+ |--------|------|-------------|
102
+ | GET | `/` | Root info |
103
+ | GET | `/health` | Liveness probe (Render uses this) |
104
+ | GET | `/api/search` | IR search dengan filter (`q`, `model`, `top_k`, `harga_min`, `harga_max`, `tipe`, `kecamatan`); filter berlaku untuk semua model termasuk smart |
105
+ | GET | `/api/listings/{id}` | Detail kos by ID |
106
+ | GET | `/api/eval/summary` | Aggregate metrics (standard + pool-restricted + CS@5 + Wilcoxon/Holm), file-based |
107
+ | GET | `/api/eval/query/{query_id}` | Per-query metric breakdown |
108
+ | GET | `/api/preprocess` | Trace pipeline 9-stage per langkah |
109
+ | GET | `/api/stats` | Statistik corpus live |
110
+ | GET | `/api/status` | Diagnostics (index loaded, RAM, DB) |
111
+ | GET | `/api/docs` | Swagger UI (auto) |
112
+
113
+ ## Project Structure
114
+
115
+ ```
116
+ TKI-KOS/
117
+ ├── frontend/ # React Vite SPA (TypeScript)
118
+ │ ├── src/
119
+ │ │ ├── api/ # API client
120
+ │ │ ├── components/ # FilterPanel, ResultCard
121
+ │ │ └── App.tsx
122
+ │ └── package.json
123
+ ├── backend/ # Python FastAPI
124
+ │ ��── app/
125
+ │ │ ├── api/ # Routes: health, search, listings, eval
126
+ │ │ ├── core/ # config, db
127
+ │ │ ├── models/ # ORM + Pydantic schemas
128
+ │ │ ├── scraper/ # Mamikos + OLX + base + utils
129
+ │ │ ├── preprocessing/ # 9-stage pipeline + 108 jargon dict
130
+ │ │ ├── indexing/ # TF-IDF + BM25 + IndoBERT + Hybrid
131
+ │ │ ├── search/ # query handling
132
+ │ │ ├── evaluation/ # P@K, MAP, NDCG, Kappa, stat tests
133
+ │ │ └── main.py # FastAPI entry + lifespan
134
+ │ ├── alembic/ # Database migrations
135
+ │ ├── scripts/ # seed_db.py
136
+ │ ├── tests/ # pytest suite
137
+ │ └── requirements.txt
138
+ ├── data/
139
+ │ ├── raw/ # Scraped JSONL (gitignored)
140
+ │ ├── processed/ # corpus.json setelah preprocessing
141
+ │ └── indexes/ # TF-IDF/BM25/FAISS serialized
142
+ ├── notebooks/ # EDA, preprocessing experiment, eval
143
+ ├── eval/ # queries.json, ground_truth.csv, results.csv
144
+ ├── docs/ # architecture, deploy guide, GT protocol
145
+ ├── docker-compose.yml # Local PG
146
+ ├── render.yaml # Render infrastructure-as-code
147
+ └── README.md
148
+ ```
149
+
150
+ ## Roles & Team (5 anggota)
151
+
152
+ | Role | Bidang | Module |
153
+ |------|--------|--------|
154
+ | A &mdash; Lead / Scraper | Koordinasi tim + Mamikos scraper + data quality | `backend/app/scraper/` |
155
+ | B &mdash; Preprocessing | Custom jargon dictionary + pipeline tuning | `backend/app/preprocessing/` |
156
+ | C &mdash; IR Baseline | TF-IDF + BM25 hyperparameter tuning | `backend/app/indexing/` |
157
+ | D &mdash; IR Neural | IndoBERT + FAISS + hybrid tuning | `backend/app/indexing/` |
158
+ | E &mdash; Frontend + Eval | React UI + ground truth coord + metrics | `frontend/` + `backend/app/evaluation/` |
159
+
160
+ ## Timeline (4-week sprint)
161
+
162
+ | Week | Dates | Milestone |
163
+ |------|-------|-----------|
164
+ | 1 | 20&ndash;26 Mei 2026 | Repo setup, Mamikos scraper, &ge;1500 listings |
165
+ | 2 | 27 Mei&ndash;2 Jun | Preprocessing pipeline, 3 IR indexes, FastAPI scaffold |
166
+ | 3 | 3&ndash;9 Jun | React UI live, ground truth annotation (Kappa &ge;0.7), deploy Render |
167
+ | 4 | 10&ndash;16 Jun | Evaluation, statistical tests, report finalization |
168
+ | **Submit** | **17 Jun 23:59 WIB** | Upload ke vClass UNILA |
169
+
170
+ ## Documentation
171
+
172
+ | Topic | File |
173
+ |-------|------|
174
+ | Architecture (komponen, data flow, deploy strategy) | [docs/architecture.md](docs/architecture.md) |
175
+ | Deploy guide step-by-step (Render.com) | [docs/deploy_guide.md](docs/deploy_guide.md) |
176
+ | Ground truth annotation protocol (3-annotator + Kappa) | [docs/ground_truth_protocol.md](docs/ground_truth_protocol.md) |
177
+ | Scraper usage + TODOs | [backend/app/scraper/README.md](backend/app/scraper/README.md) |
178
+ | Preprocessing pipeline + jargon dict | [backend/app/preprocessing/README.md](backend/app/preprocessing/README.md) |
179
+ | IR indexes (TF-IDF/BM25/IndoBERT/Hybrid) | [backend/app/indexing/README.md](backend/app/indexing/README.md) |
180
+ | Evaluation metrics + Kappa + stat tests | [backend/app/evaluation/README.md](backend/app/evaluation/README.md) |
181
+ | Data schema + acquisition methodology | [data/README.md](data/README.md) |
182
+ | Notebooks convention | [notebooks/README.md](notebooks/README.md) |
183
+
184
+ ## Course Rubric Coverage
185
+
186
+ | Aspect | Weight | Status |
187
+ |--------|--------|--------|
188
+ | Theme | 15% | Kos-kosan **seluruh Bandar Lampung**, multi-university (UNILA, ITERA, Darmajaya, UBL, UIN, Teknokrat, Polinela, Malahayati, Saburai) |
189
+ | Dataset | 15% | **227 listing REAL Mamikos** (scrape detail-page, deskripsi pemilik + koordinat asli, 18 kecamatan), methodology + pipeline disclosed |
190
+ | Preprocessing | 15% | 9-stage pipeline + ~110 domain jargon entries (>=100 rubric req) + visualisasi live per-stage |
191
+ | Model | 20% | 5 model (TF-IDF/BM25/Neural MiniLM/Hybrid/**Smart live**) — Smart teratas standard MAP 0.301 + CS@5 0.86 vs BM25 0.45 (p<0.001, n=30); hyperparameter via grid search; pooling-bias dibahas jujur |
192
+ | Evaluation | 10% | 30 query, 900 annotations; P@K + MAP + NDCG + MRR + Cohen's Kappa (all pair >=0.58) + Wilcoxon + **Holm-Bonferroni** + Constraint-Satisfaction@5 |
193
+ | System | 25% | FastAPI + React 4-tab + Postgres single Docker; HF Spaces (neural ON) + Render backup |
194
+
195
+ ## Deploy URLs
196
+
197
+ - **Live App (primary)**: https://dymazeh-kozynear.hf.space (HF Spaces 16GB, `ENABLE_NEURAL=true`: kelima model live) &mdash; lihat [docs/deploy_huggingface.md](docs/deploy_huggingface.md)
198
+ - **Backup**: https://kozynear.onrender.com (Render free 512MB, smart/BM25/TF-IDF)
199
+ - **API Docs**: `<base-url>/api/docs` (Swagger UI)
200
+ - **Health**: `<base-url>/health`
201
+
202
+ ⚠️ Render free spin down 15 menit idle (cold start ~30-60s); HF Spaces sleep setelah 48 jam idle. Workflow [keepalive.yml](.github/workflows/keepalive.yml) ping keduanya tiap 12 menit.
203
+
204
+ ## Live Results Summary (n=30 queries × 5 models, corpus real 227)
205
+
206
+ **Standard top-K** (IR atas seluruh corpus):
207
+
208
+ | Model | P@5 | P@10 | MAP | NDCG@10 | MRR |
209
+ |-------|-----|------|-----|---------|-----|
210
+ | **Smart (live)** | **0.580** | 0.480 | **0.301** | 0.548 | **0.868** |
211
+ | BM25 | 0.553 | **0.530** | 0.270 | **0.553** | 0.802 |
212
+ | Hybrid (α=0.9) | 0.553 | 0.527 | 0.267 | 0.551 | 0.804 |
213
+ | TF-IDF | 0.547 | 0.510 | 0.239 | 0.523 | 0.727 |
214
+ | Neural MiniLM | 0.160 | 0.153 | 0.057 | 0.155 | 0.280 |
215
+
216
+ **Pool-restricted** (fair &mdash; Neural MAP melompat 0.057 → **0.557**, membuktikan skor rendah di atas adalah *pooling bias*, bukan model buruk; angka Smart di lensa ini optimistic karena sinyal filter overlap dengan heuristik annotator, lihat LAPORAN §7.1b):
217
+
218
+ | Model | P@5 | MAP |
219
+ |-------|-----|-----|
220
+ | Smart (live) | 0.927 | 0.865 |
221
+ | Hybrid (α=0.9) | 0.553 | 0.591 |
222
+ | **BM25** | 0.553 | 0.583 |
223
+ | TF-IDF | 0.560 | 0.576 |
224
+ | Neural MiniLM | 0.487 | 0.557 |
225
+
226
+ **Constraint Satisfaction @5** (lensa bebas qrels: % top-5 yang memenuhi SEMUA kebutuhan user &mdash; gender, budget, fasilitas, radius 3 km kampus):
227
+
228
+ | Model | mean CS@5 (n=30) |
229
+ |-------|------------------|
230
+ | **Smart (live)** | **0.863** |
231
+ | BM25 | 0.447 |
232
+
233
+ Smart vs BM25 signifikan (Wilcoxon p&lt;0.001). Signifikansi pairwise MAP memakai koreksi **Holm-Bonferroni**: **4/10 signifikan** (semuanya pasangan vs Neural di standard eval; bertahan setelah Holm). Hyperparameter via grid search: alpha hybrid 0.9, bobot fusion smart 0.2/0.4/0.4. Detail di [LAPORAN.md](LAPORAN.md) + [eval/eval_summary.md](eval/eval_summary.md).
234
+
235
+ ## License
236
+
237
+ MIT &mdash; lihat [LICENSE](LICENSE)
backend/.env.example ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ ENVIRONMENT=development
2
+ DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/tki_kos
3
+ CORS_ORIGINS=["http://localhost:5173","http://localhost:3000"]
4
+ EMBEDDING_MODEL=sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2
5
+ INDEXES_DIR=../data/indexes
6
+ LOG_LEVEL=INFO
backend/alembic.ini ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Alembic config untuk TKI-KOS backend
2
+ # URL di-override dari app.core.config.settings di env.py
3
+
4
+ [alembic]
5
+ script_location = alembic
6
+ prepend_sys_path = .
7
+ version_path_separator = os
8
+ sqlalchemy.url = postgresql+asyncpg://postgres:postgres@localhost:5432/tki_kos
9
+
10
+ [post_write_hooks]
11
+ # Optional: auto-format migration files setelah generate
12
+ # hooks = black
13
+ # black.type = console_scripts
14
+ # black.entrypoint = black
15
+ # black.options = REVISION_SCRIPT_FILENAME
16
+
17
+ [loggers]
18
+ keys = root,sqlalchemy,alembic
19
+
20
+ [handlers]
21
+ keys = console
22
+
23
+ [formatters]
24
+ keys = generic
25
+
26
+ [logger_root]
27
+ level = WARN
28
+ handlers = console
29
+ qualname =
30
+
31
+ [logger_sqlalchemy]
32
+ level = WARN
33
+ handlers =
34
+ qualname = sqlalchemy.engine
35
+
36
+ [logger_alembic]
37
+ level = INFO
38
+ handlers =
39
+ qualname = alembic
40
+
41
+ [handler_console]
42
+ class = StreamHandler
43
+ args = (sys.stderr,)
44
+ level = NOTSET
45
+ formatter = generic
46
+
47
+ [formatter_generic]
48
+ format = %(levelname)-5.5s [%(name)s] %(message)s
49
+ datefmt = %H:%M:%S
backend/alembic/env.py ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Alembic environment script untuk async SQLAlchemy 2.0.
2
+
3
+ Migration commands (dari backend/):
4
+ alembic revision --autogenerate -m "create listings table"
5
+ alembic upgrade head
6
+ alembic downgrade -1
7
+ alembic history
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ from logging.config import fileConfig
14
+
15
+ from sqlalchemy import pool
16
+ from sqlalchemy.engine import Connection
17
+ from sqlalchemy.ext.asyncio import async_engine_from_config
18
+
19
+ from alembic import context
20
+
21
+ from app.core.config import settings
22
+ from app.core.db import Base
23
+
24
+ # Import ALL models di sini supaya Alembic detect untuk autogenerate
25
+ from app.models import listing # noqa: F401
26
+
27
+
28
+ config = context.config
29
+ if config.config_file_name is not None:
30
+ fileConfig(config.config_file_name)
31
+
32
+ # Override URL dari env-driven settings
33
+ config.set_main_option("sqlalchemy.url", settings.database_url)
34
+
35
+ target_metadata = Base.metadata
36
+
37
+
38
+ def run_migrations_offline() -> None:
39
+ """Run migrations dalam offline mode (generate SQL ke stdout)."""
40
+ url = config.get_main_option("sqlalchemy.url")
41
+ context.configure(
42
+ url=url,
43
+ target_metadata=target_metadata,
44
+ literal_binds=True,
45
+ dialect_opts={"paramstyle": "named"},
46
+ )
47
+ with context.begin_transaction():
48
+ context.run_migrations()
49
+
50
+
51
+ def do_run_migrations(connection: Connection) -> None:
52
+ context.configure(connection=connection, target_metadata=target_metadata)
53
+ with context.begin_transaction():
54
+ context.run_migrations()
55
+
56
+
57
+ async def run_async_migrations() -> None:
58
+ """Run migrations dengan async engine."""
59
+ connectable = async_engine_from_config(
60
+ config.get_section(config.config_ini_section, {}),
61
+ prefix="sqlalchemy.",
62
+ poolclass=pool.NullPool,
63
+ )
64
+
65
+ async with connectable.connect() as connection:
66
+ await connection.run_sync(do_run_migrations)
67
+
68
+ await connectable.dispose()
69
+
70
+
71
+ def run_migrations_online() -> None:
72
+ asyncio.run(run_async_migrations())
73
+
74
+
75
+ if context.is_offline_mode():
76
+ run_migrations_offline()
77
+ else:
78
+ run_migrations_online()
backend/alembic/script.py.mako ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """${message}
2
+
3
+ Revision ID: ${up_revision}
4
+ Revises: ${down_revision | comma,n}
5
+ Create Date: ${create_date}
6
+
7
+ """
8
+ from typing import Sequence, Union
9
+
10
+ from alembic import op
11
+ import sqlalchemy as sa
12
+ ${imports if imports else ""}
13
+
14
+ # revision identifiers, used by Alembic.
15
+ revision: str = ${repr(up_revision)}
16
+ down_revision: Union[str, None] = ${repr(down_revision)}
17
+ branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
18
+ depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
19
+
20
+
21
+ def upgrade() -> None:
22
+ ${upgrades if upgrades else "pass"}
23
+
24
+
25
+ def downgrade() -> None:
26
+ ${downgrades if downgrades else "pass"}
backend/alembic/versions/001_initial_schema.py ADDED
@@ -0,0 +1,129 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """initial schema: listings + queries + ground_truth + eval_results
2
+
3
+ Revision ID: 001
4
+ Revises:
5
+ Create Date: 2026-05-26 00:00:00
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Sequence, Union
11
+
12
+ import sqlalchemy as sa
13
+ from alembic import op
14
+
15
+
16
+ # revision identifiers, used by Alembic
17
+ revision: str = "001"
18
+ down_revision: Union[str, None] = None
19
+ branch_labels: Union[str, Sequence[str], None] = None
20
+ depends_on: Union[str, Sequence[str], None] = None
21
+
22
+
23
+ def upgrade() -> None:
24
+ # ------------------------------------------------------------------
25
+ # listings: raw + processed kos data
26
+ # ------------------------------------------------------------------
27
+ op.create_table(
28
+ "listings",
29
+ sa.Column("id", sa.String(), primary_key=True),
30
+ sa.Column("judul", sa.Text(), nullable=False),
31
+ sa.Column("deskripsi", sa.Text(), nullable=False),
32
+ sa.Column("deskripsi_processed", sa.Text(), nullable=True),
33
+ sa.Column("harga_per_bulan", sa.Integer(), nullable=True),
34
+ sa.Column("tipe", sa.String(length=20), nullable=True),
35
+ sa.Column("fasilitas", sa.ARRAY(sa.String()), nullable=True),
36
+ sa.Column("alamat", sa.Text(), nullable=True),
37
+ sa.Column("kecamatan", sa.String(length=100), nullable=True),
38
+ sa.Column("koordinat_lat", sa.Numeric(precision=8, scale=5), nullable=True),
39
+ sa.Column("koordinat_lng", sa.Numeric(precision=8, scale=5), nullable=True),
40
+ sa.Column("jarak_kampus_km", sa.Numeric(precision=5, scale=2), nullable=True),
41
+ sa.Column("url_source", sa.Text(), nullable=True),
42
+ sa.Column("scrape_date", sa.Date(), nullable=True),
43
+ sa.Column("source", sa.String(length=20), nullable=True),
44
+ sa.Column(
45
+ "inserted_at",
46
+ sa.DateTime(),
47
+ server_default=sa.func.now(),
48
+ nullable=True,
49
+ ),
50
+ )
51
+
52
+ # Indexes umum
53
+ op.create_index("ix_listings_tipe", "listings", ["tipe"])
54
+ op.create_index("ix_listings_kecamatan", "listings", ["kecamatan"])
55
+ op.create_index("ix_listings_harga", "listings", ["harga_per_bulan"])
56
+
57
+ # ------------------------------------------------------------------
58
+ # queries: query set untuk eval
59
+ # ------------------------------------------------------------------
60
+ op.create_table(
61
+ "queries",
62
+ sa.Column("id", sa.String(), primary_key=True),
63
+ sa.Column("query_text", sa.Text(), nullable=False),
64
+ sa.Column("context", sa.Text(), nullable=True),
65
+ sa.Column("expected_tipe", sa.String(length=20), nullable=True),
66
+ )
67
+
68
+ # ------------------------------------------------------------------
69
+ # ground_truth: raw annotation per annotator (3-annotator setup)
70
+ # ------------------------------------------------------------------
71
+ op.create_table(
72
+ "ground_truth",
73
+ sa.Column("query_id", sa.String(), nullable=False),
74
+ sa.Column("listing_id", sa.String(), nullable=False),
75
+ sa.Column("annotator", sa.String(length=50), nullable=False),
76
+ sa.Column("relevance", sa.SmallInteger(), nullable=False),
77
+ sa.Column("notes", sa.Text(), nullable=True),
78
+ sa.CheckConstraint("relevance IN (0, 1, 2)", name="ck_relevance_0_1_2"),
79
+ sa.ForeignKeyConstraint(["query_id"], ["queries.id"]),
80
+ sa.ForeignKeyConstraint(["listing_id"], ["listings.id"]),
81
+ sa.PrimaryKeyConstraint("query_id", "listing_id", "annotator"),
82
+ )
83
+
84
+ # ------------------------------------------------------------------
85
+ # ground_truth_consensus: hasil consensus 3 annotator (input ke eval runner)
86
+ # ------------------------------------------------------------------
87
+ op.create_table(
88
+ "ground_truth_consensus",
89
+ sa.Column("query_id", sa.String(), nullable=False),
90
+ sa.Column("listing_id", sa.String(), nullable=False),
91
+ sa.Column("relevance", sa.SmallInteger(), nullable=False),
92
+ sa.CheckConstraint("relevance IN (0, 1, 2)", name="ck_consensus_relevance"),
93
+ sa.ForeignKeyConstraint(["query_id"], ["queries.id"]),
94
+ sa.ForeignKeyConstraint(["listing_id"], ["listings.id"]),
95
+ sa.PrimaryKeyConstraint("query_id", "listing_id"),
96
+ )
97
+
98
+ # ------------------------------------------------------------------
99
+ # eval_results: per-model per-query metrics
100
+ # ------------------------------------------------------------------
101
+ op.create_table(
102
+ "eval_results",
103
+ sa.Column("model", sa.String(length=20), nullable=False),
104
+ sa.Column("query_id", sa.String(), nullable=False),
105
+ sa.Column("precision_at_5", sa.Numeric(precision=5, scale=4), nullable=True),
106
+ sa.Column("precision_at_10", sa.Numeric(precision=5, scale=4), nullable=True),
107
+ sa.Column("average_precision", sa.Numeric(precision=5, scale=4), nullable=True),
108
+ sa.Column("ndcg_at_10", sa.Numeric(precision=5, scale=4), nullable=True),
109
+ sa.Column("reciprocal_rank", sa.Numeric(precision=5, scale=4), nullable=True),
110
+ sa.Column(
111
+ "computed_at",
112
+ sa.DateTime(),
113
+ server_default=sa.func.now(),
114
+ nullable=True,
115
+ ),
116
+ sa.ForeignKeyConstraint(["query_id"], ["queries.id"]),
117
+ sa.PrimaryKeyConstraint("model", "query_id"),
118
+ )
119
+
120
+
121
+ def downgrade() -> None:
122
+ op.drop_table("eval_results")
123
+ op.drop_table("ground_truth_consensus")
124
+ op.drop_table("ground_truth")
125
+ op.drop_table("queries")
126
+ op.drop_index("ix_listings_harga", table_name="listings")
127
+ op.drop_index("ix_listings_kecamatan", table_name="listings")
128
+ op.drop_index("ix_listings_tipe", table_name="listings")
129
+ op.drop_table("listings")
backend/app/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """TKI-KOS application package."""
backend/app/api/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """HTTP API routes."""
backend/app/api/eval.py ADDED
@@ -0,0 +1,170 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Evaluation dashboard endpoints (file-based).
2
+
3
+ Sumber data: CSV hasil eval yang di-COMMIT di repo (eval/results.csv,
4
+ results_pool_restricted.csv, results_constraints.csv, significance_map.csv).
5
+ Sebelumnya endpoint ini membaca tabel `eval_results` yang TIDAK PERNAH diisi
6
+ oleh siapa pun (runner hanya menulis CSV) sehingga selalu kosong di production.
7
+ File-based = nol langkah deploy ekstra dan hasil identik dengan laporan.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import csv
13
+ from collections import defaultdict
14
+ from pathlib import Path
15
+
16
+ from fastapi import APIRouter, HTTPException
17
+ from pydantic import BaseModel
18
+
19
+ router = APIRouter(prefix="/eval", tags=["evaluation"])
20
+
21
+ # repo root: backend/app/api/eval.py -> parents[3]
22
+ _EVAL_DIR = Path(__file__).resolve().parents[3] / "eval"
23
+
24
+
25
+ class ModelMetrics(BaseModel):
26
+ model: str
27
+ p_at_5: float
28
+ p_at_10: float
29
+ map: float
30
+ ndcg_at_10: float
31
+ mrr: float
32
+ n_queries: int
33
+
34
+
35
+ class ConstraintSummary(BaseModel):
36
+ n_queries: int
37
+ mean_cs_at_5: dict[str, float]
38
+
39
+
40
+ class SignificanceRow(BaseModel):
41
+ pair: str
42
+ p_value: float
43
+ p_holm: float
44
+ significant_raw: bool
45
+ significant_holm: bool
46
+
47
+
48
+ class EvalSummary(BaseModel):
49
+ standard: list[ModelMetrics]
50
+ pool_restricted: list[ModelMetrics]
51
+ constraints: ConstraintSummary | None
52
+ significance: list[SignificanceRow]
53
+ total_queries: int
54
+ note: str
55
+
56
+
57
+ class QueryResult(BaseModel):
58
+ query_id: str
59
+ query_text: str
60
+ model: str
61
+ p_at_5: float
62
+ p_at_10: float
63
+ average_precision: float
64
+ ndcg_at_10: float
65
+ reciprocal_rank: float
66
+
67
+
68
+ def _aggregate_results_csv(path: Path) -> tuple[list[ModelMetrics], int]:
69
+ """results.csv -> aggregate per model + jumlah query."""
70
+ if not path.exists():
71
+ return [], 0
72
+ per_model: dict[str, list[dict]] = defaultdict(list)
73
+ with open(path, encoding="utf-8") as f:
74
+ for row in csv.DictReader(f):
75
+ per_model[row["model"]].append(row)
76
+ out: list[ModelMetrics] = []
77
+ n_queries = 0
78
+ for model, rows in sorted(per_model.items()):
79
+ n = len(rows)
80
+ n_queries = max(n_queries, n)
81
+ out.append(ModelMetrics(
82
+ model=model,
83
+ p_at_5=sum(float(r["p_at_5"]) for r in rows) / n,
84
+ p_at_10=sum(float(r["p_at_10"]) for r in rows) / n,
85
+ map=sum(float(r["ap"]) for r in rows) / n,
86
+ ndcg_at_10=sum(float(r["ndcg_at_10"]) for r in rows) / n,
87
+ mrr=sum(float(r["rr"]) for r in rows) / n,
88
+ n_queries=n,
89
+ ))
90
+ out.sort(key=lambda m: -m.map)
91
+ return out, n_queries
92
+
93
+
94
+ def _load_constraints(path: Path) -> ConstraintSummary | None:
95
+ if not path.exists():
96
+ return None
97
+ with open(path, encoding="utf-8") as f:
98
+ rows = list(csv.DictReader(f))
99
+ if not rows:
100
+ return None
101
+ return ConstraintSummary(
102
+ n_queries=len(rows),
103
+ mean_cs_at_5={
104
+ "smart": sum(float(r["cs_at_5_smart"]) for r in rows) / len(rows),
105
+ "bm25": sum(float(r["cs_at_5_bm25"]) for r in rows) / len(rows),
106
+ },
107
+ )
108
+
109
+
110
+ def _load_significance(path: Path) -> list[SignificanceRow]:
111
+ if not path.exists():
112
+ return []
113
+ with open(path, encoding="utf-8") as f:
114
+ return [
115
+ SignificanceRow(
116
+ pair=r["pair"],
117
+ p_value=float(r["p_value"]),
118
+ p_holm=float(r["p_holm"]),
119
+ significant_raw=r["significant_raw"] == "yes",
120
+ significant_holm=r["significant_holm"] == "yes",
121
+ )
122
+ for r in csv.DictReader(f)
123
+ ]
124
+
125
+
126
+ @router.get("/summary", response_model=EvalSummary)
127
+ async def eval_summary() -> EvalSummary:
128
+ """Aggregate metrics per IR model: standard, pool-restricted, CS@5, Wilcoxon+Holm."""
129
+ standard, n_queries = _aggregate_results_csv(_EVAL_DIR / "results.csv")
130
+ pool, _ = _aggregate_results_csv(_EVAL_DIR / "results_pool_restricted.csv")
131
+ return EvalSummary(
132
+ standard=standard,
133
+ pool_restricted=pool,
134
+ constraints=_load_constraints(_EVAL_DIR / "results_constraints.csv"),
135
+ significance=_load_significance(_EVAL_DIR / "significance_map.csv"),
136
+ total_queries=n_queries,
137
+ note=(
138
+ "Standard top-K memakai qrels yang di-pool dari kandidat lexical "
139
+ "(pooling bias menekan model semantic & smart geo-augment); "
140
+ "pool-restricted = ranking di dalam pool annotated (fair); "
141
+ "CS@5 = % top-5 yang memenuhi semua constraint user (bebas qrels). "
142
+ "Signifikansi: Wilcoxon signed-rank + koreksi Holm-Bonferroni."
143
+ ),
144
+ )
145
+
146
+
147
+ @router.get("/query/{query_id}", response_model=list[QueryResult])
148
+ async def eval_per_query(query_id: str) -> list[QueryResult]:
149
+ """Per-model metric untuk satu query (drill-down dari results.csv)."""
150
+ path = _EVAL_DIR / "results.csv"
151
+ if not path.exists():
152
+ raise HTTPException(404, "results.csv belum ada — jalankan evaluasi dulu")
153
+ out: list[QueryResult] = []
154
+ with open(path, encoding="utf-8") as f:
155
+ for r in csv.DictReader(f):
156
+ if r["query_id"] == query_id:
157
+ out.append(QueryResult(
158
+ query_id=r["query_id"],
159
+ query_text=r["query"],
160
+ model=r["model"],
161
+ p_at_5=float(r["p_at_5"]),
162
+ p_at_10=float(r["p_at_10"]),
163
+ average_precision=float(r["ap"]),
164
+ ndcg_at_10=float(r["ndcg_at_10"]),
165
+ reciprocal_rank=float(r["rr"]),
166
+ ))
167
+ if not out:
168
+ raise HTTPException(404, f"Query '{query_id}' tidak ada di results.csv")
169
+ out.sort(key=lambda x: x.model)
170
+ return out
backend/app/api/health.py ADDED
@@ -0,0 +1,138 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Health check + status diagnostics endpoints.
2
+
3
+ - /health: simple liveness probe untuk Render
4
+ - /api/info: basic version info
5
+ - /api/status: comprehensive diagnostics (indexes, DB, model warm state)
6
+ Penting untuk debug user-reported issues karena user copy-paste output ini.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import platform
12
+ import sys
13
+ from datetime import datetime, timezone
14
+
15
+ from fastapi import APIRouter, Depends, Request
16
+ from sqlalchemy import func, select
17
+ from sqlalchemy.ext.asyncio import AsyncSession
18
+
19
+ from app.core.db import get_session
20
+
21
+ router = APIRouter()
22
+
23
+
24
+ @router.get("/health")
25
+ async def health():
26
+ """Liveness probe untuk Render.com."""
27
+ return {"status": "ok", "service": "kozynear"}
28
+
29
+
30
+ @router.get("/api/info")
31
+ async def api_info():
32
+ """Basic API info."""
33
+ return {
34
+ "name": "KozyNear API",
35
+ "version": "0.2.0",
36
+ "scope": "Bandar Lampung full coverage (20 kecamatan, 9 universitas)",
37
+ "docs": "/api/docs",
38
+ "health": "/health",
39
+ "status": "/api/status",
40
+ }
41
+
42
+
43
+ @router.get("/api/status")
44
+ async def status(
45
+ request: Request,
46
+ session: AsyncSession = Depends(get_session),
47
+ ):
48
+ """Comprehensive diagnostics — paste output ini saat report bug.
49
+
50
+ Returns:
51
+ - Service: name, version, environment, uptime
52
+ - Indexes loaded (tfidf, bm25, indobert, hybrid)
53
+ - Preprocessing pipeline loaded
54
+ - Database: listings count, connection ok
55
+ - Runtime: Python version, platform
56
+ """
57
+ from app.core.config import settings
58
+
59
+ # Index availability checks
60
+ state = request.app.state
61
+ indexes_status = {
62
+ "tfidf": state.tfidf is not None,
63
+ "bm25": state.bm25 is not None,
64
+ "indobert": state.indobert is not None,
65
+ "hybrid": state.hybrid is not None,
66
+ }
67
+ indobert_ready = getattr(state, "indobert_ready", False)
68
+
69
+ # Index sizes (kalau loaded)
70
+ indexes_size = {}
71
+ for name in ("tfidf", "bm25", "indobert"):
72
+ idx = getattr(state, name, None)
73
+ if idx is not None and hasattr(idx, "size"):
74
+ try:
75
+ indexes_size[name] = idx.size()
76
+ except Exception:
77
+ indexes_size[name] = None
78
+
79
+ # DB row counts
80
+ db_ok = True
81
+ db_error = None
82
+ listings_count = None
83
+ try:
84
+ from app.models.listing import Listing
85
+
86
+ result = await session.execute(select(func.count(Listing.id)))
87
+ listings_count = int(result.scalar() or 0)
88
+ except Exception as e:
89
+ db_ok = False
90
+ db_error = str(e)
91
+
92
+ # Memory usage (kalau psutil ada)
93
+ memory_info = None
94
+ try:
95
+ import psutil
96
+
97
+ process = psutil.Process()
98
+ mem = process.memory_info()
99
+ memory_info = {
100
+ "rss_mb": round(mem.rss / 1024 / 1024, 1),
101
+ "vms_mb": round(mem.vms / 1024 / 1024, 1),
102
+ }
103
+ except ImportError:
104
+ pass
105
+
106
+ return {
107
+ "timestamp": datetime.now(timezone.utc).isoformat(),
108
+ "service": {
109
+ "name": "kozynear",
110
+ "version": "0.2.0",
111
+ "environment": settings.environment,
112
+ },
113
+ "preprocessing": {
114
+ "loaded": state.preprocessing_pipeline is not None,
115
+ },
116
+ "indexes": {
117
+ "loaded": indexes_status,
118
+ "sizes": indexes_size,
119
+ "indexes_dir": settings.indexes_dir,
120
+ "indobert_model_ready": indobert_ready,
121
+ },
122
+ "database": {
123
+ "connected": db_ok,
124
+ "listings_count": listings_count,
125
+ "error": db_error,
126
+ },
127
+ "runtime": {
128
+ "python": sys.version.split()[0],
129
+ "platform": platform.platform(),
130
+ },
131
+ "memory": memory_info,
132
+ "settings": {
133
+ "embedding_model": settings.embedding_model,
134
+ "default_top_k": settings.default_top_k,
135
+ "bm25_k1": settings.bm25_k1,
136
+ "bm25_b": settings.bm25_b,
137
+ },
138
+ }
backend/app/api/insights.py ADDED
@@ -0,0 +1,126 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Endpoint demo/insight: visualisasi preprocessing + statistik corpus.
2
+
3
+ Diporting dari konsep prototype Flask `proyek stki` (tab Preprocessing +
4
+ /api/stats) — fitur paling berguna untuk demo rubric Preprocessing & Sistem.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from pathlib import Path
11
+
12
+ from fastapi import APIRouter, Depends, HTTPException, Query, Request
13
+ from sqlalchemy import func, select
14
+ from sqlalchemy.ext.asyncio import AsyncSession
15
+
16
+ from app.core.db import get_session
17
+ from app.models.listing import Listing
18
+
19
+ router = APIRouter()
20
+
21
+ # Fallback statistik kalau DB down: corpus.json ikut ter-copy di image.
22
+ _CORPUS_JSON = Path(__file__).resolve().parents[3] / "data" / "processed" / "corpus.json"
23
+
24
+
25
+ @router.get("/preprocess", tags=["insights"])
26
+ async def preprocess_trace(
27
+ request: Request,
28
+ text: str = Query(..., min_length=1, max_length=1000,
29
+ description="Teks untuk dilewatkan pipeline 9-stage"),
30
+ ):
31
+ """Jalankan pipeline preprocessing dengan trace per-stage (untuk UI demo)."""
32
+ pipeline = getattr(request.app.state, "preprocessing_pipeline", None)
33
+ if pipeline is None:
34
+ raise HTTPException(503, detail={"error": "preprocessing pipeline belum loaded"})
35
+ result = pipeline.process(text, trace=True)
36
+ return {
37
+ "raw": result.raw,
38
+ "processed": result.processed,
39
+ "tokens": result.tokens,
40
+ "extracted_prices": result.extracted_prices,
41
+ "stages": result.trace,
42
+ }
43
+
44
+
45
+ def _corpus_fallback_stats() -> dict:
46
+ """Statistik dari corpus.json saat DB tidak tersedia (mis. test lokal)."""
47
+ docs = json.loads(_CORPUS_JSON.read_text(encoding="utf-8"))
48
+ kecamatan: dict[str, int] = {}
49
+ tipe: dict[str, int] = {}
50
+ hargas: list[int] = []
51
+ for d in docs:
52
+ meta = d.get("metadata", d)
53
+ kec = meta.get("kecamatan") or "?"
54
+ kecamatan[kec] = kecamatan.get(kec, 0) + 1
55
+ t = meta.get("tipe") or "?"
56
+ tipe[t] = tipe.get(t, 0) + 1
57
+ h = meta.get("harga_per_bulan")
58
+ if isinstance(h, int):
59
+ hargas.append(h)
60
+ return {
61
+ "total_listings": len(docs),
62
+ "kecamatan": dict(sorted(kecamatan.items(), key=lambda kv: -kv[1])),
63
+ "tipe": tipe,
64
+ "harga_min": min(hargas) if hargas else None,
65
+ "harga_max": max(hargas) if hargas else None,
66
+ "harga_avg": int(sum(hargas) / len(hargas)) if hargas else None,
67
+ "source": "corpus.json (DB tidak tersedia)",
68
+ }
69
+
70
+
71
+ @router.get("/stats", tags=["insights"])
72
+ async def corpus_stats(
73
+ request: Request,
74
+ session: AsyncSession = Depends(get_session),
75
+ ):
76
+ """Statistik corpus live: jumlah listing, distribusi kecamatan/tipe, harga,
77
+ ukuran vocabulary index. Untuk tab Statistik + bahan slide."""
78
+ stats: dict
79
+ try:
80
+ total = (await session.execute(select(func.count(Listing.id)))).scalar() or 0
81
+ kec_rows = (await session.execute(
82
+ select(Listing.kecamatan, func.count())
83
+ .group_by(Listing.kecamatan)
84
+ .order_by(func.count().desc())
85
+ )).all()
86
+ tipe_rows = (await session.execute(
87
+ select(Listing.tipe, func.count()).group_by(Listing.tipe)
88
+ )).all()
89
+ harga_row = (await session.execute(
90
+ select(
91
+ func.min(Listing.harga_per_bulan),
92
+ func.max(Listing.harga_per_bulan),
93
+ func.avg(Listing.harga_per_bulan),
94
+ )
95
+ )).one()
96
+ stats = {
97
+ "total_listings": int(total),
98
+ "kecamatan": {k or "?": int(c) for k, c in kec_rows},
99
+ "tipe": {t or "?": int(c) for t, c in tipe_rows},
100
+ "harga_min": int(harga_row[0]) if harga_row[0] is not None else None,
101
+ "harga_max": int(harga_row[1]) if harga_row[1] is not None else None,
102
+ "harga_avg": int(harga_row[2]) if harga_row[2] is not None else None,
103
+ "source": "database",
104
+ }
105
+ except Exception:
106
+ # DB down / belum seed -> fallback corpus committed (read-only stats)
107
+ try:
108
+ stats = _corpus_fallback_stats()
109
+ except Exception as e: # corpus juga tidak ada
110
+ raise HTTPException(503, detail={"error": f"stats tidak tersedia: {e}"})
111
+
112
+ # Vocabulary size dari index yang loaded (BM25 idf vocab)
113
+ bm25 = getattr(request.app.state, "bm25", None)
114
+ vocab_size = None
115
+ if bm25 is not None and getattr(bm25, "bm25", None) is not None:
116
+ try:
117
+ vocab_size = len(bm25.bm25.idf)
118
+ except Exception:
119
+ vocab_size = None
120
+ stats["vocab_size"] = vocab_size
121
+ stats["models_loaded"] = [
122
+ m for m in ("tfidf", "bm25", "indobert", "hybrid")
123
+ if getattr(request.app.state, m, None) is not None
124
+ ] + (["smart"] if getattr(request.app.state, "bm25", None) is not None
125
+ and getattr(request.app.state, "gazetteer", None) is not None else [])
126
+ return stats
backend/app/api/listings.py ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Listing detail endpoint: GET /listings/{id}."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from fastapi import APIRouter, Depends, HTTPException
6
+ from sqlalchemy import select
7
+ from sqlalchemy.ext.asyncio import AsyncSession
8
+
9
+ from app.core.db import get_session
10
+ from app.models.listing import Listing, ListingDetail
11
+
12
+
13
+ router = APIRouter()
14
+
15
+
16
+ @router.get(
17
+ "/listings/{listing_id}",
18
+ response_model=ListingDetail,
19
+ tags=["listings"],
20
+ )
21
+ async def get_listing(
22
+ listing_id: str,
23
+ session: AsyncSession = Depends(get_session),
24
+ ) -> ListingDetail:
25
+ """Get detail listing by ID. Return 404 kalau gak ditemukan."""
26
+ result = await session.execute(
27
+ select(Listing).where(Listing.id == listing_id)
28
+ )
29
+ listing = result.scalar_one_or_none()
30
+ if not listing:
31
+ raise HTTPException(
32
+ status_code=404,
33
+ detail=f"Listing '{listing_id}' not found",
34
+ )
35
+
36
+ coordinates = None
37
+ if listing.koordinat_lat is not None and listing.koordinat_lng is not None:
38
+ coordinates = [
39
+ float(listing.koordinat_lat),
40
+ float(listing.koordinat_lng),
41
+ ]
42
+
43
+ return ListingDetail(
44
+ id=listing.id,
45
+ judul=listing.judul,
46
+ deskripsi=listing.deskripsi,
47
+ deskripsi_processed=listing.deskripsi_processed,
48
+ harga_per_bulan=listing.harga_per_bulan,
49
+ tipe=listing.tipe,
50
+ fasilitas=listing.fasilitas,
51
+ alamat=listing.alamat,
52
+ kecamatan=listing.kecamatan,
53
+ koordinat=coordinates,
54
+ jarak_kampus_km=(
55
+ float(listing.jarak_kampus_km)
56
+ if listing.jarak_kampus_km is not None
57
+ else None
58
+ ),
59
+ url_source=listing.url_source,
60
+ scrape_date=listing.scrape_date,
61
+ source=listing.source,
62
+ )
backend/app/api/search.py ADDED
@@ -0,0 +1,232 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Search endpoint: GET /search?q=X&model=Y&top_k=N + optional filters.
2
+
3
+ Pipeline:
4
+ 1. Pilih IR index dari app.state berdasar param `model`
5
+ 2. Preprocess query (same as corpus preprocessing saat indexing)
6
+ 3. IR index query top-K * overshoot (3x) — supaya filter setelahnya
7
+ gak return < top_k
8
+ 4. Hydrate dari DB dengan filter (harga, tipe, kecamatan)
9
+ 5. Take top-K filtered, preserve ranking, return SearchResponse
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import time
15
+ from typing import Literal, Optional
16
+
17
+ from fastapi import APIRouter, Depends, HTTPException, Query, Request
18
+ from loguru import logger
19
+ from sqlalchemy import select
20
+ from sqlalchemy.ext.asyncio import AsyncSession
21
+
22
+ from app.core.db import get_session
23
+ from app.models.listing import Listing, ListingRead
24
+ from app.models.search import SearchResponse
25
+ from app.search.pipeline import SmartFilters, smart_search
26
+
27
+
28
+ router = APIRouter()
29
+
30
+
31
+ # Overshoot factor: index query ambil top_k * X supaya filter gak terlalu
32
+ # memotong hasil. 3 cukup untuk filter ringan; bisa naik kalau filter ketat.
33
+ OVERSHOOT_FACTOR = 3
34
+
35
+
36
+ @router.get("/search", response_model=SearchResponse, tags=["search"])
37
+ async def search(
38
+ request: Request,
39
+ q: str = Query(..., min_length=1, max_length=500, description="Query string"),
40
+ model: Literal["tfidf", "bm25", "indobert", "hybrid", "smart"] = Query(
41
+ "smart", description="IR model pilihan (default: smart = query understanding + geo + BM25)"
42
+ ),
43
+ top_k: int = Query(10, ge=1, le=50, description="Jumlah hasil"),
44
+ # ---- Optional filters (metadata-based) ----
45
+ harga_min: Optional[int] = Query(
46
+ None, ge=0, description="Min harga per bulan (IDR)"
47
+ ),
48
+ harga_max: Optional[int] = Query(
49
+ None, ge=0, description="Max harga per bulan (IDR)"
50
+ ),
51
+ tipe: Optional[Literal["putra", "putri", "campur"]] = Query(
52
+ None, description="Filter tipe kos"
53
+ ),
54
+ kecamatan: Optional[str] = Query(
55
+ None, description="Filter kecamatan (substring match)"
56
+ ),
57
+ session: AsyncSession = Depends(get_session),
58
+ ) -> SearchResponse:
59
+ """Cari kos dengan IR model + optional metadata filters.
60
+
61
+ Returns 503 kalau index belum di-load di backend.
62
+ """
63
+ # 0. Smart pipeline (default): query understanding + geo + BM25 (no neural).
64
+ if model == "smart":
65
+ gz = getattr(request.app.state, "gazetteer", None)
66
+ bm25_index = getattr(request.app.state, "bm25", None)
67
+ if bm25_index is None or gz is None:
68
+ raise HTTPException(
69
+ status_code=503,
70
+ detail={"error": "smart pipeline belum siap (butuh bm25 + gazetteer)"},
71
+ )
72
+ # Residual query harus lewat preprocessing yang sama dengan corpus
73
+ # saat indexing (stem + jargon), kalau tidak token gak match index.
74
+ pipeline = getattr(request.app.state, "preprocessing_pipeline", None)
75
+ preprocess = (
76
+ (lambda s: pipeline.process(s).processed) if pipeline else None
77
+ )
78
+ # Filter UI eksplisit ikut ke smart sebagai hard constraint
79
+ # (sebelumnya diabaikan diam-diam di mode smart).
80
+ smart_filters = SmartFilters(
81
+ harga_min=harga_min, harga_max=harga_max,
82
+ tipe=tipe, kecamatan=kecamatan,
83
+ )
84
+ t0 = time.perf_counter()
85
+ results, understood, relaxed = await smart_search(
86
+ q, bm25_index, session, gz, top_k=top_k,
87
+ preprocess=preprocess, filters=smart_filters,
88
+ )
89
+ return SearchResponse(
90
+ query=q, model=model, top_k=top_k,
91
+ took_ms=int((time.perf_counter() - t0) * 1000),
92
+ results=results, understood=understood, relaxed=relaxed,
93
+ )
94
+
95
+ # 1. Get index dari app.state
96
+ index = getattr(request.app.state, model, None)
97
+ if index is None:
98
+ available = [
99
+ m for m in ("tfidf", "bm25", "indobert", "hybrid")
100
+ if getattr(request.app.state, m, None) is not None
101
+ ]
102
+ raise HTTPException(
103
+ status_code=503,
104
+ detail={
105
+ "error": f"Index '{model}' belum di-load di backend",
106
+ "model_requested": model,
107
+ "available_models": available,
108
+ "preprocessing_loaded": getattr(
109
+ request.app.state, "preprocessing_pipeline", None
110
+ ) is not None,
111
+ "hint": (
112
+ "Kemungkinan OOM saat prewarm IndoBERT di Render free tier "
113
+ "(512MB RAM). Check Render logs untuk detail. Untuk "
114
+ "diagnostics lengkap GET /api/status."
115
+ ),
116
+ },
117
+ )
118
+
119
+ # 1b. Check IndoBERT/Hybrid model warm state (background prewarm)
120
+ if model in ("indobert", "hybrid"):
121
+ if not getattr(request.app.state, "indobert_ready", False):
122
+ failed = getattr(request.app.state, "indobert_failed", False)
123
+ if failed:
124
+ raise HTTPException(
125
+ status_code=503,
126
+ detail={
127
+ "error": "IndoBERT model gagal load (prewarm permanent failure)",
128
+ "model_requested": model,
129
+ "ready_models": [
130
+ m for m in ("tfidf", "bm25")
131
+ if getattr(request.app.state, m, None) is not None
132
+ ],
133
+ "hint": (
134
+ "IndoBERT prewarm gagal permanen (kemungkinan OOM di "
135
+ "Render free tier 512MB). Restart deployment atau gunakan "
136
+ "TF-IDF/BM25. Retry TIDAK akan membantu tanpa restart."
137
+ ),
138
+ },
139
+ )
140
+ raise HTTPException(
141
+ status_code=503,
142
+ detail={
143
+ "error": "IndoBERT model masih loading (background prewarm)",
144
+ "model_requested": model,
145
+ "ready_models": [
146
+ m for m in ("tfidf", "bm25")
147
+ if getattr(request.app.state, m, None) is not None
148
+ ],
149
+ "hint": (
150
+ "Background prewarm jalan di startup, butuh ~10-30 detik "
151
+ "di Render free tier. Coba TF-IDF/BM25 dulu yang sudah "
152
+ "ready, atau retry IndoBERT/Hybrid setelah ~30 detik."
153
+ ),
154
+ "retry_after_sec": 30,
155
+ },
156
+ headers={"Retry-After": "30"},
157
+ )
158
+
159
+ t0 = time.perf_counter()
160
+
161
+ # 2. Preprocess query (untuk BM25/TF-IDF). IndoBERT & Hybrid pakai raw
162
+ # query karena sentence-transformers ekspektasi natural language.
163
+ pipeline = getattr(request.app.state, "preprocessing_pipeline", None)
164
+ processed_q = pipeline.process(q).processed if pipeline else q
165
+ search_q = q if model in ("indobert", "hybrid") else processed_q
166
+ logger.debug(
167
+ f"[search] q='{q}' processed='{processed_q}' model={model} "
168
+ f"using={'raw' if model in ('indobert', 'hybrid') else 'processed'}"
169
+ )
170
+
171
+ # 3. IR query — overshoot kalau ada filter (mitigate filter cutting hits)
172
+ has_filter = any([harga_min is not None, harga_max is not None, tipe is not None, kecamatan is not None])
173
+ fetch_k = top_k * OVERSHOOT_FACTOR if has_filter else top_k
174
+ hits = index.query(search_q, top_k=fetch_k)
175
+ if not hits:
176
+ return SearchResponse(
177
+ query=q, model=model, top_k=top_k, took_ms=0, results=[]
178
+ )
179
+
180
+ doc_ids = [h.doc_id for h in hits]
181
+ score_map = {h.doc_id: h.score for h in hits}
182
+
183
+ # 4. Hydrate from DB dengan filters
184
+ stmt = select(Listing).where(Listing.id.in_(doc_ids))
185
+ if harga_min is not None:
186
+ stmt = stmt.where(Listing.harga_per_bulan >= harga_min)
187
+ if harga_max is not None:
188
+ stmt = stmt.where(Listing.harga_per_bulan <= harga_max)
189
+ if tipe:
190
+ stmt = stmt.where(Listing.tipe == tipe)
191
+ if kecamatan:
192
+ stmt = stmt.where(Listing.kecamatan.ilike(f"%{kecamatan}%"))
193
+
194
+ db_result = await session.execute(stmt)
195
+ listings_map = {row.id: row for row in db_result.scalars().all()}
196
+
197
+ # 5. Preserve IR ranking, take top_k filtered
198
+ results: list[ListingRead] = []
199
+ for doc_id in doc_ids:
200
+ if len(results) >= top_k:
201
+ break
202
+ listing = listings_map.get(doc_id)
203
+ if not listing:
204
+ continue # filtered out atau gak ada di DB
205
+ results.append(
206
+ ListingRead(
207
+ id=listing.id,
208
+ judul=listing.judul,
209
+ deskripsi=listing.deskripsi,
210
+ harga_per_bulan=listing.harga_per_bulan,
211
+ tipe=listing.tipe,
212
+ fasilitas=listing.fasilitas,
213
+ alamat=listing.alamat,
214
+ kecamatan=listing.kecamatan,
215
+ score=score_map[doc_id],
216
+ koordinat=(
217
+ [float(listing.koordinat_lat), float(listing.koordinat_lng)]
218
+ if listing.koordinat_lat is not None
219
+ and listing.koordinat_lng is not None
220
+ else None
221
+ ),
222
+ )
223
+ )
224
+
225
+ took_ms = int((time.perf_counter() - t0) * 1000)
226
+ logger.info(
227
+ f"[search] q='{q}' model={model} filter={has_filter} "
228
+ f"hits={len(results)}/{top_k} took_ms={took_ms}"
229
+ )
230
+ return SearchResponse(
231
+ query=q, model=model, top_k=top_k, took_ms=took_ms, results=results
232
+ )
backend/app/core/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Core utilities: config, DB connection, logging."""
backend/app/core/config.py ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Application configuration via Pydantic Settings (env-driven).
2
+
3
+ Analogi Laravel: ini setara dengan `config/app.php` + `config/database.php`
4
+ yang baca dari `.env`. Pydantic Settings otomatis cast type (mis. CORS_ORIGINS
5
+ JSON array → Python list).
6
+ """
7
+
8
+ from pydantic import field_validator
9
+ from pydantic_settings import BaseSettings, SettingsConfigDict
10
+
11
+
12
+ class Settings(BaseSettings):
13
+ model_config = SettingsConfigDict(
14
+ env_file=".env",
15
+ env_file_encoding="utf-8",
16
+ extra="ignore",
17
+ )
18
+
19
+ # General
20
+ environment: str = "development"
21
+ log_level: str = "INFO"
22
+
23
+ # Database
24
+ database_url: str = "postgresql+asyncpg://postgres:postgres@localhost:5432/tki_kos"
25
+
26
+ # CORS — list domain frontend yang boleh akses
27
+ cors_origins: list[str] = [
28
+ "http://localhost:5173",
29
+ "http://localhost:3000",
30
+ ]
31
+
32
+ # IR config
33
+ indexes_dir: str = "../data/indexes"
34
+ embedding_model: str = "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2"
35
+ default_top_k: int = 10
36
+ bm25_k1: float = 1.5
37
+ bm25_b: float = 0.75
38
+
39
+ # Neural (IndoBERT) di-OFF di production (hemat RAM Render free 512MB).
40
+ # Hidup di notebook/Colab untuk eval. Set ENABLE_NEURAL=true untuk aktif.
41
+ enable_neural: bool = False
42
+
43
+ @field_validator("database_url", mode="after")
44
+ @classmethod
45
+ def normalize_database_url(cls, v: str) -> str:
46
+ """Normalize URL Postgres ke `postgresql+asyncpg://` (SQLAlchemy 2.0 async).
47
+
48
+ Render kasih env var DATABASE_URL dengan scheme `postgres://`, sementara
49
+ SQLAlchemy 2.0 minta `postgresql://` minimum, dan untuk async pakai
50
+ `postgresql+asyncpg://`. Validator ini handle 3 case:
51
+
52
+ - `postgres://...` -> `postgresql+asyncpg://...` (Render default)
53
+ - `postgresql://...` -> `postgresql+asyncpg://...` (bare)
54
+ - `postgresql+asyncpg://...` -> unchanged (sudah benar)
55
+ - `postgresql+<other>://...` -> unchanged (explicit driver)
56
+ """
57
+ if v.startswith("postgres://"):
58
+ return v.replace("postgres://", "postgresql+asyncpg://", 1)
59
+ # Match bare `postgresql://` (no driver suffix)
60
+ if v.startswith("postgresql://"):
61
+ return v.replace("postgresql://", "postgresql+asyncpg://", 1)
62
+ return v
63
+
64
+
65
+ settings = Settings()
backend/app/core/db.py ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Async SQLAlchemy engine + session factory.
2
+
3
+ Pattern: yield AsyncSession per-request via FastAPI dependency.
4
+
5
+ Analogi Laravel: ini setara dengan Database Manager + DB facade.
6
+ - Eloquent ORM = SQLAlchemy ORM
7
+ - DB::transaction = async with session.begin()
8
+ - DB::table('x') = select(X)
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import AsyncGenerator
14
+
15
+ from sqlalchemy.ext.asyncio import (
16
+ AsyncSession,
17
+ async_sessionmaker,
18
+ create_async_engine,
19
+ )
20
+ from sqlalchemy.orm import DeclarativeBase
21
+
22
+ from app.core.config import settings
23
+
24
+
25
+ class Base(DeclarativeBase):
26
+ """SQLAlchemy declarative base untuk semua ORM models.
27
+
28
+ Subclass ini di setiap model file:
29
+ from app.core.db import Base
30
+ class Listing(Base): ...
31
+ """
32
+
33
+
34
+ engine = create_async_engine(
35
+ settings.database_url,
36
+ echo=settings.environment == "development",
37
+ pool_pre_ping=True, # validate connection before use (avoid stale conns)
38
+ )
39
+
40
+
41
+ async_session_factory = async_sessionmaker(
42
+ engine,
43
+ class_=AsyncSession,
44
+ expire_on_commit=False, # objects accessible after commit
45
+ )
46
+
47
+
48
+ async def get_session() -> AsyncGenerator[AsyncSession, None]:
49
+ """FastAPI dependency: yield session per request, auto cleanup + rollback.
50
+
51
+ Usage di route:
52
+ @router.get("/x")
53
+ async def handler(session: AsyncSession = Depends(get_session)):
54
+ ...
55
+ """
56
+ async with async_session_factory() as session:
57
+ try:
58
+ yield session
59
+ except Exception:
60
+ await session.rollback()
61
+ raise
62
+
63
+
64
+ async def init_db() -> None:
65
+ """Create tables dari metadata. Untuk DEV ONLY.
66
+
67
+ Production: pakai Alembic migrations (lihat backend/alembic/).
68
+ Tim TODO: setup Alembic Week 2:
69
+ cd backend
70
+ alembic init alembic
71
+ # edit alembic/env.py untuk import Base
72
+ alembic revision --autogenerate -m "initial schema"
73
+ alembic upgrade head
74
+ """
75
+ async with engine.begin() as conn:
76
+ await conn.run_sync(Base.metadata.create_all)
backend/app/evaluation/README.md ADDED
@@ -0,0 +1,172 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Evaluation Module
2
+
3
+ IR evaluation metrics, inter-annotator agreement (Cohen's Kappa), dan
4
+ statistical significance tests untuk rubric Evaluation 10%.
5
+
6
+ ## Metrics
7
+
8
+ | Metric | Definisi | Range | Best |
9
+ |--------|----------|-------|------|
10
+ | P@K (K=5,10) | proportion of top-K predicted yang relevant | [0,1] | higher |
11
+ | MAP | Mean Average Precision across queries | [0,1] | higher |
12
+ | NDCG@10 | Normalized Discounted Cumulative Gain (graded relevance) | [0,1] | higher |
13
+ | MRR | Mean Reciprocal Rank (1/rank of first relevant) | [0,1] | higher |
14
+
15
+ ## Inter-Annotator Agreement
16
+
17
+ - **`cohen_kappa`**: untuk 2 annotator, nominal labels
18
+ - **`weighted_kappa`**: untuk ordinal labels (0/1/2 relevance) — direkomendasikan
19
+
20
+ **Target rubric**: Kappa >= 0.7 across all annotator pairs. Kalau di bawah,
21
+ **dokumentasikan + lakuin consensus resolution** sebelum compute IR metrics.
22
+
23
+ Interpretation (Landis & Koch 1977):
24
+ - < 0.20: slight | 0.21-0.40: fair | 0.41-0.60: moderate
25
+ - 0.61-0.80: substantial | 0.81-1.00: almost perfect
26
+
27
+ ## Statistical Significance
28
+
29
+ Untuk compare 2 model (e.g., BM25 vs IndoBERT) berdasar per-query metric:
30
+
31
+ - **`paired_ttest`**: kalau distribusi roughly normal (lebih powerful)
32
+ - **`wilcoxon_signed_rank`**: non-parametric, **direkomendasikan** untuk IR
33
+ (metric in [0,1] sering skewed)
34
+
35
+ H0: "Model A == Model B". alpha = 0.05. p < alpha => significant difference.
36
+
37
+ ## Workflow Penuh
38
+
39
+ ### 1. Annotation Phase (Week 3)
40
+
41
+ Persiapan:
42
+ 1. Tim Anggota E koordinasi 3 annotator (anggota tim, bukan AI)
43
+ 2. Pre-annotation calibration: discuss 3-5 sample queries bareng, agree on
44
+ what "relevant" / "somewhat relevant" / "not relevant" means
45
+ 3. Distribute task: tiap annotator label SEMUA top-30 hits dari BM25 untuk
46
+ setiap query (300+ judgments per annotator, ~12 queries x 30 docs)
47
+
48
+ Output: `eval/annotations_annotator_{A,B,C}.csv`
49
+ ```csv
50
+ query_id,doc_id,relevance
51
+ q01,d12345,2
52
+ q01,d12346,1
53
+ ...
54
+ ```
55
+
56
+ ### 2. Compute Kappa + Consensus
57
+
58
+ ```python
59
+ import csv
60
+ from app.evaluation import cohen_kappa, weighted_kappa, interpret_kappa
61
+
62
+ # Load annotations
63
+ def load(path):
64
+ with open(path) as f:
65
+ return {(row["query_id"], row["doc_id"]): int(row["relevance"])
66
+ for row in csv.DictReader(f)}
67
+
68
+ ann_a = load("eval/annotations_annotator_A.csv")
69
+ ann_b = load("eval/annotations_annotator_B.csv")
70
+ ann_c = load("eval/annotations_annotator_C.csv")
71
+
72
+ # Align by shared (query, doc) keys
73
+ shared = set(ann_a.keys()) & set(ann_b.keys()) & set(ann_c.keys())
74
+
75
+ for pair_name, (pa, pb) in [("A-B", (ann_a, ann_b)), ("A-C", (ann_a, ann_c)),
76
+ ("B-C", (ann_b, ann_c))]:
77
+ a_vals = [pa[k] for k in shared]
78
+ b_vals = [pb[k] for k in shared]
79
+ k = weighted_kappa(a_vals, b_vals)
80
+ print(f"{pair_name}: kappa={k:.3f} ({interpret_kappa(k)})")
81
+ ```
82
+
83
+ Target: semua pair >= 0.7. Kalau ada yang <0.7:
84
+ 1. Identify queries dengan disagreement tertinggi
85
+ 2. Discussion session: 3 annotator bareng, agree pada labeling
86
+ 3. Re-annotate problematic queries
87
+ 4. Recompute kappa
88
+ 5. Generate consensus: majority vote, atau (kalau 2-2 split) tertinggi/median
89
+
90
+ Output: `eval/ground_truth.csv` (consensus labels)
91
+ ```csv
92
+ query_id,doc_id,relevance
93
+ q01,d12345,2
94
+ ...
95
+ ```
96
+
97
+ ### 3. Run Evaluation (Week 4)
98
+
99
+ ```bash
100
+ cd backend
101
+ python -m app.evaluation.runner \
102
+ --queries ../eval/queries.json \
103
+ --ground-truth ../eval/ground_truth.csv \
104
+ --indexes-dir ../data/indexes \
105
+ --output ../eval/results.csv
106
+ ```
107
+
108
+ Output:
109
+ - `eval/results.csv`: per-model per-query metrics
110
+ - Console: aggregate MAP / P@5 / P@10 / NDCG@10 / MRR per model + pairwise
111
+ Wilcoxon significance
112
+
113
+ ### 4. Generate Report (Notebook)
114
+
115
+ File: `notebooks/04_evaluation.ipynb` (Anggota E buat).
116
+
117
+ Wajib content untuk laporan:
118
+ - Table aggregate metrics (4 model x 5 metrics)
119
+ - Per-query analysis: queries mana yang hard untuk each model + kenapa
120
+ - Statistical significance table (pairwise Wilcoxon p-values)
121
+ - Visualizations:
122
+ - Bar chart MAP per model
123
+ - Scatter plot: query difficulty (avg performance) vs query length/complexity
124
+ - Heatmap: model x query (cell = MAP per query)
125
+ - Interpretation: kenapa Model X menang/kalah di query Y
126
+
127
+ ## Files in Module
128
+
129
+ | File | Purpose |
130
+ |------|---------|
131
+ | `metrics.py` | P@K, MAP, NDCG@K, MRR |
132
+ | `kappa.py` | Cohen's & Weighted Kappa untuk inter-annotator |
133
+ | `statistical.py` | Paired t-test, Wilcoxon signed-rank |
134
+ | `runner.py` | CLI: run all-models x all-queries, output CSV |
135
+ | `README.md` | this file |
136
+
137
+ ## What Tim Anggota E (Frontend + Eval Lead) WAJIB Do
138
+
139
+ ### Priority 1 — Coordinate annotation (Week 3 start)
140
+
141
+ - Kick off 3-annotator process minggu pertama Week 3
142
+ - Use BM25 top-30 sebagai pool kandidat (cepat, lexical baseline)
143
+ - Provide annotators dengan: query text, listing snippet (judul + 200 char
144
+ deskripsi), full deskripsi link
145
+ - Calibration session sebelum mulai (1 jam, walk through 5 sample queries)
146
+
147
+ ### Priority 2 — Build evaluation notebook (Week 4)
148
+
149
+ File: `notebooks/04_evaluation.ipynb`. Template structure di README di atas.
150
+
151
+ ### Priority 3 — Build /eval dashboard (optional, kalau ada budget waktu)
152
+
153
+ FastAPI route `/eval/summary` yang return aggregate metrics + per-query
154
+ breakdown sebagai JSON. Frontend tab di React app untuk dosen lihat eval
155
+ langsung di web (bonus poin System 25%).
156
+
157
+ ## Anti-Patterns
158
+
159
+ - [BAD] Compute MAP tanpa Kappa check dulu — kalau Kappa < 0.7, ground truth
160
+ unreliable, MAP angka gak meaningful
161
+ - [BAD] Annotator labels berbeda interpretation "relevant" — selalu kalibrasi
162
+ dulu
163
+ - [BAD] Cuma 1 annotator — gak ada agreement check, gak reproducible
164
+ - [BAD] Ground truth dari AI/dosen tanpa human annotator independen
165
+ - [BAD] Report MAP without significance test — perbedaan 0.05 bisa noise
166
+
167
+ ## Testing
168
+
169
+ ```bash
170
+ cd backend
171
+ pytest tests/test_evaluation.py -v
172
+ ```
backend/app/evaluation/__init__.py ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Evaluation metrics + statistical tests untuk IR experiment.
2
+
3
+ Public API:
4
+ from app.evaluation import (
5
+ precision_at_k, average_precision, mean_average_precision,
6
+ ndcg_at_k, reciprocal_rank, mean_reciprocal_rank,
7
+ cohen_kappa, weighted_kappa,
8
+ paired_ttest, wilcoxon_signed_rank,
9
+ )
10
+ """
11
+
12
+ from .kappa import cohen_kappa, weighted_kappa
13
+ from .metrics import (
14
+ average_precision,
15
+ mean_average_precision,
16
+ mean_reciprocal_rank,
17
+ ndcg_at_k,
18
+ precision_at_k,
19
+ reciprocal_rank,
20
+ )
21
+ from .statistical import paired_ttest, wilcoxon_signed_rank
22
+
23
+ __all__ = [
24
+ # Metrics
25
+ "precision_at_k",
26
+ "average_precision",
27
+ "mean_average_precision",
28
+ "ndcg_at_k",
29
+ "reciprocal_rank",
30
+ "mean_reciprocal_rank",
31
+ # Inter-annotator agreement
32
+ "cohen_kappa",
33
+ "weighted_kappa",
34
+ # Statistical significance
35
+ "paired_ttest",
36
+ "wilcoxon_signed_rank",
37
+ ]
backend/app/evaluation/kappa.py ADDED
@@ -0,0 +1,135 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Cohen's Kappa untuk inter-annotator agreement.
2
+
3
+ Course requirement (rubric Evaluation 10%): target Kappa >= 0.7 across
4
+ all annotator pairs. Kalau di bawah 0.7, dokumentasikan + lakuin
5
+ consensus resolution sebelum compute metric IR.
6
+
7
+ Interpretation (Landis & Koch 1977):
8
+ - < 0.20: slight agreement
9
+ - 0.21 - 0.40: fair
10
+ - 0.41 - 0.60: moderate
11
+ - 0.61 - 0.80: substantial
12
+ - 0.81 - 1.00: almost perfect
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import numpy as np
18
+
19
+
20
+ def cohen_kappa(
21
+ annotator_a: list[int],
22
+ annotator_b: list[int],
23
+ labels: list[int] | None = None,
24
+ ) -> float:
25
+ """Cohen's Kappa untuk 2 annotator pada same items.
26
+
27
+ Args:
28
+ annotator_a: labels dari annotator A (urutan harus match B)
29
+ annotator_b: labels dari annotator B
30
+ labels: list of possible label values (default: {0, 1, 2})
31
+
32
+ Returns:
33
+ Kappa value in [-1, 1]. 1 = perfect agreement, 0 = chance level.
34
+ """
35
+ if len(annotator_a) != len(annotator_b):
36
+ raise ValueError(
37
+ f"Length mismatch: A={len(annotator_a)}, B={len(annotator_b)}"
38
+ )
39
+ n = len(annotator_a)
40
+ if n == 0:
41
+ return 0.0
42
+
43
+ if labels is None:
44
+ labels = sorted(set(annotator_a) | set(annotator_b))
45
+
46
+ # Confusion matrix
47
+ label_to_idx = {label: i for i, label in enumerate(labels)}
48
+ k = len(labels)
49
+ matrix = np.zeros((k, k))
50
+ for a, b in zip(annotator_a, annotator_b):
51
+ matrix[label_to_idx[a]][label_to_idx[b]] += 1
52
+
53
+ # Observed agreement (Po)
54
+ po = np.trace(matrix) / n
55
+
56
+ # Expected agreement by chance (Pe)
57
+ row_marginals = matrix.sum(axis=1) / n
58
+ col_marginals = matrix.sum(axis=0) / n
59
+ pe = float(np.sum(row_marginals * col_marginals))
60
+
61
+ if pe >= 1.0:
62
+ return 1.0 if po >= 1.0 else 0.0
63
+
64
+ return float((po - pe) / (1 - pe))
65
+
66
+
67
+ def weighted_kappa(
68
+ annotator_a: list[int],
69
+ annotator_b: list[int],
70
+ labels: list[int] | None = None,
71
+ weight_type: str = "linear",
72
+ ) -> float:
73
+ """Weighted Cohen's Kappa untuk ordinal labels (0/1/2 relevance).
74
+
75
+ Lebih appropriate dari plain kappa untuk graded relevance: disagreement
76
+ 1 vs 2 di-penalize lebih ringan dari 0 vs 2.
77
+
78
+ Args:
79
+ weight_type: 'linear' atau 'quadratic' (lebih tegas)
80
+ """
81
+ if len(annotator_a) != len(annotator_b):
82
+ raise ValueError("Length mismatch")
83
+ n = len(annotator_a)
84
+ if n == 0:
85
+ return 0.0
86
+
87
+ if labels is None:
88
+ labels = sorted(set(annotator_a) | set(annotator_b))
89
+ k = len(labels)
90
+ label_to_idx = {label: i for i, label in enumerate(labels)}
91
+
92
+ # Confusion matrix
93
+ matrix = np.zeros((k, k))
94
+ for a, b in zip(annotator_a, annotator_b):
95
+ matrix[label_to_idx[a]][label_to_idx[b]] += 1
96
+
97
+ # Weight matrix
98
+ weights = np.zeros((k, k))
99
+ for i in range(k):
100
+ for j in range(k):
101
+ diff = abs(i - j)
102
+ if weight_type == "linear":
103
+ weights[i][j] = diff / (k - 1) if k > 1 else 0
104
+ elif weight_type == "quadratic":
105
+ weights[i][j] = (diff / (k - 1)) ** 2 if k > 1 else 0
106
+ else:
107
+ raise ValueError(f"Unknown weight_type: {weight_type}")
108
+
109
+ # Expected matrix (outer product of marginals)
110
+ row_sum = matrix.sum(axis=1)
111
+ col_sum = matrix.sum(axis=0)
112
+ expected = np.outer(row_sum, col_sum) / n
113
+
114
+ weighted_observed = np.sum(weights * matrix)
115
+ weighted_expected = np.sum(weights * expected)
116
+
117
+ if weighted_expected == 0:
118
+ return 1.0 if weighted_observed == 0 else 0.0
119
+ return float(1 - weighted_observed / weighted_expected)
120
+
121
+
122
+ def interpret_kappa(kappa: float) -> str:
123
+ """Kategorisasi kappa per Landis & Koch (1977)."""
124
+ if kappa < 0.0:
125
+ return "less than chance"
126
+ elif kappa < 0.20:
127
+ return "slight"
128
+ elif kappa < 0.40:
129
+ return "fair"
130
+ elif kappa < 0.60:
131
+ return "moderate"
132
+ elif kappa < 0.80:
133
+ return "substantial"
134
+ else:
135
+ return "almost perfect"
backend/app/evaluation/metrics.py ADDED
@@ -0,0 +1,197 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """IR evaluation metrics: P@K, MAP, NDCG@K, MRR.
2
+
3
+ Convention:
4
+ - `predicted` = ordered list of doc_ids dari index query (rank 1 = first)
5
+ - `relevant` = set of doc_ids yang TRULY relevant (dari ground truth)
6
+ - `relevance_scores` = dict {doc_id: int} untuk graded relevance (0/1/2)
7
+
8
+ Semua metric in [0, 1] dengan 1 = perfect.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import math
14
+ from collections.abc import Iterable
15
+
16
+
17
+ # =============================================================================
18
+ # Precision @ K
19
+ # =============================================================================
20
+ def precision_at_k(
21
+ predicted: list[str], relevant: Iterable[str], k: int
22
+ ) -> float:
23
+ """P@K = proportion of top-K predicted yang relevant.
24
+
25
+ Example:
26
+ predicted = ['a', 'b', 'c', 'd', 'e']
27
+ relevant = {'a', 'c', 'x'}
28
+ k = 5 -> 2/5 = 0.4
29
+ """
30
+ if k <= 0:
31
+ return 0.0
32
+ relevant_set = set(relevant)
33
+ top_k = predicted[:k]
34
+ if not top_k:
35
+ return 0.0
36
+ hits = sum(1 for doc_id in top_k if doc_id in relevant_set)
37
+ return hits / k
38
+
39
+
40
+ # =============================================================================
41
+ # Average Precision (AP) per query
42
+ # =============================================================================
43
+ def average_precision(
44
+ predicted: list[str], relevant: Iterable[str]
45
+ ) -> float:
46
+ """Average Precision per query.
47
+
48
+ AP = mean of P@k untuk setiap relevant doc yang muncul di predicted.
49
+
50
+ Example:
51
+ predicted = ['a', 'b', 'c', 'd']
52
+ relevant = {'a', 'c'}
53
+ -> P@1 = 1/1 (a hit), P@3 = 2/3 (c hit)
54
+ -> AP = (1.0 + 0.667) / 2 = 0.833
55
+ """
56
+ relevant_set = set(relevant)
57
+ if not relevant_set:
58
+ return 0.0
59
+
60
+ precisions: list[float] = []
61
+ hits = 0
62
+ for i, doc_id in enumerate(predicted, start=1):
63
+ if doc_id in relevant_set:
64
+ hits += 1
65
+ precisions.append(hits / i)
66
+
67
+ if not precisions:
68
+ return 0.0
69
+ # Divide by number of relevant docs (kalau gak semua relevant ada di predicted,
70
+ # bagi tetap pake count of relevant — penalti untuk missed relevant)
71
+ return sum(precisions) / len(relevant_set)
72
+
73
+
74
+ def mean_average_precision(
75
+ predicted_per_query: dict[str, list[str]],
76
+ relevant_per_query: dict[str, Iterable[str]],
77
+ ) -> float:
78
+ """MAP = mean of AP across queries."""
79
+ if not predicted_per_query:
80
+ return 0.0
81
+ aps: list[float] = []
82
+ for query_id, predicted in predicted_per_query.items():
83
+ relevant = relevant_per_query.get(query_id, [])
84
+ aps.append(average_precision(predicted, relevant))
85
+ return sum(aps) / len(aps)
86
+
87
+
88
+ # =============================================================================
89
+ # NDCG @ K (Normalized Discounted Cumulative Gain)
90
+ # =============================================================================
91
+ def _dcg_at_k(gains: list[float], k: int) -> float:
92
+ """DCG@K = sum gain_i / log2(i+1), 1-indexed rank."""
93
+ return sum(g / math.log2(i + 1) for i, g in enumerate(gains[:k], start=1))
94
+
95
+
96
+ def ndcg_at_k(
97
+ predicted: list[str],
98
+ relevance_scores: dict[str, int],
99
+ k: int = 10,
100
+ ) -> float:
101
+ """NDCG@K dengan graded relevance.
102
+
103
+ Args:
104
+ predicted: ordered doc_ids dari IR model
105
+ relevance_scores: {doc_id: int} -- typical 0/1/2 atau 0-3 scale
106
+ k: cutoff rank
107
+
108
+ Score in [0, 1]. Penalize relevant docs di posisi rendah.
109
+ """
110
+ if k <= 0 or not predicted:
111
+ return 0.0
112
+
113
+ # Actual DCG: pakai relevance_scores
114
+ gains = [relevance_scores.get(doc_id, 0) for doc_id in predicted[:k]]
115
+ dcg = _dcg_at_k(gains, k)
116
+
117
+ # Ideal DCG: sort all relevant docs by score desc
118
+ ideal_gains = sorted(relevance_scores.values(), reverse=True)
119
+ idcg = _dcg_at_k(ideal_gains, k)
120
+
121
+ if idcg == 0:
122
+ return 0.0
123
+ return dcg / idcg
124
+
125
+
126
+ # =============================================================================
127
+ # Mean Reciprocal Rank (MRR)
128
+ # =============================================================================
129
+ def reciprocal_rank(
130
+ predicted: list[str], relevant: Iterable[str]
131
+ ) -> float:
132
+ """1 / rank dari relevant pertama di predicted. 0 kalau gak ada relevant."""
133
+ relevant_set = set(relevant)
134
+ for i, doc_id in enumerate(predicted, start=1):
135
+ if doc_id in relevant_set:
136
+ return 1.0 / i
137
+ return 0.0
138
+
139
+
140
+ def mean_reciprocal_rank(
141
+ predicted_per_query: dict[str, list[str]],
142
+ relevant_per_query: dict[str, Iterable[str]],
143
+ ) -> float:
144
+ """MRR = mean of reciprocal_rank across queries."""
145
+ if not predicted_per_query:
146
+ return 0.0
147
+ rrs: list[float] = []
148
+ for query_id, predicted in predicted_per_query.items():
149
+ relevant = relevant_per_query.get(query_id, [])
150
+ rrs.append(reciprocal_rank(predicted, relevant))
151
+ return sum(rrs) / len(rrs)
152
+
153
+
154
+ # =============================================================================
155
+ # Constraint Satisfaction @ K (lensa kedua: ukur kebutuhan user, bukan teks)
156
+ # =============================================================================
157
+ def constraint_satisfaction_at_k(
158
+ results: list[dict],
159
+ constraints: dict,
160
+ k: int = 5,
161
+ max_km: float = 3.0,
162
+ ) -> float:
163
+ """Rasio top-K hasil yang memenuhi SEMUA konstrain query yang ada.
164
+
165
+ Berbeda dari P@K/MAP (yang mengukur relevansi teks dari qrels), metric ini
166
+ mengukur hal yang dioptimalkan smart pipeline: gender benar + harga sesuai +
167
+ fasilitas ada + dalam radius dari anchor. Tidak butuh qrels.
168
+
169
+ Args:
170
+ results: list dict listing TERURUT, key: tipe, harga_per_bulan,
171
+ fasilitas (list[str]), lat, lng.
172
+ constraints: {gender, harga_max, fasilitas: list[str], anchor: (lat, lng)|None}.
173
+ k: cutoff. max_km: ambang jarak "dekat" dari anchor.
174
+ """
175
+ from app.search.gazetteer import haversine_km
176
+
177
+ topk = results[:k]
178
+ if not topk:
179
+ return 0.0
180
+
181
+ ok = 0
182
+ for r in topk:
183
+ good = True
184
+ if constraints.get("gender") and r.get("tipe") != constraints["gender"]:
185
+ good = False
186
+ if constraints.get("harga_max") and (r.get("harga_per_bulan") or 0) > constraints["harga_max"]:
187
+ good = False
188
+ have = [str(f).lower() for f in (r.get("fasilitas") or [])]
189
+ for kw in constraints.get("fasilitas", []):
190
+ if not any(kw in f for f in have):
191
+ good = False
192
+ anchor = constraints.get("anchor")
193
+ if anchor and r.get("lat") is not None and r.get("lng") is not None:
194
+ if haversine_km(float(r["lat"]), float(r["lng"]), anchor[0], anchor[1]) > max_km:
195
+ good = False
196
+ ok += int(good)
197
+ return ok / len(topk)
backend/app/evaluation/runner.py ADDED
@@ -0,0 +1,195 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """CLI runner untuk evaluation: run semua model x query, compute metrics.
2
+
3
+ Workflow:
4
+ # 1. Pastikan: corpus + indexes + ground truth siap
5
+ # - data/processed/corpus.json
6
+ # - data/indexes/{tfidf.pkl, bm25.pkl, indobert/}
7
+ # - eval/queries.json
8
+ # - eval/ground_truth.csv
9
+
10
+ # 2. Run evaluation
11
+ python -m app.evaluation.runner \\
12
+ --queries ../eval/queries.json \\
13
+ --ground-truth ../eval/ground_truth.csv \\
14
+ --indexes-dir ../data/indexes \\
15
+ --output ../eval/results.csv
16
+
17
+ # 3. Output:
18
+ # eval/results.csv -- per-model per-query metrics
19
+ # Console summary: aggregate MAP, P@5/10, NDCG@10, MRR per model
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import argparse
25
+ import csv
26
+ import json
27
+ import sys
28
+ from pathlib import Path
29
+ from typing import Any
30
+
31
+ from loguru import logger
32
+
33
+ from .kappa import interpret_kappa
34
+ from .metrics import (
35
+ mean_average_precision,
36
+ mean_reciprocal_rank,
37
+ ndcg_at_k,
38
+ precision_at_k,
39
+ )
40
+ from .statistical import paired_ttest, wilcoxon_signed_rank
41
+
42
+
43
+ def load_queries(path: Path) -> list[dict[str, Any]]:
44
+ """Load eval/queries.json -> list of {id, query, context, ...}."""
45
+ with open(path, encoding="utf-8") as f:
46
+ data = json.load(f)
47
+ return data.get("queries", data) # support raw list atau {queries: [...]}
48
+
49
+
50
+ def load_ground_truth(path: Path) -> dict[str, dict[str, int]]:
51
+ """Load ground_truth.csv -> {query_id: {doc_id: consensus_relevance}}.
52
+
53
+ Expected columns: query_id, doc_id, relevance (consensus)
54
+ """
55
+ gt: dict[str, dict[str, int]] = {}
56
+ with open(path, encoding="utf-8") as f:
57
+ reader = csv.DictReader(f)
58
+ for row in reader:
59
+ qid = row["query_id"]
60
+ did = row["doc_id"]
61
+ rel = int(row["relevance"])
62
+ gt.setdefault(qid, {})[did] = rel
63
+ return gt
64
+
65
+
66
+ def relevant_set(rel_dict: dict[str, int], threshold: int = 1) -> set[str]:
67
+ """Convert graded relevance ke binary relevant set (rel >= threshold)."""
68
+ return {doc_id for doc_id, rel in rel_dict.items() if rel >= threshold}
69
+
70
+
71
+ def main() -> int:
72
+ parser = argparse.ArgumentParser(description="Run IR evaluation")
73
+ parser.add_argument("--queries", type=Path, required=True)
74
+ parser.add_argument("--ground-truth", type=Path, required=True)
75
+ parser.add_argument("--indexes-dir", type=Path, required=True)
76
+ parser.add_argument("--output", type=Path, required=True)
77
+ parser.add_argument("--top-k", type=int, default=10)
78
+ parser.add_argument(
79
+ "--models",
80
+ nargs="+",
81
+ default=["tfidf", "bm25", "indobert", "hybrid"],
82
+ )
83
+ args = parser.parse_args()
84
+
85
+ # Load eval data
86
+ queries = load_queries(args.queries)
87
+ ground_truth = load_ground_truth(args.ground_truth)
88
+ logger.info(
89
+ f"[eval] {len(queries)} queries, "
90
+ f"{sum(len(v) for v in ground_truth.values())} GT annotations"
91
+ )
92
+
93
+ # Load preprocessing pipeline (untuk preprocess queries)
94
+ from app.preprocessing import PreprocessingPipeline
95
+
96
+ pipeline = PreprocessingPipeline()
97
+
98
+ # Load indexes
99
+ from app.indexing.hybrid import HybridIndex
100
+ from app.indexing.loader import load_all_indexes
101
+
102
+ indexes = load_all_indexes(args.indexes_dir)
103
+ if "bm25" in indexes and "indobert" in indexes and "hybrid" in args.models:
104
+ indexes["hybrid"] = HybridIndex(
105
+ indexes["bm25"],
106
+ indexes["indobert"],
107
+ query_preprocessor=lambda q: pipeline.process(q).processed,
108
+ )
109
+
110
+ # Run all model x all queries
111
+ # Structure: results[model][query_id] = {p5, p10, ap, ndcg10, rr}
112
+ results: dict[str, dict[str, dict[str, float]]] = {}
113
+
114
+ args.output.parent.mkdir(parents=True, exist_ok=True)
115
+ with open(args.output, "w", encoding="utf-8", newline="") as out_f:
116
+ writer = csv.writer(out_f)
117
+ writer.writerow(
118
+ ["model", "query_id", "query", "p_at_5", "p_at_10", "ap",
119
+ "ndcg_at_10", "rr"]
120
+ )
121
+
122
+ for model_name in args.models:
123
+ if model_name not in indexes:
124
+ logger.warning(f"[skip] index {model_name} gak ditemukan")
125
+ continue
126
+ index = indexes[model_name]
127
+ results[model_name] = {}
128
+
129
+ for q in queries:
130
+ qid = q["id"]
131
+ q_text = q["query"]
132
+ processed = pipeline.process(q_text).processed
133
+ # BM25/TF-IDF: lexical, butuh ter-stem. IndoBERT/Hybrid:
134
+ # semantic, perlu natural language (raw query).
135
+ if model_name in ("indobert", "hybrid"):
136
+ search_q = q_text
137
+ else:
138
+ search_q = processed
139
+ hits = index.query(search_q, top_k=args.top_k)
140
+ predicted = [h.doc_id for h in hits]
141
+
142
+ rel_dict = ground_truth.get(qid, {})
143
+ rel_set = relevant_set(rel_dict, threshold=1)
144
+
145
+ p5 = precision_at_k(predicted, rel_set, 5)
146
+ p10 = precision_at_k(predicted, rel_set, 10)
147
+ from .metrics import average_precision, reciprocal_rank
148
+
149
+ ap = average_precision(predicted, rel_set)
150
+ nd10 = ndcg_at_k(predicted, rel_dict, 10)
151
+ rr = reciprocal_rank(predicted, rel_set)
152
+
153
+ results[model_name][qid] = {
154
+ "p5": p5, "p10": p10, "ap": ap, "ndcg10": nd10, "rr": rr,
155
+ }
156
+ writer.writerow([model_name, qid, q_text, p5, p10, ap, nd10, rr])
157
+
158
+ logger.info(f"[done] model={model_name}")
159
+
160
+ # Console summary
161
+ logger.info("\n=== AGGREGATE METRICS ===")
162
+ for model_name, per_query in results.items():
163
+ n = len(per_query)
164
+ if n == 0:
165
+ continue
166
+ avg_p5 = sum(v["p5"] for v in per_query.values()) / n
167
+ avg_p10 = sum(v["p10"] for v in per_query.values()) / n
168
+ avg_ap = sum(v["ap"] for v in per_query.values()) / n
169
+ avg_ndcg = sum(v["ndcg10"] for v in per_query.values()) / n
170
+ avg_rr = sum(v["rr"] for v in per_query.values()) / n
171
+ logger.info(
172
+ f"{model_name:10s} P@5={avg_p5:.4f} P@10={avg_p10:.4f} "
173
+ f"MAP={avg_ap:.4f} NDCG@10={avg_ndcg:.4f} MRR={avg_rr:.4f}"
174
+ )
175
+
176
+ # Pairwise statistical significance (Wilcoxon)
177
+ model_names = [m for m in args.models if m in results]
178
+ if len(model_names) >= 2:
179
+ logger.info("\n=== PAIRWISE WILCOXON (MAP) ===")
180
+ for i, ma in enumerate(model_names):
181
+ for mb in model_names[i + 1 :]:
182
+ a_aps = [results[ma][qid]["ap"] for qid in results[ma]]
183
+ b_aps = [results[mb][qid]["ap"] for qid in results[mb]]
184
+ try:
185
+ test = wilcoxon_signed_rank(a_aps, b_aps)
186
+ logger.info(f"{ma} vs {mb}: {test}")
187
+ except Exception as e:
188
+ logger.warning(f"{ma} vs {mb}: {e}")
189
+
190
+ logger.info(f"\n[done] per-query metrics saved to {args.output}")
191
+ return 0
192
+
193
+
194
+ if __name__ == "__main__":
195
+ sys.exit(main())
backend/app/evaluation/statistical.py ADDED
@@ -0,0 +1,171 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Statistical significance tests untuk compare IR models.
2
+
3
+ Untuk per-query metric (e.g., AP per query untuk Model A vs Model B):
4
+ - **paired_ttest**: kalau distribusi roughly normal
5
+ - **wilcoxon_signed_rank**: non-parametric, lebih robust untuk metric bounded [0,1]
6
+
7
+ Convention: H0 = "Model A = Model B", alpha = 0.05.
8
+ Kalau p-value < 0.05, reject H0 => significant difference.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from dataclasses import dataclass
14
+ from typing import Literal
15
+
16
+ from scipy import stats
17
+
18
+
19
+ @dataclass
20
+ class StatTestResult:
21
+ """Hasil statistical test."""
22
+ test_name: str
23
+ statistic: float
24
+ p_value: float
25
+ n: int # jumlah pair
26
+ significant: bool # p < alpha
27
+ alpha: float = 0.05
28
+ effect_size: float | None = None # cohen's d untuk t-test
29
+
30
+ def __str__(self) -> str:
31
+ sig = "SIGNIFICANT" if self.significant else "not significant"
32
+ return (
33
+ f"{self.test_name}: stat={self.statistic:.4f}, "
34
+ f"p={self.p_value:.4f}, n={self.n}, {sig} (alpha={self.alpha})"
35
+ )
36
+
37
+
38
+ def paired_ttest(
39
+ model_a_scores: list[float],
40
+ model_b_scores: list[float],
41
+ alpha: float = 0.05,
42
+ alternative: Literal["two-sided", "less", "greater"] = "two-sided",
43
+ ) -> StatTestResult:
44
+ """Paired t-test untuk per-query metric.
45
+
46
+ Args:
47
+ model_a_scores: metric per query untuk Model A (e.g., AP@10)
48
+ model_b_scores: same query order, Model B
49
+ alpha: significance threshold (default 0.05)
50
+ alternative: 'two-sided' / 'less' / 'greater' (one-sided)
51
+
52
+ Returns StatTestResult dengan Cohen's d effect size.
53
+ """
54
+ if len(model_a_scores) != len(model_b_scores):
55
+ raise ValueError(
56
+ f"Length mismatch: A={len(model_a_scores)}, B={len(model_b_scores)}"
57
+ )
58
+ if len(model_a_scores) < 2:
59
+ raise ValueError("Butuh minimal 2 query untuk t-test")
60
+
61
+ result = stats.ttest_rel(
62
+ model_a_scores, model_b_scores, alternative=alternative
63
+ )
64
+
65
+ # Cohen's d effect size (paired)
66
+ import numpy as np
67
+
68
+ diffs = np.array(model_a_scores) - np.array(model_b_scores)
69
+ effect_size = float(np.mean(diffs) / np.std(diffs, ddof=1)) if np.std(diffs, ddof=1) > 0 else 0.0
70
+
71
+ return StatTestResult(
72
+ test_name="paired_ttest",
73
+ statistic=float(result.statistic),
74
+ p_value=float(result.pvalue),
75
+ n=len(model_a_scores),
76
+ significant=result.pvalue < alpha,
77
+ alpha=alpha,
78
+ effect_size=effect_size,
79
+ )
80
+
81
+
82
+ @dataclass
83
+ class HolmEntry:
84
+ """Hasil koreksi Holm-Bonferroni untuk satu uji dalam keluarga uji."""
85
+ label: str
86
+ p_value: float
87
+ p_adjusted: float # Holm step-down adjusted p-value (monotone)
88
+ threshold: float # ambang alpha/(m - rank) untuk uji ini
89
+ significant: bool # p_adjusted < alpha
90
+
91
+
92
+ def holm_bonferroni(
93
+ tests: list[tuple[str, float]],
94
+ alpha: float = 0.05,
95
+ ) -> list[HolmEntry]:
96
+ """Koreksi multiple comparison Holm-Bonferroni (step-down).
97
+
98
+ Menjalankan m uji sekaligus pada alpha=0.05 menggelembungkan peluang
99
+ false positive (m=6 -> ~26%). Holm: urutkan p ascending, bandingkan
100
+ p_i dengan alpha/(m-i); berhenti di kegagalan pertama, sisanya tidak
101
+ signifikan. Lebih powerful dari Bonferroni murni, tetap kontrol FWER.
102
+
103
+ Args:
104
+ tests: list (label, p_value), urutan bebas.
105
+ Returns:
106
+ list HolmEntry dengan urutan input dipertahankan.
107
+ """
108
+ m = len(tests)
109
+ if m == 0:
110
+ return []
111
+ order = sorted(range(m), key=lambda i: tests[i][1])
112
+
113
+ # Adjusted p: max kumulatif dari (m - rank) * p, di-cap 1.0 (monotone)
114
+ adjusted = [0.0] * m
115
+ running_max = 0.0
116
+ rejecting = True
117
+ thresholds = [0.0] * m
118
+ significant = [False] * m
119
+ for rank, idx in enumerate(order):
120
+ p = tests[idx][1]
121
+ adj = min(1.0, (m - rank) * p)
122
+ running_max = max(running_max, adj)
123
+ adjusted[idx] = running_max
124
+ thresholds[idx] = alpha / (m - rank)
125
+ # step-down: begitu satu gagal, semua setelahnya gagal
126
+ if rejecting and p <= thresholds[idx]:
127
+ significant[idx] = True
128
+ else:
129
+ rejecting = False
130
+
131
+ return [
132
+ HolmEntry(
133
+ label=tests[i][0],
134
+ p_value=tests[i][1],
135
+ p_adjusted=adjusted[i],
136
+ threshold=thresholds[i],
137
+ significant=significant[i],
138
+ )
139
+ for i in range(m)
140
+ ]
141
+
142
+
143
+ def wilcoxon_signed_rank(
144
+ model_a_scores: list[float],
145
+ model_b_scores: list[float],
146
+ alpha: float = 0.05,
147
+ alternative: Literal["two-sided", "less", "greater"] = "two-sided",
148
+ ) -> StatTestResult:
149
+ """Wilcoxon signed-rank test — non-parametric paired comparison.
150
+
151
+ Lebih robust untuk metric bounded [0, 1] yang sering tidak normal.
152
+ Direkomendasikan untuk IR metric comparison.
153
+ """
154
+ if len(model_a_scores) != len(model_b_scores):
155
+ raise ValueError("Length mismatch")
156
+ if len(model_a_scores) < 5:
157
+ raise ValueError("Butuh minimal 5 query untuk Wilcoxon (statistical power)")
158
+
159
+ result = stats.wilcoxon(
160
+ model_a_scores, model_b_scores, alternative=alternative
161
+ )
162
+
163
+ return StatTestResult(
164
+ test_name="wilcoxon_signed_rank",
165
+ statistic=float(result.statistic),
166
+ p_value=float(result.pvalue),
167
+ n=len(model_a_scores),
168
+ significant=result.pvalue < alpha,
169
+ alpha=alpha,
170
+ effect_size=None, # Wilcoxon gak punya standard effect size
171
+ )
backend/app/indexing/README.md ADDED
@@ -0,0 +1,189 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Indexing Module
2
+
3
+ Tiga paradigma IR + optional hybrid:
4
+
5
+ | Index | Library | Type | Strength | Weakness |
6
+ |-------|---------|------|----------|----------|
7
+ | `TFIDFIndex` | scikit-learn `TfidfVectorizer` + cosine | Lexical | Fast, interpretable | Synonym/paraphrase miss |
8
+ | `BM25Index` | `rank_bm25` `BM25Okapi` | Lexical (probabilistic) | Term saturation | Still lexical |
9
+ | `IndoBERTIndex` | `sentence-transformers` + FAISS | Neural / semantic | Semantic, paraphrase-aware | Heavy, slower |
10
+ | `HybridIndex` | BM25 candidates -> IndoBERT rerank | Composite | Best of both | Latency tertinggi |
11
+
12
+ ## Workflow
13
+
14
+ ```
15
+ data/raw/mamikos.jsonl (output Anggota A scraper)
16
+ -> preprocessing pipeline (Anggota B)
17
+ -> data/processed/corpus.json (input ke build.py)
18
+ -> python -m app.indexing.build (Anggota C + D)
19
+ -> data/indexes/
20
+ tfidf.pkl
21
+ bm25.pkl
22
+ indobert/
23
+ embeddings.npy
24
+ faiss.index
25
+ meta.json
26
+ ```
27
+
28
+ ## Quick Start
29
+
30
+ ```bash
31
+ cd backend
32
+ .venv\Scripts\activate # Windows
33
+
34
+ # 1. Pastikan corpus.json sudah ada
35
+ ls ../data/processed/corpus.json
36
+
37
+ # 2. Build semua indexes
38
+ python -m app.indexing.build \
39
+ --corpus ../data/processed/corpus.json \
40
+ --output-dir ../data/indexes
41
+
42
+ # 3. Smoke test load (di Python REPL atau notebook)
43
+ python -c "
44
+ from pathlib import Path
45
+ from app.indexing.loader import load_all_indexes
46
+ indexes = load_all_indexes(Path('../data/indexes'))
47
+ hits = indexes['bm25'].query('kos putra dekat unila', top_k=5)
48
+ for h in hits:
49
+ print(h)
50
+ "
51
+ ```
52
+
53
+ ## Corpus Format
54
+
55
+ `data/processed/corpus.json` = list of objects:
56
+
57
+ ```json
58
+ [
59
+ {
60
+ "id": "kos-abc-123",
61
+ "text": "kos putra eksklusif gedong meneng air conditioner kamar mandi dalam ...",
62
+ "raw_text": "Kos Putra Exclusive Gedong Meneng dengan AC dan KMD ...",
63
+ "metadata": {
64
+ "judul": "Kos Putra Exclusive Gedong Meneng",
65
+ "harga_per_bulan": 850000,
66
+ "tipe": "putra",
67
+ "fasilitas": ["ac", "wifi", "kamar mandi dalam"],
68
+ "alamat": "Jl. Sumantri Brojonegoro No. 1",
69
+ "kecamatan": "Rajabasa"
70
+ }
71
+ },
72
+ ...
73
+ ]
74
+ ```
75
+
76
+ - `text` = output preprocessing pipeline (lowercased, stemmed, stopword-free)
77
+ - `raw_text` = original deskripsi untuk display di UI
78
+ - `metadata` = field tambahan dari schema scraper
79
+
80
+ ## What's Done (oleh mentor — scaffold)
81
+
82
+ - [x] `base.py` — `Document`, `SearchHit`, `IndexBase` abstract
83
+ - [x] `tfidf.py` — `TFIDFIndex` dengan default ngram=(1,2), min_df=2,
84
+ max_features=10000, sublinear_tf
85
+ - [x] `bm25.py` — `BM25Index` dengan k1=1.5, b=0.75 (rank_bm25)
86
+ - [x] `indobert.py` — `IndoBERTIndex` dengan MiniLM multilingual default,
87
+ FAISS IndexFlatIP, lazy model load, `score_docs()` untuk hybrid rerun
88
+ - [x] `hybrid.py` — `HybridIndex` (BM25 top-50 -> IndoBERT rerank, min-max
89
+ normalize + weighted combination, configurable alpha)
90
+ - [x] `build.py` — CLI builder untuk semua indexes
91
+ - [x] `loader.py` — FastAPI lifespan helper
92
+ - [x] `tests/test_indexing.py` — unit test dengan small fixture corpus
93
+
94
+ ## What Tim Anggota C (IR Baseline) WAJIB Do
95
+
96
+ ### Priority 1 — Tune TF-IDF + BM25 hyperparameters
97
+
98
+ File: `notebooks/03_model_comparison.ipynb`.
99
+
100
+ Experiment:
101
+ - TF-IDF: `ngram_range` in [(1,1), (1,2), (1,3)], `min_df` in [1, 2, 5],
102
+ `max_features` in [5000, 10000, 20000, None]
103
+ - BM25: `k1` in [1.0, 1.2, 1.5, 2.0], `b` in [0.5, 0.75, 1.0]
104
+
105
+ Plot heatmap MAP per kombinasi. Pilih best, dokumentasikan kenapa di laporan.
106
+
107
+ ### Priority 2 — Edge case query testing
108
+
109
+ Setelah index ready, test queries:
110
+ - 1-word query: "kos" — apakah TF-IDF/BM25 robust?
111
+ - Stopword-heavy query: "yang ada di dekat kampus" — apakah preprocessing
112
+ handle dengan baik?
113
+ - Out-of-vocabulary: query dengan term yang gak muncul di corpus
114
+
115
+ ## What Tim Anggota D (IR Neural) WAJIB Do
116
+
117
+ ### Priority 1 — Compare embedding models
118
+
119
+ Default: `paraphrase-multilingual-MiniLM-L12-v2` (~118MB).
120
+ Alternative: `indobenchmark/indobert-base-p2` (~440MB, lebih bagus tapi heavy).
121
+
122
+ Test both di sample queries, compare MAP. Trade-off: model size vs accuracy
123
+ vs Render free tier RAM (512MB).
124
+
125
+ ### Priority 2 — Tune Hybrid alpha
126
+
127
+ File: `hybrid.py` di constructor `alpha` parameter.
128
+
129
+ Experiment alpha in [0.0, 0.3, 0.5, 0.7] di ground truth queries. Pure rerank
130
+ (alpha=0) sering kompetitif untuk Indonesian.
131
+
132
+ ### Priority 3 — Pooling strategy comparison
133
+
134
+ `sentence-transformers` default mean-pool. Bisa explore:
135
+ - CLS token pooling
136
+ - Max pooling
137
+ - Concat (CLS + mean)
138
+
139
+ Pakai `model.encode(texts, output_value="token_embeddings")` untuk custom pool.
140
+
141
+ ## Anti-Patterns
142
+
143
+ - [BAD] `FAISS IndexIVFFlat` untuk corpus <=5000 docs — overhead lebih besar
144
+ dari benefit. Pakai `IndexFlatIP` (exhaustive) sampai 10K+ docs.
145
+ - [BAD] Re-encode docs di tiap hybrid query — slow. Pakai `score_docs()`
146
+ yang reuse embeddings.
147
+ - [BAD] Pickle FAISS index langsung — fragile across versions. Pakai
148
+ `faiss.write_index()` + `faiss.read_index()`.
149
+ - [BAD] Cache embeddings di memory tanpa save — restart loss. Selalu save
150
+ ke disk.
151
+
152
+ ## Performance Expectations
153
+
154
+ Corpus 3000 listings @ 384-dim embeddings:
155
+
156
+ | Operation | Time |
157
+ |-----------|------|
158
+ | TF-IDF build | 1-2s |
159
+ | BM25 build | <1s |
160
+ | IndoBERT encode all 3K docs (MiniLM, CPU) | 30-60s |
161
+ | IndoBERT encode all 3K docs (IndoBERT base, CPU) | 2-3 min |
162
+ | TF-IDF query | <5ms |
163
+ | BM25 query | <10ms |
164
+ | IndoBERT query (encode + FAISS Flat) | 50-100ms (mostly encode) |
165
+ | Hybrid query | 80-150ms |
166
+
167
+ Acceptable untuk demo. Optimization optional kalau ada budget waktu di Week 4.
168
+
169
+ ## Testing
170
+
171
+ ```bash
172
+ cd backend
173
+ pytest tests/test_indexing.py -v
174
+ ```
175
+
176
+ Test cover:
177
+ - Each index: build, query, save, load roundtrip
178
+ - Edge cases: empty corpus, top_k > corpus_size
179
+ - Hybrid: combination math, alpha=0/0.5/1
180
+ - Performance smoke: 50-doc fixture builds < 5s
181
+
182
+ ## Save Format Reference
183
+
184
+ | Index | File | Format |
185
+ |-------|------|--------|
186
+ | TF-IDF | `tfidf.pkl` | pickle: `{vectorizer, doc_matrix (sparse), doc_ids, size}` |
187
+ | BM25 | `bm25.pkl` | pickle: `{k1, b, doc_ids, tokenized_corpus, size}` (BM25Okapi rebuilt on load) |
188
+ | IndoBERT | `indobert/` folder | `embeddings.npy` + `faiss.index` + `meta.json` |
189
+ | Hybrid | _none_ | Composition only — load sub-indexes, instantiate HybridIndex |
backend/app/indexing/__init__.py ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """IR index builders: TF-IDF, BM25, IndoBERT+FAISS, Hybrid.
2
+
3
+ Public API:
4
+ from app.indexing import (
5
+ Document, SearchHit, IndexBase,
6
+ TFIDFIndex, BM25Index, IndoBERTIndex, HybridIndex,
7
+ )
8
+
9
+ Workflow:
10
+ 1. Load corpus dari data/processed/corpus.json (after preprocessing)
11
+ 2. Build setiap index, save ke data/indexes/
12
+ 3. FastAPI lifespan load semua indexes saat startup
13
+ 4. Search route /search?model=X panggil index.query(q)
14
+ """
15
+
16
+ from .base import Document, IndexBase, SearchHit
17
+ from .bm25 import BM25Index
18
+ from .hybrid import HybridIndex
19
+ from .indobert import IndoBERTIndex
20
+ from .tfidf import TFIDFIndex
21
+
22
+ __all__ = [
23
+ "Document",
24
+ "SearchHit",
25
+ "IndexBase",
26
+ "TFIDFIndex",
27
+ "BM25Index",
28
+ "IndoBERTIndex",
29
+ "HybridIndex",
30
+ ]
backend/app/indexing/base.py ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Abstract IndexBase + shared dataclasses.
2
+
3
+ Setiap index subclass implement: build, query, save, load.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from abc import ABC, abstractmethod
9
+ from dataclasses import dataclass, field
10
+ from pathlib import Path
11
+ from typing import Any, Optional
12
+
13
+
14
+ @dataclass
15
+ class Document:
16
+ """Document representation untuk indexing.
17
+
18
+ `text` = processed text (output preprocessing pipeline), siap di-index.
19
+ `raw_text` = original deskripsi (untuk display di UI).
20
+ `metadata` = field tambahan (judul, harga, alamat, kecamatan, dll).
21
+ """
22
+ id: str
23
+ text: str
24
+ raw_text: Optional[str] = None
25
+ metadata: dict[str, Any] = field(default_factory=dict)
26
+
27
+
28
+ @dataclass
29
+ class SearchHit:
30
+ """Single result dari index query."""
31
+ doc_id: str
32
+ score: float
33
+ rank: int
34
+
35
+
36
+ class IndexBase(ABC):
37
+ """Abstract base class untuk semua IR index.
38
+
39
+ Convention: setiap subclass punya class attribute `name` (string identifier
40
+ untuk routing di /search?model=<name>).
41
+ """
42
+
43
+ name: str = "base" # override per subclass
44
+
45
+ @abstractmethod
46
+ def build(self, corpus: list[Document]) -> None:
47
+ """Build index dari corpus. Overwrite existing state."""
48
+
49
+ @abstractmethod
50
+ def query(self, q: str, top_k: int = 10) -> list[SearchHit]:
51
+ """Run query, return top-K hits sorted by score DESC."""
52
+
53
+ @abstractmethod
54
+ def save(self, path: Path) -> None:
55
+ """Serialize index ke disk."""
56
+
57
+ @classmethod
58
+ @abstractmethod
59
+ def load(cls, path: Path) -> "IndexBase":
60
+ """Deserialize index dari disk."""
61
+
62
+ def size(self) -> int:
63
+ """Jumlah dokumen yang sudah di-index."""
64
+ return getattr(self, "_size", 0)
backend/app/indexing/bm25.py ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """BM25 (Best Matching 25) index via rank_bm25.
2
+
3
+ Hyperparameter default sesuai standar literature:
4
+ - k1=1.5: term frequency saturation parameter
5
+ - b=0.75: length normalization parameter
6
+
7
+ Tim Anggota C: experiment k1 in [1.2, 2.0] dan b in [0.5, 1.0] untuk lihat
8
+ sensitivity. Biasanya BM25 beat TF-IDF tipis (~5-10% MAP) di Indonesian corpus.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import pickle
14
+ from pathlib import Path
15
+ from typing import Optional
16
+
17
+ import numpy as np
18
+ from rank_bm25 import BM25Okapi
19
+
20
+ from .base import Document, IndexBase, SearchHit
21
+
22
+
23
+ class BM25Index(IndexBase):
24
+ name = "bm25"
25
+
26
+ def __init__(self, k1: float = 1.5, b: float = 0.75):
27
+ self.k1 = k1
28
+ self.b = b
29
+ self.bm25: Optional[BM25Okapi] = None
30
+ self.doc_ids: list[str] = []
31
+ self.tokenized_corpus: list[list[str]] = []
32
+ self._size = 0
33
+
34
+ def build(self, corpus: list[Document]) -> None:
35
+ self.doc_ids = [doc.id for doc in corpus]
36
+ # rank_bm25 minta list of token lists
37
+ self.tokenized_corpus = [doc.text.split() for doc in corpus]
38
+ self.bm25 = BM25Okapi(self.tokenized_corpus, k1=self.k1, b=self.b)
39
+ self._size = len(corpus)
40
+
41
+ def query(self, q: str, top_k: int = 10) -> list[SearchHit]:
42
+ if self.bm25 is None or self._size == 0:
43
+ return []
44
+ q_tokens = q.split()
45
+ scores = self.bm25.get_scores(q_tokens)
46
+ top_k = min(top_k, self._size)
47
+ top_indices = np.argpartition(-scores, top_k - 1)[:top_k]
48
+ top_indices = top_indices[np.argsort(-scores[top_indices])]
49
+ return [
50
+ SearchHit(
51
+ doc_id=self.doc_ids[idx],
52
+ score=float(scores[idx]),
53
+ rank=rank + 1,
54
+ )
55
+ for rank, idx in enumerate(top_indices)
56
+ ]
57
+
58
+ def save(self, path: Path) -> None:
59
+ path.parent.mkdir(parents=True, exist_ok=True)
60
+ with open(path, "wb") as f:
61
+ pickle.dump(
62
+ {
63
+ "k1": self.k1,
64
+ "b": self.b,
65
+ "doc_ids": self.doc_ids,
66
+ "tokenized_corpus": self.tokenized_corpus,
67
+ "size": self._size,
68
+ },
69
+ f,
70
+ )
71
+
72
+ @classmethod
73
+ def load(cls, path: Path) -> "BM25Index":
74
+ with open(path, "rb") as f:
75
+ data = pickle.load(f)
76
+ instance = cls(k1=data["k1"], b=data["b"])
77
+ instance.doc_ids = data["doc_ids"]
78
+ instance.tokenized_corpus = data["tokenized_corpus"]
79
+ instance._size = data["size"]
80
+ # Rebuild BM25Okapi (gak bisa di-pickle reliably across versions)
81
+ instance.bm25 = BM25Okapi(
82
+ instance.tokenized_corpus, k1=instance.k1, b=instance.b
83
+ )
84
+ return instance
backend/app/indexing/build.py ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """CLI builder untuk semua IR indexes.
2
+
3
+ Workflow:
4
+ # 1. Setelah scraping selesai dan preprocessing dijalankan:
5
+ # data/processed/corpus.json berisi list of {id, text, raw_text, metadata}
6
+
7
+ # 2. Build semua index sekaligus
8
+ python -m app.indexing.build \\
9
+ --corpus ../data/processed/corpus.json \\
10
+ --output-dir ../data/indexes
11
+
12
+ # 3. Output struktur:
13
+ # data/indexes/tfidf.pkl
14
+ # data/indexes/bm25.pkl
15
+ # data/indexes/indobert/embeddings.npy
16
+ # data/indexes/indobert/faiss.index
17
+ # data/indexes/indobert/meta.json
18
+
19
+ Tiap index loadable independen di FastAPI lifespan startup (lihat loader.py).
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import argparse
25
+ import json
26
+ import sys
27
+ import time
28
+ from pathlib import Path
29
+
30
+ from loguru import logger
31
+
32
+ from .base import Document
33
+ from .bm25 import BM25Index
34
+ from .indobert import IndoBERTIndex
35
+ from .tfidf import TFIDFIndex
36
+
37
+
38
+ def load_corpus(path: Path) -> list[Document]:
39
+ """Load corpus JSON: expect list of {id, text, raw_text?, metadata?}."""
40
+ with open(path, encoding="utf-8") as f:
41
+ raw = json.load(f)
42
+ return [
43
+ Document(
44
+ id=item["id"],
45
+ text=item["text"],
46
+ raw_text=item.get("raw_text"),
47
+ metadata=item.get("metadata", {}),
48
+ )
49
+ for item in raw
50
+ ]
51
+
52
+
53
+ def main() -> int:
54
+ parser = argparse.ArgumentParser(description="Build IR indexes dari corpus")
55
+ parser.add_argument(
56
+ "--corpus", type=Path, required=True,
57
+ help="Path ke corpus.json (output preprocessing)",
58
+ )
59
+ parser.add_argument(
60
+ "--output-dir", type=Path, required=True,
61
+ help="Folder output indexes (e.g., ../data/indexes)",
62
+ )
63
+ parser.add_argument(
64
+ "--skip", nargs="*", default=[],
65
+ choices=["tfidf", "bm25", "indobert"],
66
+ help="Index yang di-skip (untuk debug)",
67
+ )
68
+ parser.add_argument(
69
+ "--indobert-model",
70
+ default="sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2",
71
+ help="Sentence-transformers model name (default: MiniLM lightweight)",
72
+ )
73
+ parser.add_argument(
74
+ "--tfidf-ngram", nargs=2, type=int, default=[1, 2],
75
+ help="TF-IDF ngram range (default: 1 2)",
76
+ )
77
+ parser.add_argument("--bm25-k1", type=float, default=1.5)
78
+ parser.add_argument("--bm25-b", type=float, default=0.75)
79
+ args = parser.parse_args()
80
+
81
+ logger.info(f"[load corpus] {args.corpus}")
82
+ corpus = load_corpus(args.corpus)
83
+ logger.info(f"[corpus] {len(corpus)} documents")
84
+
85
+ args.output_dir.mkdir(parents=True, exist_ok=True)
86
+
87
+ # ---- TF-IDF ----
88
+ if "tfidf" not in args.skip:
89
+ logger.info("[build TF-IDF]")
90
+ t0 = time.time()
91
+ tfidf = TFIDFIndex(ngram_range=tuple(args.tfidf_ngram))
92
+ tfidf.build(corpus)
93
+ tfidf.save(args.output_dir / "tfidf.pkl")
94
+ logger.info(f"[TF-IDF] saved ({time.time() - t0:.1f}s)")
95
+
96
+ # ---- BM25 ----
97
+ if "bm25" not in args.skip:
98
+ logger.info("[build BM25]")
99
+ t0 = time.time()
100
+ bm25 = BM25Index(k1=args.bm25_k1, b=args.bm25_b)
101
+ bm25.build(corpus)
102
+ bm25.save(args.output_dir / "bm25.pkl")
103
+ logger.info(f"[BM25] saved ({time.time() - t0:.1f}s)")
104
+
105
+ # ---- IndoBERT + FAISS ----
106
+ if "indobert" not in args.skip:
107
+ logger.info(f"[build IndoBERT] model={args.indobert_model}")
108
+ t0 = time.time()
109
+ indobert = IndoBERTIndex(model_name=args.indobert_model)
110
+ indobert.build(corpus)
111
+ indobert.save(args.output_dir / "indobert")
112
+ logger.info(f"[IndoBERT] saved ({time.time() - t0:.1f}s)")
113
+
114
+ logger.info(f"[done] all indexes saved ke {args.output_dir}")
115
+ return 0
116
+
117
+
118
+ if __name__ == "__main__":
119
+ sys.exit(main())
backend/app/indexing/hybrid.py ADDED
@@ -0,0 +1,127 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Hybrid retrieval: BM25 candidates -> IndoBERT rerank.
2
+
3
+ Strategy:
4
+ 1. BM25 fetch top-N candidates (default N=50). BM25 cepat dan lexical-aware,
5
+ bagus buat narrow down dari 1500-3000 docs ke 50.
6
+ 2. IndoBERT score 50 candidates dengan pre-computed embeddings (no re-encode).
7
+ 3. Combine score: `final = alpha * bm25_norm + (1 - alpha) * indobert_norm`,
8
+ atau pure rerank by IndoBERT (alpha=0).
9
+ 4. Return top-K (default K=10).
10
+
11
+ Tim Anggota D: experiment alpha in [0.0, 0.3, 0.5, 0.7] untuk lihat balance
12
+ yang optimal. Pure rerank (alpha=0) sering kompetitif untuk Indonesian
13
+ queries yang semantik.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from pathlib import Path
19
+ from typing import Callable, Optional
20
+
21
+ import numpy as np
22
+
23
+ from .base import Document, IndexBase, SearchHit
24
+ from .bm25 import BM25Index
25
+ from .indobert import IndoBERTIndex
26
+
27
+
28
+ class HybridIndex(IndexBase):
29
+ name = "hybrid"
30
+
31
+ def __init__(
32
+ self,
33
+ bm25: BM25Index,
34
+ indobert: IndoBERTIndex,
35
+ bm25_top_k: int = 50,
36
+ # alpha 0.9 dari grid search pool-restricted n=30 (eval/
37
+ # hybrid_alpha_grid_pool.csv): kurva flat 0.6-0.9, puncak 0.9
38
+ # (MAP 0.5909 vs BM25 murni 0.5836; selisih kecil, inconclusive).
39
+ # Default lama 0.3 menyeret hybrid jatuh di standard eval
40
+ # (MAP 0.188 vs 0.267 di alpha 0.9) karena GT lexical-pooled.
41
+ alpha: float = 0.9,
42
+ query_preprocessor: Optional[Callable[[str], str]] = None,
43
+ ):
44
+ """Args:
45
+ bm25: BM25Index yang sudah di-build
46
+ indobert: IndoBERTIndex yang sudah di-build
47
+ bm25_top_k: jumlah candidate dari BM25 untuk re-rank (default 50)
48
+ alpha: weight untuk BM25 score di final combination.
49
+ alpha=0 -> pure IndoBERT rerank, alpha=1 -> pure BM25.
50
+ query_preprocessor: callable(q_raw) -> q_processed; dipakai untuk BM25
51
+ karena BM25 perlu teks ter-preprocess (stemmed). IndoBERT pakai
52
+ raw query (natural language). Kalau None, q dipakai untuk
53
+ keduanya (backwards-compat untuk pemanggil yang sudah preprocess).
54
+ """
55
+ self.bm25 = bm25
56
+ self.indobert = indobert
57
+ self.bm25_top_k = bm25_top_k
58
+ self.alpha = alpha
59
+ self.query_preprocessor = query_preprocessor
60
+ self._size = bm25.size()
61
+
62
+ def build(self, corpus: list[Document]) -> None:
63
+ """Hybrid = composition; sub-indexes di-build separately."""
64
+ # Tetap track size kalau dipanggil
65
+ self._size = len(corpus)
66
+ # NOTE: caller harus pastikan bm25 & indobert sudah di-build sebelum query.
67
+
68
+ def query(self, q: str, top_k: int = 10) -> list[SearchHit]:
69
+ # 1. BM25 candidates — pakai processed query (stemmed lexical match)
70
+ q_for_bm25 = self.query_preprocessor(q) if self.query_preprocessor else q
71
+ bm25_hits = self.bm25.query(q_for_bm25, top_k=self.bm25_top_k)
72
+ if not bm25_hits:
73
+ return []
74
+ candidate_ids = [h.doc_id for h in bm25_hits]
75
+ bm25_scores_raw = np.array([h.score for h in bm25_hits])
76
+
77
+ # 2. IndoBERT score candidates pakai RAW query (semantic embedding)
78
+ q_emb = self.indobert.encode_query(q)
79
+ indobert_scores_pairs = self.indobert.score_docs(q_emb, candidate_ids)
80
+ indobert_scores_map = dict(indobert_scores_pairs)
81
+
82
+ # Align IndoBERT scores dengan order BM25 candidates
83
+ indobert_scores_raw = np.array(
84
+ [indobert_scores_map.get(doc_id, 0.0) for doc_id in candidate_ids]
85
+ )
86
+
87
+ # 3. Min-max normalize tiap score list (skala [0, 1])
88
+ bm25_norm = self._minmax_normalize(bm25_scores_raw)
89
+ indobert_norm = self._minmax_normalize(indobert_scores_raw)
90
+
91
+ # 4. Combine
92
+ combined = self.alpha * bm25_norm + (1 - self.alpha) * indobert_norm
93
+
94
+ # 5. Sort + top-K
95
+ sorted_idx = np.argsort(-combined)[:top_k]
96
+ return [
97
+ SearchHit(
98
+ doc_id=candidate_ids[idx],
99
+ score=float(combined[idx]),
100
+ rank=rank + 1,
101
+ )
102
+ for rank, idx in enumerate(sorted_idx)
103
+ ]
104
+
105
+ @staticmethod
106
+ def _minmax_normalize(scores: np.ndarray) -> np.ndarray:
107
+ """Min-max normalize ke [0, 1]. Kalau semua sama, return zeros."""
108
+ if len(scores) == 0:
109
+ return scores
110
+ s_min, s_max = scores.min(), scores.max()
111
+ if s_max - s_min < 1e-9:
112
+ return np.zeros_like(scores)
113
+ return (scores - s_min) / (s_max - s_min)
114
+
115
+ def save(self, path: Path) -> None:
116
+ """Hybrid bukan index murni — sub-indexes save separately."""
117
+ raise NotImplementedError(
118
+ "HybridIndex composition; save bm25 & indobert separately. "
119
+ "Hybrid restore via constructor dari sub-indexes yang sudah di-load."
120
+ )
121
+
122
+ @classmethod
123
+ def load(cls, path: Path) -> "HybridIndex":
124
+ raise NotImplementedError(
125
+ "HybridIndex composition; load bm25 & indobert separately, "
126
+ "lalu instantiate HybridIndex(bm25, indobert)."
127
+ )
backend/app/indexing/indobert.py ADDED
@@ -0,0 +1,178 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Neural / semantic index pakai sentence-transformers + FAISS.
2
+
3
+ Default model: `paraphrase-multilingual-MiniLM-L12-v2` (~118 MB, multilingual,
4
+ inferensi cepat di CPU). Untuk hasil lebih bagus tapi heavy: ganti ke
5
+ `indobenchmark/indobert-base-p2` (~440 MB).
6
+
7
+ FAISS IndexFlatIP = inner product. Karena embeddings sudah L2-normalized
8
+ (via `normalize_embeddings=True`), inner product == cosine similarity.
9
+
10
+ Tim Anggota D: experiment di notebook 03_model_comparison.ipynb:
11
+ - Compare MiniLM (small/fast) vs IndoBERT-base (better quality)
12
+ - Pooling strategy: mean-pool (default) vs CLS token
13
+ - FAISS Flat (exhaustive) vs IVF (clustered, faster tapi approximate)
14
+
15
+ Untuk corpus <= 5K docs, Flat sudah cukup (sub-ms query).
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ import pickle
22
+ from pathlib import Path
23
+ from typing import Optional
24
+
25
+ import numpy as np
26
+
27
+ from .base import Document, IndexBase, SearchHit
28
+
29
+
30
+ class IndoBERTIndex(IndexBase):
31
+ name = "indobert"
32
+
33
+ def __init__(
34
+ self,
35
+ model_name: str = "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2",
36
+ normalize_embeddings: bool = True,
37
+ batch_size: int = 32,
38
+ ):
39
+ self.model_name = model_name
40
+ self.normalize_embeddings = normalize_embeddings
41
+ self.batch_size = batch_size
42
+ self.embeddings: Optional[np.ndarray] = None # (N, dim) float32
43
+ self.index = None # faiss.IndexFlatIP
44
+ self.doc_ids: list[str] = []
45
+ self.id_to_idx: dict[str, int] = {}
46
+ self._size = 0
47
+ self._model = None # Lazy load
48
+
49
+ @property
50
+ def model(self):
51
+ """Lazy load fastembed TextEmbedding (ONNX runtime).
52
+
53
+ Trade-off vs sentence-transformers + torch:
54
+ - fastembed: ~150MB total RAM (Render free tier compatible)
55
+ - sentence-transformers + torch: ~500MB total (OOM di 512MB Render)
56
+ - Same underlying MiniLM model, numerical results virtually identical
57
+ - Faster init: ~1-2s vs 10-30s untuk first encode
58
+ """
59
+ if self._model is None:
60
+ import os
61
+
62
+ from fastembed import TextEmbedding
63
+
64
+ # cache_dir eksplisit (FASTEMBED_CACHE_PATH) supaya model ONNX yang
65
+ # di-pre-download saat docker build ke-reuse di runtime — tanpa ini
66
+ # tiap cold start download ulang ~120MB.
67
+ cache_dir = os.getenv("FASTEMBED_CACHE_PATH")
68
+ self._model = TextEmbedding(self.model_name, cache_dir=cache_dir)
69
+ return self._model
70
+
71
+ def _encode(self, texts: list[str]) -> "np.ndarray":
72
+ """Encode texts via fastembed + L2-normalize untuk FAISS IndexFlatIP."""
73
+ import numpy as np
74
+
75
+ embeddings = list(self.model.embed(texts))
76
+ arr = np.vstack(embeddings).astype("float32")
77
+ if self.normalize_embeddings:
78
+ norms = np.linalg.norm(arr, axis=1, keepdims=True)
79
+ norms = np.where(norms > 0, norms, 1.0)
80
+ arr = arr / norms
81
+ return arr
82
+
83
+ def build(self, corpus: list[Document]) -> None:
84
+ import faiss
85
+
86
+ texts = [(doc.raw_text or doc.text) for doc in corpus]
87
+ embeddings = self._encode(texts)
88
+ self.embeddings = embeddings
89
+ # FAISS IndexFlatIP — exhaustive cosine via inner product
90
+ dim = embeddings.shape[1]
91
+ self.index = faiss.IndexFlatIP(dim)
92
+ self.index.add(embeddings)
93
+ self.doc_ids = [doc.id for doc in corpus]
94
+ self.id_to_idx = {doc_id: i for i, doc_id in enumerate(self.doc_ids)}
95
+ self._size = len(corpus)
96
+
97
+ def encode_query(self, q: str) -> np.ndarray:
98
+ """Encode query string ke embedding (via fastembed ONNX).
99
+
100
+ Returns shape (1, dim) — satu baris, bukan flat (dim,).
101
+ FAISS index.search dan score_docs keduanya mengharapkan (1, dim).
102
+ Jangan lakukan dot-product langsung tanpa .T transpose.
103
+ """
104
+ return self._encode([q])
105
+
106
+ def score_docs(
107
+ self, q_emb: np.ndarray, doc_ids: list[str]
108
+ ) -> list[tuple[str, float]]:
109
+ """Score subset doc_ids vs query embedding using pre-computed embeddings.
110
+
111
+ Dipakai oleh HybridIndex untuk rerank candidate dari BM25 tanpa
112
+ re-encode dokumen.
113
+ """
114
+ if self.embeddings is None:
115
+ return []
116
+ indices = [self.id_to_idx[d] for d in doc_ids if d in self.id_to_idx]
117
+ subset = self.embeddings[indices]
118
+ # Inner product (cosine kalau normalized)
119
+ scores = (subset @ q_emb.T).flatten()
120
+ return [
121
+ (self.doc_ids[idx], float(score))
122
+ for idx, score in zip(indices, scores)
123
+ ]
124
+
125
+ def query(self, q: str, top_k: int = 10) -> list[SearchHit]:
126
+ if self.index is None or self._size == 0:
127
+ return []
128
+ q_emb = self.encode_query(q)
129
+ top_k = min(top_k, self._size)
130
+ scores, indices = self.index.search(q_emb, top_k)
131
+ return [
132
+ SearchHit(
133
+ doc_id=self.doc_ids[idx],
134
+ score=float(scores[0][rank]),
135
+ rank=rank + 1,
136
+ )
137
+ for rank, idx in enumerate(indices[0])
138
+ if idx >= 0 # FAISS pakai -1 untuk padding kalau result < top_k
139
+ ]
140
+
141
+ def save(self, path: Path) -> None:
142
+ """Save: embeddings.npy + faiss.index + meta.json di folder `path`."""
143
+ import faiss
144
+
145
+ path.mkdir(parents=True, exist_ok=True)
146
+ np.save(path / "embeddings.npy", self.embeddings)
147
+ faiss.write_index(self.index, str(path / "faiss.index"))
148
+ with open(path / "meta.json", "w", encoding="utf-8") as f:
149
+ json.dump(
150
+ {
151
+ "model_name": self.model_name,
152
+ "normalize_embeddings": self.normalize_embeddings,
153
+ "batch_size": self.batch_size,
154
+ "doc_ids": self.doc_ids,
155
+ "size": self._size,
156
+ },
157
+ f,
158
+ ensure_ascii=False,
159
+ )
160
+
161
+ @classmethod
162
+ def load(cls, path: Path) -> "IndoBERTIndex":
163
+ """Load dari folder yang berisi embeddings.npy + faiss.index + meta.json."""
164
+ import faiss
165
+
166
+ with open(path / "meta.json", encoding="utf-8") as f:
167
+ meta = json.load(f)
168
+ instance = cls(
169
+ model_name=meta["model_name"],
170
+ normalize_embeddings=meta["normalize_embeddings"],
171
+ batch_size=meta["batch_size"],
172
+ )
173
+ instance.embeddings = np.load(path / "embeddings.npy")
174
+ instance.index = faiss.read_index(str(path / "faiss.index"))
175
+ instance.doc_ids = meta["doc_ids"]
176
+ instance.id_to_idx = {d: i for i, d in enumerate(instance.doc_ids)}
177
+ instance._size = meta["size"]
178
+ return instance
backend/app/indexing/loader.py ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Loader helper untuk FastAPI lifespan startup.
2
+
3
+ Load semua indexes dari disk sekali saat backend startup, store di
4
+ `app.state` untuk reuse di route handlers.
5
+
6
+ Usage di app/main.py:
7
+
8
+ from app.indexing.loader import load_all_indexes
9
+
10
+ @asynccontextmanager
11
+ async def lifespan(app: FastAPI):
12
+ indexes = load_all_indexes(Path(settings.indexes_dir))
13
+ app.state.tfidf = indexes["tfidf"]
14
+ app.state.bm25 = indexes["bm25"]
15
+ app.state.indobert = indexes["indobert"]
16
+ app.state.hybrid = HybridIndex(indexes["bm25"], indexes["indobert"])
17
+ yield
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from pathlib import Path
23
+ from typing import Optional
24
+
25
+ from loguru import logger
26
+
27
+ from .bm25 import BM25Index
28
+ from .tfidf import TFIDFIndex
29
+
30
+
31
+ def load_all_indexes(
32
+ indexes_dir: Path, include_neural: bool = True
33
+ ) -> dict[str, object]:
34
+ """Load semua indexes dari folder. Return dict {name: index_instance}.
35
+
36
+ Missing index logged sebagai warning, tidak raise (supaya partial deploy OK).
37
+ `include_neural=False` melewati IndoBERT supaya runtime production tidak
38
+ perlu import faiss/fastembed (hemat RAM).
39
+ """
40
+ result: dict[str, object] = {}
41
+
42
+ tfidf_path = indexes_dir / "tfidf.pkl"
43
+ if tfidf_path.exists():
44
+ logger.info(f"[load] TF-IDF dari {tfidf_path}")
45
+ result["tfidf"] = TFIDFIndex.load(tfidf_path)
46
+ else:
47
+ logger.warning(f"[skip] TF-IDF tidak ditemukan di {tfidf_path}")
48
+
49
+ bm25_path = indexes_dir / "bm25.pkl"
50
+ if bm25_path.exists():
51
+ logger.info(f"[load] BM25 dari {bm25_path}")
52
+ result["bm25"] = BM25Index.load(bm25_path)
53
+ else:
54
+ logger.warning(f"[skip] BM25 tidak ditemukan di {bm25_path}")
55
+
56
+ indobert_path = indexes_dir / "indobert"
57
+ if not include_neural:
58
+ logger.info("[skip] IndoBERT dilewati (enable_neural=False)")
59
+ elif indobert_path.exists() and indobert_path.is_dir():
60
+ from .indobert import IndoBERTIndex # lazy: hindari import faiss saat off
61
+
62
+ logger.info(f"[load] IndoBERT dari {indobert_path}")
63
+ result["indobert"] = IndoBERTIndex.load(indobert_path)
64
+ else:
65
+ logger.warning(f"[skip] IndoBERT tidak ditemukan di {indobert_path}")
66
+
67
+ logger.info(f"[load] {len(result)} indexes loaded: {list(result.keys())}")
68
+ return result
backend/app/indexing/tfidf.py ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """TF-IDF + cosine similarity index (scikit-learn).
2
+
3
+ Hyperparameter default:
4
+ - ngram_range=(1, 2): unigram + bigram (capture compound terms seperti
5
+ "kamar mandi", "air panas")
6
+ - min_df=2: drop term yang muncul cuma sekali (likely typo/noise)
7
+ - max_features=10000: limit vocab size untuk memory + speed
8
+
9
+ Tim Anggota C: experiment dengan hyperparameter di
10
+ notebooks/03_model_comparison.ipynb. Pada corpus real 227 listing, TF-IDF
11
+ MAP ~0.24 (standard) / ~0.59 (pool-restricted) — kompetitif dengan BM25.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import pickle
17
+ from pathlib import Path
18
+ from typing import Optional
19
+
20
+ import numpy as np
21
+ from sklearn.feature_extraction.text import TfidfVectorizer
22
+ from sklearn.metrics.pairwise import cosine_similarity
23
+
24
+ from .base import Document, IndexBase, SearchHit
25
+
26
+
27
+ class TFIDFIndex(IndexBase):
28
+ name = "tfidf"
29
+
30
+ def __init__(
31
+ self,
32
+ ngram_range: tuple[int, int] = (1, 2),
33
+ min_df: int = 2,
34
+ max_features: int = 10000,
35
+ ):
36
+ self.vectorizer = TfidfVectorizer(
37
+ ngram_range=ngram_range,
38
+ min_df=min_df,
39
+ max_features=max_features,
40
+ sublinear_tf=True, # log(1+tf) — handle high-tf terms
41
+ )
42
+ self.doc_matrix = None # sparse matrix (N, vocab_size)
43
+ self.doc_ids: list[str] = []
44
+ self._size = 0
45
+
46
+ def build(self, corpus: list[Document]) -> None:
47
+ texts = [doc.text for doc in corpus]
48
+ self.doc_ids = [doc.id for doc in corpus]
49
+ self.doc_matrix = self.vectorizer.fit_transform(texts)
50
+ self._size = len(corpus)
51
+
52
+ def query(self, q: str, top_k: int = 10) -> list[SearchHit]:
53
+ if self.doc_matrix is None or self._size == 0:
54
+ return []
55
+ q_vec = self.vectorizer.transform([q])
56
+ # Cosine similarity (since TF-IDF vectors already L2-normalized by default)
57
+ scores = cosine_similarity(q_vec, self.doc_matrix).flatten()
58
+ # Top-K indices (descending)
59
+ top_k = min(top_k, self._size)
60
+ top_indices = np.argpartition(-scores, top_k - 1)[:top_k]
61
+ # Sort within top-K
62
+ top_indices = top_indices[np.argsort(-scores[top_indices])]
63
+ return [
64
+ SearchHit(
65
+ doc_id=self.doc_ids[idx],
66
+ score=float(scores[idx]),
67
+ rank=rank + 1,
68
+ )
69
+ for rank, idx in enumerate(top_indices)
70
+ ]
71
+
72
+ def save(self, path: Path) -> None:
73
+ path.parent.mkdir(parents=True, exist_ok=True)
74
+ with open(path, "wb") as f:
75
+ pickle.dump(
76
+ {
77
+ "vectorizer": self.vectorizer,
78
+ "doc_matrix": self.doc_matrix,
79
+ "doc_ids": self.doc_ids,
80
+ "size": self._size,
81
+ },
82
+ f,
83
+ )
84
+
85
+ @classmethod
86
+ def load(cls, path: Path) -> "TFIDFIndex":
87
+ with open(path, "rb") as f:
88
+ data = pickle.load(f)
89
+ instance = cls.__new__(cls)
90
+ instance.vectorizer = data["vectorizer"]
91
+ instance.doc_matrix = data["doc_matrix"]
92
+ instance.doc_ids = data["doc_ids"]
93
+ instance._size = data["size"]
94
+ return instance
backend/app/main.py ADDED
@@ -0,0 +1,226 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """FastAPI entry point untuk KozyNear search engine.
2
+
3
+ Architecture: single-container Docker. FastAPI serve:
4
+ - React SPA dari `/app/static/` (built dari frontend/)
5
+ - API endpoints di `/api/*`
6
+ - Health check di `/health` (Render uses this)
7
+ - Swagger UI di `/api/docs`
8
+
9
+ Routing order (FastAPI matches in registration order):
10
+ 1. Specific API routes: /health, /api/info, /api/search, /api/listings/{id}, /api/eval/*
11
+ 2. /api/docs, /api/openapi.json (FastAPI auto-generated)
12
+ 3. /assets/* (Vite build output)
13
+ 4. Catch-all /{path} -> serve index.html for SPA routing (React Router fallback)
14
+
15
+ Lifespan:
16
+ - Startup: load preprocessing pipeline + IR indexes dari disk
17
+ - Shutdown: cleanup
18
+ """
19
+
20
+ from contextlib import asynccontextmanager
21
+ from pathlib import Path
22
+
23
+ from fastapi import FastAPI, HTTPException
24
+ from fastapi.middleware.cors import CORSMiddleware
25
+ from fastapi.responses import FileResponse
26
+ from fastapi.staticfiles import StaticFiles
27
+ from loguru import logger
28
+
29
+ from app.api.eval import router as eval_router
30
+ from app.api.health import router as health_router
31
+ from app.api.insights import router as insights_router
32
+ from app.api.listings import router as listings_router
33
+ from app.api.search import router as search_router
34
+ from app.core.config import settings
35
+
36
+
37
+ @asynccontextmanager
38
+ async def lifespan(app: FastAPI):
39
+ """Startup: load heavy resources. Shutdown: cleanup."""
40
+ logger.info(f"[startup] KozyNear backend (env={settings.environment})")
41
+
42
+ # Initialize app.state slots
43
+ app.state.tfidf = None
44
+ app.state.bm25 = None
45
+ app.state.indobert = None
46
+ app.state.hybrid = None
47
+ app.state.preprocessing_pipeline = None
48
+ app.state.gazetteer = None
49
+
50
+ # 1. Preprocessing pipeline (Sastrawi factory init ~1s)
51
+ try:
52
+ from app.preprocessing import PreprocessingPipeline
53
+
54
+ app.state.preprocessing_pipeline = PreprocessingPipeline()
55
+ logger.info("[startup] preprocessing pipeline loaded")
56
+ except Exception as e:
57
+ logger.error(f"[startup] preprocessing pipeline FAIL: {e}")
58
+
59
+ # 1b. Gazetteer (kampus + landmark) untuk smart search
60
+ try:
61
+ from app.search.gazetteer import Gazetteer
62
+
63
+ app.state.gazetteer = Gazetteer.load()
64
+ logger.info("[startup] gazetteer loaded")
65
+ except Exception as e:
66
+ app.state.gazetteer = None
67
+ logger.error(f"[startup] gazetteer FAIL: {e}")
68
+
69
+ # 2. IR indexes (graceful skip kalau missing)
70
+ try:
71
+ from app.indexing.loader import load_all_indexes
72
+
73
+ indexes_path = Path(settings.indexes_dir)
74
+ if indexes_path.exists():
75
+ indexes = load_all_indexes(
76
+ indexes_path, include_neural=settings.enable_neural
77
+ )
78
+ app.state.tfidf = indexes.get("tfidf")
79
+ app.state.bm25 = indexes.get("bm25")
80
+ app.state.indobert = indexes.get("indobert")
81
+
82
+ if app.state.bm25 is not None and app.state.indobert is not None:
83
+ from app.indexing.hybrid import HybridIndex
84
+
85
+ _pipeline = getattr(app.state, "preprocessing_pipeline", None)
86
+ app.state.hybrid = HybridIndex(
87
+ app.state.bm25,
88
+ app.state.indobert,
89
+ query_preprocessor=(
90
+ (lambda q: _pipeline.process(q).processed)
91
+ if _pipeline is not None else None
92
+ ),
93
+ )
94
+ logger.info("[startup] hybrid index assembled")
95
+
96
+ # BACKGROUND prewarm IndoBERT model:
97
+ # - Index sudah loaded (embeddings.npy + faiss.index dari disk)
98
+ # - SentenceTransformer model load butuh ~10-30s di Render free tier CPU
99
+ # - SYNCHRONOUS prewarm di lifespan = block port binding (deploy fail)
100
+ # - LAZY load di first request = Render proxy timeout 30s -> 502
101
+ # - SOLUTION: spawn async background task setelah port binds
102
+ # - Sambil model load di background, /search?model=indobert return
103
+ # 503 dengan flag indobert_ready=False sampai task complete
104
+ app.state.indobert_ready = False
105
+ app.state.indobert_failed = False
106
+ if app.state.indobert is not None:
107
+ async def _bg_prewarm():
108
+ import asyncio
109
+ import time
110
+ try:
111
+ logger.info("[bg] starting IndoBERT model prewarm...")
112
+ t0 = time.perf_counter()
113
+ # Run blocking encode_query di thread pool supaya
114
+ # tidak block event loop
115
+ await asyncio.to_thread(
116
+ app.state.indobert.encode_query, "warmup",
117
+ )
118
+ elapsed = time.perf_counter() - t0
119
+ app.state.indobert_ready = True
120
+ logger.info(f"[bg] IndoBERT model warm ({elapsed:.1f}s)")
121
+ except Exception as e:
122
+ logger.error(f"[bg] IndoBERT prewarm FAIL: {e}")
123
+ # Set ke None supaya graceful 503 di /search
124
+ app.state.indobert = None
125
+ app.state.hybrid = None
126
+ app.state.indobert_failed = True
127
+
128
+ import asyncio
129
+ asyncio.create_task(_bg_prewarm())
130
+ logger.info(
131
+ "[startup] IndoBERT prewarm scheduled di background; "
132
+ "/search?model=indobert akan return 503 sampai ready"
133
+ )
134
+ else:
135
+ logger.warning(
136
+ f"[startup] indexes_dir tidak ada: {indexes_path}. "
137
+ "Build dulu via `python -m app.indexing.build`."
138
+ )
139
+ except Exception as e:
140
+ logger.error(f"[startup] index loading FAIL: {e}")
141
+
142
+ yield
143
+ logger.info("[shutdown] KozyNear backend stopping")
144
+
145
+
146
+ app = FastAPI(
147
+ title="KozyNear API",
148
+ description=(
149
+ "Indonesian Kos-Kosan Search Engine -- UNILA STKI Final Project. "
150
+ "Compare 3 IR paradigms: TF-IDF, BM25, IndoBERT+FAISS, dan Hybrid."
151
+ ),
152
+ version="0.1.0",
153
+ docs_url="/api/docs",
154
+ redoc_url="/api/redoc",
155
+ openapi_url="/api/openapi.json",
156
+ lifespan=lifespan,
157
+ )
158
+
159
+ # CORS: tetap aktifkan untuk dev (frontend di :5173, backend di :8000).
160
+ # Di production single-container, frontend & API same origin -> CORS no-op.
161
+ app.add_middleware(
162
+ CORSMiddleware,
163
+ allow_origins=settings.cors_origins,
164
+ allow_credentials=True,
165
+ allow_methods=["GET", "POST"],
166
+ allow_headers=["*"],
167
+ )
168
+
169
+ # ----------------------------------------------------------------------------
170
+ # Routes order: API dulu, lalu static catch-all
171
+ # ----------------------------------------------------------------------------
172
+
173
+ # Health check at /health (Render uses this)
174
+ app.include_router(health_router, tags=["health"])
175
+
176
+ # API endpoints under /api prefix
177
+ app.include_router(search_router, prefix="/api", tags=["search"])
178
+ app.include_router(listings_router, prefix="/api", tags=["listings"])
179
+ app.include_router(eval_router, prefix="/api") # eval_router internal prefix /eval
180
+ app.include_router(insights_router, prefix="/api", tags=["insights"])
181
+
182
+
183
+ # ----------------------------------------------------------------------------
184
+ # Static frontend (Docker build) -- serve dari /app/static
185
+ # ----------------------------------------------------------------------------
186
+ # Path discovery: di Docker container path absolute /app/static.
187
+ # Untuk local dev tanpa build, skip mount (frontend run terpisah via vite dev).
188
+ _DOCKER_STATIC = Path("/app/static")
189
+ _LOCAL_STATIC = Path(__file__).resolve().parent.parent.parent / "static"
190
+
191
+ STATIC_DIR: Path | None = None
192
+ if _DOCKER_STATIC.exists():
193
+ STATIC_DIR = _DOCKER_STATIC
194
+ elif _LOCAL_STATIC.exists():
195
+ STATIC_DIR = _LOCAL_STATIC
196
+
197
+ if STATIC_DIR is not None:
198
+ # Mount /assets/ untuk JS/CSS chunks (Vite outputs ke /assets/)
199
+ assets_dir = STATIC_DIR / "assets"
200
+ if assets_dir.exists():
201
+ app.mount(
202
+ "/assets",
203
+ StaticFiles(directory=str(assets_dir)),
204
+ name="assets",
205
+ )
206
+
207
+ # Catch-all: serve real file kalau ada, else index.html (SPA fallback)
208
+ @app.get("/{full_path:path}", include_in_schema=False)
209
+ async def serve_spa(full_path: str):
210
+ # Security: prevent path traversal
211
+ candidate = (STATIC_DIR / full_path).resolve()
212
+ if not str(candidate).startswith(str(STATIC_DIR.resolve())):
213
+ raise HTTPException(403, "Forbidden")
214
+
215
+ if candidate.is_file():
216
+ return FileResponse(candidate)
217
+
218
+ # SPA fallback: serve index.html untuk unknown routes
219
+ index = STATIC_DIR / "index.html"
220
+ if index.exists():
221
+ return FileResponse(index)
222
+ raise HTTPException(404, "Not Found")
223
+
224
+ logger.info(f"[static] frontend mounted dari {STATIC_DIR}")
225
+ else:
226
+ logger.info("[static] tidak ada static dir -- run frontend terpisah via vite dev")
backend/app/models/__init__.py ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pydantic schemas (API request/response) + SQLAlchemy ORM models.
2
+
3
+ Analogi Laravel:
4
+ - Pydantic schemas = Form Request validation + API Resource
5
+ - SQLAlchemy ORM = Eloquent Models
6
+
7
+ Files (akan dibuat di Week 2):
8
+ - listing.py — Listing ORM + Pydantic schemas (ListingRead, ListingDetail)
9
+ - query.py — Query + Annotation + EvalResult ORM
10
+ - search.py — SearchRequest, SearchResponse Pydantic schemas
11
+ """
backend/app/models/eval.py ADDED
@@ -0,0 +1,144 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """ORM + Pydantic schemas untuk eval data (queries, ground truth, results).
2
+
3
+ Tables di-create via Alembic migration 001_initial_schema.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from datetime import datetime
9
+ from typing import Optional
10
+
11
+ from pydantic import BaseModel, ConfigDict
12
+ from sqlalchemy import (
13
+ CheckConstraint,
14
+ DateTime,
15
+ ForeignKey,
16
+ Numeric,
17
+ SmallInteger,
18
+ String,
19
+ Text,
20
+ func,
21
+ )
22
+ from sqlalchemy.orm import Mapped, mapped_column
23
+
24
+ from app.core.db import Base
25
+
26
+
27
+ # =============================================================================
28
+ # ORM models
29
+ # =============================================================================
30
+ class Query(Base):
31
+ """Query set untuk evaluation."""
32
+ __tablename__ = "queries"
33
+
34
+ id: Mapped[str] = mapped_column(String, primary_key=True)
35
+ query_text: Mapped[str] = mapped_column(Text, nullable=False)
36
+ context: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
37
+ expected_tipe: Mapped[Optional[str]] = mapped_column(
38
+ String(20), nullable=True
39
+ )
40
+
41
+
42
+ class GroundTruth(Base):
43
+ """Raw annotation per (query, listing, annotator)."""
44
+ __tablename__ = "ground_truth"
45
+
46
+ query_id: Mapped[str] = mapped_column(
47
+ String, ForeignKey("queries.id"), primary_key=True
48
+ )
49
+ listing_id: Mapped[str] = mapped_column(
50
+ String, ForeignKey("listings.id"), primary_key=True
51
+ )
52
+ annotator: Mapped[str] = mapped_column(String(50), primary_key=True)
53
+ relevance: Mapped[int] = mapped_column(SmallInteger, nullable=False)
54
+ notes: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
55
+
56
+ __table_args__ = (
57
+ CheckConstraint(
58
+ "relevance IN (0, 1, 2)", name="ck_relevance_0_1_2"
59
+ ),
60
+ )
61
+
62
+
63
+ class GroundTruthConsensus(Base):
64
+ """Consensus labels (post-discussion). Input untuk evaluation runner."""
65
+ __tablename__ = "ground_truth_consensus"
66
+
67
+ query_id: Mapped[str] = mapped_column(
68
+ String, ForeignKey("queries.id"), primary_key=True
69
+ )
70
+ listing_id: Mapped[str] = mapped_column(
71
+ String, ForeignKey("listings.id"), primary_key=True
72
+ )
73
+ relevance: Mapped[int] = mapped_column(SmallInteger, nullable=False)
74
+
75
+ __table_args__ = (
76
+ CheckConstraint(
77
+ "relevance IN (0, 1, 2)", name="ck_consensus_relevance"
78
+ ),
79
+ )
80
+
81
+
82
+ class EvalResult(Base):
83
+ """Per-model per-query evaluation metrics."""
84
+ __tablename__ = "eval_results"
85
+
86
+ model: Mapped[str] = mapped_column(String(20), primary_key=True)
87
+ query_id: Mapped[str] = mapped_column(
88
+ String, ForeignKey("queries.id"), primary_key=True
89
+ )
90
+ precision_at_5: Mapped[Optional[float]] = mapped_column(
91
+ Numeric(5, 4), nullable=True
92
+ )
93
+ precision_at_10: Mapped[Optional[float]] = mapped_column(
94
+ Numeric(5, 4), nullable=True
95
+ )
96
+ average_precision: Mapped[Optional[float]] = mapped_column(
97
+ Numeric(5, 4), nullable=True
98
+ )
99
+ ndcg_at_10: Mapped[Optional[float]] = mapped_column(
100
+ Numeric(5, 4), nullable=True
101
+ )
102
+ reciprocal_rank: Mapped[Optional[float]] = mapped_column(
103
+ Numeric(5, 4), nullable=True
104
+ )
105
+ computed_at: Mapped[Optional[datetime]] = mapped_column(
106
+ DateTime, server_default=func.now(), nullable=True
107
+ )
108
+
109
+
110
+ # =============================================================================
111
+ # Pydantic schemas (API)
112
+ # =============================================================================
113
+ class ModelMetrics(BaseModel):
114
+ """Aggregate metrics per IR model."""
115
+ model_config = ConfigDict(from_attributes=True, protected_namespaces=())
116
+
117
+ model: str
118
+ p_at_5: float
119
+ p_at_10: float
120
+ map: float
121
+ ndcg_at_10: float
122
+ mrr: float
123
+ n_queries: int
124
+
125
+
126
+ class EvalSummary(BaseModel):
127
+ """Response untuk /eval/summary."""
128
+ models: list[ModelMetrics]
129
+ total_queries: int
130
+ total_listings_indexed: int
131
+
132
+
133
+ class QueryResult(BaseModel):
134
+ """Per-query metric breakdown untuk /eval/query/{id}."""
135
+ model_config = ConfigDict(protected_namespaces=())
136
+
137
+ query_id: str
138
+ query_text: str
139
+ model: str
140
+ p_at_5: float
141
+ p_at_10: float
142
+ average_precision: float
143
+ ndcg_at_10: float
144
+ reciprocal_rank: float
backend/app/models/listing.py ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Listing ORM model + Pydantic schemas.
2
+
3
+ Analogi Laravel:
4
+ - Listing ORM = Eloquent Model (database mapping)
5
+ - ListingRead/Detail Pydantic = Form Request / API Resource (serialization)
6
+
7
+ ORM untuk persistence, Pydantic untuk API I/O. Konversi lewat
8
+ `ListingRead.model_validate(listing_orm)` atau `from_attributes=True`.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from datetime import date
14
+ from typing import Optional
15
+
16
+ from pydantic import BaseModel, ConfigDict, Field
17
+ from sqlalchemy import ARRAY, Date, DateTime, Integer, Numeric, String, Text, func
18
+ from sqlalchemy.orm import Mapped, mapped_column
19
+
20
+ from app.core.db import Base
21
+
22
+
23
+ # =============================================================================
24
+ # ORM model
25
+ # =============================================================================
26
+ class Listing(Base):
27
+ """Table `listings` — raw scraped + processed kos data."""
28
+
29
+ __tablename__ = "listings"
30
+
31
+ id: Mapped[str] = mapped_column(String, primary_key=True)
32
+ judul: Mapped[str] = mapped_column(Text, nullable=False)
33
+ deskripsi: Mapped[str] = mapped_column(Text, nullable=False)
34
+ deskripsi_processed: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
35
+
36
+ harga_per_bulan: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
37
+ tipe: Mapped[Optional[str]] = mapped_column(String(20), nullable=True)
38
+ fasilitas: Mapped[Optional[list[str]]] = mapped_column(
39
+ ARRAY(String), nullable=True
40
+ )
41
+
42
+ alamat: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
43
+ kecamatan: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
44
+ koordinat_lat: Mapped[Optional[float]] = mapped_column(
45
+ Numeric(8, 5), nullable=True
46
+ )
47
+ koordinat_lng: Mapped[Optional[float]] = mapped_column(
48
+ Numeric(8, 5), nullable=True
49
+ )
50
+ jarak_kampus_km: Mapped[Optional[float]] = mapped_column(
51
+ Numeric(5, 2), nullable=True
52
+ )
53
+
54
+ url_source: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
55
+ scrape_date: Mapped[Optional[date]] = mapped_column(Date, nullable=True)
56
+ source: Mapped[Optional[str]] = mapped_column(String(20), nullable=True)
57
+
58
+ inserted_at: Mapped[Optional[date]] = mapped_column(
59
+ DateTime, server_default=func.now(), nullable=True
60
+ )
61
+
62
+
63
+ # =============================================================================
64
+ # Pydantic schemas (API I/O)
65
+ # =============================================================================
66
+ class ListingBase(BaseModel):
67
+ """Shared fields antara list & detail."""
68
+ model_config = ConfigDict(from_attributes=True)
69
+
70
+ id: str
71
+ judul: str
72
+ harga_per_bulan: Optional[int] = None
73
+ tipe: Optional[str] = None
74
+ fasilitas: Optional[list[str]] = None
75
+ alamat: Optional[str] = None
76
+ kecamatan: Optional[str] = None
77
+
78
+
79
+ class ListingRead(ListingBase):
80
+ """Schema untuk hasil search (deskripsi present, score dari IR model)."""
81
+ deskripsi: str = Field(..., description="Original deskripsi; UI truncate ~200 chars")
82
+ score: float = Field(..., description="Relevance score dari IR model")
83
+ koordinat: Optional[list[float]] = Field(
84
+ None, description="[lat, lng] untuk pin Google Maps di frontend"
85
+ )
86
+
87
+
88
+ class ListingDetail(ListingBase):
89
+ """Schema untuk endpoint /listings/{id} (full detail)."""
90
+ deskripsi: str
91
+ deskripsi_processed: Optional[str] = None
92
+ koordinat: Optional[list[float]] = Field(
93
+ None, description="[lat, lng] format"
94
+ )
95
+ jarak_kampus_km: Optional[float] = None
96
+ url_source: Optional[str] = None
97
+ scrape_date: Optional[date] = None
98
+ source: Optional[str] = None
backend/app/models/search.py ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pydantic schemas untuk /search endpoint request/response."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Literal
6
+
7
+ from pydantic import BaseModel, Field
8
+
9
+ from app.models.listing import ListingRead
10
+
11
+
12
+ # Allowed IR model names (sinkron dengan IndexBase.name di app.indexing.*)
13
+ Model = Literal["tfidf", "bm25", "indobert", "hybrid", "smart"]
14
+
15
+
16
+ class SearchResponse(BaseModel):
17
+ """Response untuk GET /search."""
18
+ query: str
19
+ model: Model
20
+ top_k: int
21
+ took_ms: int = Field(..., description="Latency total search dalam millisecond")
22
+ results: list[ListingRead]
23
+ understood: dict = Field(
24
+ default_factory=dict, description="Atribut terdeteksi dari query (mode smart)"
25
+ )
26
+ relaxed: list[str] = Field(
27
+ default_factory=list, description="Filter yang dilonggarkan (mode smart)"
28
+ )
backend/app/preprocessing/README.md ADDED
@@ -0,0 +1,161 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Preprocessing Module
2
+
3
+ Indonesian text preprocessing pipeline untuk kos listings, dengan custom jargon
4
+ dictionary (105+ entries).
5
+
6
+ ## Pipeline Order (PENTING)
7
+
8
+ ```
9
+ raw text
10
+ -> 1. HTML strip (BeautifulSoup)
11
+ -> 2. Whitespace normalize
12
+ -> 3. Price extraction (SEBELUM lowercase — preserve `Rp` capitalized)
13
+ -> 4. Lowercase
14
+ -> 5. Jargon dictionary substitution (word-boundary, longest-first)
15
+ -> 6. Spelling correction (typo dictionary)
16
+ -> 7. Tokenize (whitespace + punctuation)
17
+ -> 8. Stopword removal (Sastrawi default + custom domain)
18
+ -> 9. Stem (Sastrawi StemmerFactory, LRU-cached)
19
+ ```
20
+
21
+ **Anti-pattern**: lowercase SEBELUM extract harga → regex `[Rr][Pp]` masih
22
+ match, tapi edge case `Rp850k` (tanpa space) jadi rapuh kalau pattern berubah.
23
+
24
+ ## Usage
25
+
26
+ ```python
27
+ from app.preprocessing import PreprocessingPipeline, PipelineConfig
28
+
29
+ # Default: semua stage aktif
30
+ pipeline = PreprocessingPipeline()
31
+ result = pipeline.process(
32
+ "Kos Putra AC WiFi <b>Rp 850.000</b>/bulan dekat unyila"
33
+ )
34
+
35
+ print(result.processed) # joined tokens setelah stemming
36
+ print(result.tokens) # ['putra', 'air', 'condition', ...]
37
+ print(result.extracted_prices) # [850000]
38
+ print(result.stages_applied) # ['strip_html', 'normalize_whitespace', ...]
39
+
40
+ # Shorthand utility
41
+ from app.preprocessing import preprocess
42
+ text = preprocess("kos murah 500k dekat unyila")
43
+
44
+ # Experiment mode: disable stem
45
+ config = PipelineConfig(stem=False)
46
+ pipeline = PreprocessingPipeline(config)
47
+ ```
48
+
49
+ ## What's Done (oleh mentor — scaffold)
50
+
51
+ - [x] `pipeline.py` — orchestrator dengan stage toggles
52
+ - [x] `normalizer.py` — HTML strip, whitespace, lowercase, inline price extract
53
+ - [x] `jargon.py` — **105+ entries** (abbreviation, location, type, rules,
54
+ payment, kos form) — `python -m app.preprocessing.jargon` cek count
55
+ - [x] `spelling.py` — typo dictionary baseline (15+ fixes)
56
+ - [x] `tokenizer.py` — whitespace + punctuation tokenizer
57
+ - [x] `stopwords.py` — Sastrawi + custom kos stopwords
58
+ - [x] `stemmer.py` — Sastrawi wrapper dengan LRU cache 10k
59
+ - [x] Tests di `backend/tests/test_preprocessing.py`
60
+
61
+ ## What Tim Anggota B (Preprocessing Engineer) WAJIB Do
62
+
63
+ ### Priority 1 — Tambah jargon dict ke ≥120 entries
64
+
65
+ File: `jargon.py`. Saat ini 105 entries (sudah lewat threshold 100, tapi
66
+ **lebih bagus 120+ untuk dokumentasi rubric**).
67
+
68
+ Cara discover entries baru:
69
+ 1. Tunggu Anggota A scrape selesai (`data/raw/mamikos.jsonl`)
70
+ 2. Random sample 50 listings, baca manual:
71
+ ```python
72
+ import json
73
+ with open("data/raw/mamikos.jsonl") as f:
74
+ sample = [json.loads(line) for line in list(f)[:50]]
75
+ for listing in sample:
76
+ print(listing["deskripsi"][:300])
77
+ print("---")
78
+ ```
79
+ 3. Catat pattern abbreviation / slang yang belum di `jargon.py`
80
+ 4. Tambah ke kategori sesuai (`ABBREVIATIONS`, `LOCATIONS`, etc.)
81
+ 5. Run check:
82
+ ```bash
83
+ cd backend
84
+ python -m app.preprocessing.jargon
85
+ # Output: KOS_JARGON_DICT size: 120+ -- OK: meets rubric requirement
86
+ ```
87
+
88
+ ### Priority 2 — Custom stopwords dari EDA
89
+
90
+ File: `stopwords.py` — `DEFAULT_CUSTOM_STOPWORDS`.
91
+
92
+ Di notebook `01_eda.ipynb`:
93
+ ```python
94
+ from collections import Counter
95
+ import json
96
+
97
+ corpus = [json.loads(line) for line in open("../data/raw/mamikos.jsonl")]
98
+ all_tokens = []
99
+ for listing in corpus:
100
+ all_tokens.extend(listing["deskripsi"].lower().split())
101
+
102
+ # Top-50 paling sering
103
+ for token, count in Counter(all_tokens).most_common(50):
104
+ print(f"{token}: {count}")
105
+ ```
106
+
107
+ Top-50 paling sering biasanya stopword domain — tambah ke
108
+ `DEFAULT_CUSTOM_STOPWORDS` kalau memang noise (bukan informative term).
109
+
110
+ ### Priority 3 — Benchmark preprocessing impact
111
+
112
+ File: `notebooks/02_preprocessing_experiment.ipynb` (Anggota B buat sendiri).
113
+
114
+ Untuk **rubric Preprocessing 15%** — wajib show preprocessing impact secara
115
+ empirik:
116
+
117
+ | Variant | MAP | P@10 | Notes |
118
+ |---------|-----|------|-------|
119
+ | BASELINE (raw text, no preprocess) | 0.XX | 0.XX | reference |
120
+ | + HTML strip + lowercase | 0.XX | 0.XX | |
121
+ | + jargon dict | 0.XX | 0.XX | expect lift |
122
+ | + spelling correction | 0.XX | 0.XX | |
123
+ | + stopword removal | 0.XX | 0.XX | |
124
+ | + stemming | 0.XX | 0.XX | |
125
+ | FULL pipeline | 0.XX | 0.XX | |
126
+
127
+ Insight ditulis di laporan akhir + slide.
128
+
129
+ ### Priority 4 — Spelling correction yang lebih robust (optional, Week 3)
130
+
131
+ Kalau ada budget waktu di Week 2-3, upgrade `spelling.py`:
132
+ - Edit distance (Levenshtein) untuk catch unknown typos
133
+ - Bigram language model untuk context-aware correction
134
+
135
+ Libraries: `pyspellchecker`, custom n-gram model.
136
+
137
+ ## Testing
138
+
139
+ ```bash
140
+ cd backend
141
+ pip install -r requirements.txt # include Sastrawi
142
+ pytest tests/test_preprocessing.py -v
143
+ ```
144
+
145
+ Test coverage:
146
+ - Atomic functions (normalizer, tokenizer, jargon, spelling) — no Sastrawi needed
147
+ - Pipeline integration (stopword, stemmer) — needs Sastrawi installed
148
+ - Anti-pattern test: price extraction sebelum lowercase
149
+ - Jargon substitution dengan word boundary + longest-first ordering
150
+ - Config toggles (disable individual stages)
151
+
152
+ ## Performance Notes
153
+
154
+ Untuk corpus 3000 listings × ~150 tokens avg:
155
+ - HTML strip: ~5ms/doc
156
+ - Jargon substitution: ~2ms/doc (pre-compiled regex)
157
+ - Sastrawi stem (first time): ~500ms-1s setup, ~10-50ms/doc
158
+ - Sastrawi stem (cached): ~1-5ms/doc
159
+
160
+ Total batch preprocessing 3000 docs: ~30-60 detik (acceptable, run once,
161
+ save corpus.json).
backend/app/preprocessing/__init__.py ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Indonesian text preprocessing pipeline untuk kos listings.
2
+
3
+ Public API:
4
+ from app.preprocessing import (
5
+ PreprocessingPipeline, PipelineConfig, PipelineResult, preprocess,
6
+ KOS_JARGON_DICT, jargon_count,
7
+ )
8
+
9
+ Pipeline ORDER (PENTING):
10
+ 1. HTML strip (BeautifulSoup)
11
+ 2. Whitespace normalize
12
+ 3. Price extraction (SEBELUM lowercase — preserve `Rp`)
13
+ 4. Lowercase
14
+ 5. Jargon dict substitution
15
+ 6. Spelling correction
16
+ 7. Tokenize
17
+ 8. Stopword removal (Sastrawi + custom)
18
+ 9. Stem (Sastrawi StemmerFactory, cached)
19
+ """
20
+
21
+ from .jargon import KOS_JARGON_DICT, jargon_count
22
+ from .pipeline import (
23
+ PipelineConfig,
24
+ PipelineResult,
25
+ PreprocessingPipeline,
26
+ preprocess,
27
+ )
28
+
29
+ __all__ = [
30
+ "PreprocessingPipeline",
31
+ "PipelineConfig",
32
+ "PipelineResult",
33
+ "preprocess",
34
+ "KOS_JARGON_DICT",
35
+ "jargon_count",
36
+ ]
backend/app/preprocessing/jargon.py ADDED
@@ -0,0 +1,205 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Kos-specific jargon dictionary untuk Indonesian preprocessing.
2
+
3
+ COURSE REQUIREMENT: ≥100 entries domain-specific (drop rubric Preprocessing 15%
4
+ kalau cuma pakai pipeline Sastrawi default tanpa custom dict).
5
+
6
+ Mentor menyediakan ~105 baseline entries di file ini. Tim Anggota B (Preprocessing
7
+ Engineer) WAJIB tambah minimal 20-30 lagi dari hasil EDA, terutama dari:
8
+ - Slang kos spesifik Bandar Lampung yang ditemukan saat scrape (cek
9
+ `data/raw/mamikos.jsonl` untuk pattern abbreviation baru)
10
+ - Variant kecamatan / area UNILA yang belum tercakup
11
+ - Brand merchandise / produk yang sering muncul (merek kasur, mereka brand AC)
12
+ - Singkatan unik dari listing pengelola tertentu
13
+
14
+ Format: variant (apa adanya di listing) -> canonical (bentuk standar).
15
+ Matching: word-boundary regex, case-insensitive, longest-first ordering.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+
21
+ # =============================================================================
22
+ # Abbreviation umum kos & fasilitas
23
+ # =============================================================================
24
+ ABBREVIATIONS: dict[str, str] = {
25
+ # Fasilitas kamar mandi
26
+ "kmd": "kamar mandi dalam",
27
+ "km dlm": "kamar mandi dalam",
28
+ "wc dlm": "kamar mandi dalam",
29
+ "km dalam": "kamar mandi dalam",
30
+ "wc dalam": "kamar mandi dalam",
31
+ "km luar": "kamar mandi luar",
32
+ "wc luar": "kamar mandi luar",
33
+ # Fasilitas elektronik
34
+ "ac": "air conditioner",
35
+ "tv": "televisi",
36
+ "wf": "wifi",
37
+ # Harga & periode
38
+ "jt": "juta",
39
+ "rb": "ribu",
40
+ "thn": "tahun",
41
+ "bln": "bulan",
42
+ "blnan": "bulanan",
43
+ "thnan": "tahunan",
44
+ "min": "minimum",
45
+ "maks": "maksimum",
46
+ # General Indonesian abbreviation
47
+ "dlm": "dalam",
48
+ "sblm": "sebelum",
49
+ "ssdh": "sesudah",
50
+ "yg": "yang",
51
+ "krn": "karena",
52
+ "tdk": "tidak",
53
+ "gak": "tidak",
54
+ "ngga": "tidak",
55
+ "engga": "tidak",
56
+ "dr": "dari",
57
+ "dgn": "dengan",
58
+ "utk": "untuk",
59
+ "skr": "sekarang",
60
+ "blm": "belum",
61
+ "udh": "sudah",
62
+ "udah": "sudah",
63
+ "byk": "banyak",
64
+ "sdh": "sudah",
65
+ "jln": "jalan",
66
+ "jl": "jalan",
67
+ "rmh": "rumah",
68
+ "ortu": "orang tua",
69
+ "bsk": "besok",
70
+ "tgl": "tanggal",
71
+ "tgl2": "tanggal",
72
+ }
73
+
74
+ # =============================================================================
75
+ # Location variants (Bandar Lampung area sekitar UNILA)
76
+ # =============================================================================
77
+ LOCATIONS: dict[str, str] = {
78
+ # Kecamatan & area kampus
79
+ "gdg meneng": "gedong meneng",
80
+ "gd meneng": "gedong meneng",
81
+ "rjbs": "rajabasa",
82
+ "rj basa": "rajabasa",
83
+ "sumbro": "sumantri brojonegoro",
84
+ "kdtn": "kedaton",
85
+ "wh": "way halim",
86
+ "lab ratu": "labuhan ratu",
87
+ "tj senang": "tanjung senang",
88
+ "tlb": "teluk betung",
89
+ "tjk": "tanjungkarang",
90
+ "bdl": "bandar lampung",
91
+ "blampung": "bandar lampung",
92
+ # Kampus & landmark
93
+ "unila": "universitas lampung",
94
+ "unyila": "universitas lampung",
95
+ "unl": "universitas lampung",
96
+ "fmipa": "fakultas mipa universitas lampung",
97
+ "ft unila": "fakultas teknik universitas lampung",
98
+ "fkip": "fakultas keguruan universitas lampung",
99
+ "feb": "fakultas ekonomi bisnis universitas lampung",
100
+ "uin": "uin raden intan lampung",
101
+ "polnep": "politeknik negeri lampung",
102
+ "itera": "institut teknologi sumatera",
103
+ "kampus a": "kampus utama",
104
+ }
105
+
106
+ # =============================================================================
107
+ # Tipe kos & slang gender
108
+ # =============================================================================
109
+ TYPE_SLANG: dict[str, str] = {
110
+ "cowo": "putra",
111
+ "cowok": "putra",
112
+ "pria": "putra",
113
+ "laki": "putra",
114
+ "lk": "putra",
115
+ "cewe": "putri",
116
+ "cewek": "putri",
117
+ "wanita": "putri",
118
+ "perempuan": "putri",
119
+ "pr": "putri",
120
+ "mix": "campur",
121
+ }
122
+
123
+ # =============================================================================
124
+ # Aturan kos (rules)
125
+ # =============================================================================
126
+ RULES: dict[str, str] = {
127
+ "jamal": "jam malam",
128
+ "jam mlm": "jam malam",
129
+ "no smoking": "dilarang merokok",
130
+ "no smoke": "dilarang merokok",
131
+ "free": "bebas",
132
+ }
133
+
134
+ # =============================================================================
135
+ # Payment & harga terms
136
+ # =============================================================================
137
+ PAYMENT: dict[str, str] = {
138
+ "dp": "uang muka",
139
+ "uang dp": "uang muka",
140
+ "down payment": "uang muka",
141
+ "deposit": "uang jaminan",
142
+ "all in": "termasuk semua",
143
+ "all-in": "termasuk semua",
144
+ "include": "termasuk",
145
+ "exclude": "tidak termasuk",
146
+ }
147
+
148
+ # =============================================================================
149
+ # Bentuk kos (kostan, kontrakan, dll)
150
+ # =============================================================================
151
+ KOS_FORM: dict[str, str] = {
152
+ "kostan": "kos kosan",
153
+ "kost-kostan": "kos kosan",
154
+ "kost2an": "kos kosan",
155
+ "kost kostan": "kos kosan",
156
+ "rumah kost": "kos kosan",
157
+ "kontrakan": "kos kosan",
158
+ "kos eksekutif": "kos eksklusif",
159
+ "ekslusive": "eksklusif",
160
+ "exclusive": "eksklusif",
161
+ "exclusif": "eksklusif",
162
+ "exklusif": "eksklusif",
163
+ "exklusive": "eksklusif",
164
+ "ready": "tersedia",
165
+ "available": "tersedia",
166
+ "ready stock": "tersedia",
167
+ }
168
+
169
+ # =============================================================================
170
+ # Final merged dict
171
+ # =============================================================================
172
+ KOS_JARGON_DICT: dict[str, str] = {
173
+ **ABBREVIATIONS,
174
+ **LOCATIONS,
175
+ **TYPE_SLANG,
176
+ **RULES,
177
+ **PAYMENT,
178
+ **KOS_FORM,
179
+ }
180
+
181
+
182
+ def jargon_count() -> int:
183
+ """Total unique variants di KOS_JARGON_DICT."""
184
+ return len(KOS_JARGON_DICT)
185
+
186
+
187
+ # Rubric requirement
188
+ MIN_REQUIRED = 100
189
+
190
+
191
+ if __name__ == "__main__":
192
+ count = jargon_count()
193
+ print(f"KOS_JARGON_DICT size: {count}")
194
+ print(f"Minimum required for rubric: {MIN_REQUIRED}")
195
+ if count < MIN_REQUIRED:
196
+ print(f"WARNING: tambah {MIN_REQUIRED - count} more entries (tim Anggota B)")
197
+ else:
198
+ print("OK: meets rubric requirement")
199
+ print("\nBreakdown:")
200
+ print(f" ABBREVIATIONS: {len(ABBREVIATIONS)}")
201
+ print(f" LOCATIONS: {len(LOCATIONS)}")
202
+ print(f" TYPE_SLANG: {len(TYPE_SLANG)}")
203
+ print(f" RULES: {len(RULES)}")
204
+ print(f" PAYMENT: {len(PAYMENT)}")
205
+ print(f" KOS_FORM: {len(KOS_FORM)}")
backend/app/preprocessing/normalizer.py ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Atomic normalization: HTML strip, whitespace, lowercase, price extraction.
2
+
3
+ ORDER-SENSITIVE: `extract_prices_inline()` MUST jalan SEBELUM `lowercase()`
4
+ karena regex match `[Rr][Pp]` (case-insensitive), tapi `Rp` capitalized lebih
5
+ robust untuk catch edge case dimana harga tidak dipisah space.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+
12
+ from bs4 import BeautifulSoup
13
+
14
+
15
+ def strip_html(text: str) -> str:
16
+ """Strip HTML tags + decode entities, leave plain text dengan space separator."""
17
+ if not text:
18
+ return ""
19
+ soup = BeautifulSoup(text, "lxml")
20
+ return soup.get_text(separator=" ", strip=True)
21
+
22
+
23
+ def normalize_whitespace(text: str) -> str:
24
+ """Collapse multiple whitespace (space/tab/newline) → single space, strip."""
25
+ if not text:
26
+ return ""
27
+ return re.sub(r"\s+", " ", text).strip()
28
+
29
+
30
+ def lowercase(text: str) -> str:
31
+ """Lowercase. Wajib jalan SETELAH `extract_prices_inline()`."""
32
+ return (text or "").lower()
33
+
34
+
35
+ # =============================================================================
36
+ # Price extraction (inline, return all matches)
37
+ # =============================================================================
38
+ _PRICE_PATTERNS = [
39
+ # Rp 1.250.000 / Rp1.250.000 / Rp 850,000
40
+ (re.compile(r"[Rr][Pp]\s*([\d.,]+)", re.IGNORECASE), "rupiah"),
41
+ # 1.5jt, 1jt, 2,3 jt
42
+ (re.compile(r"(\d+(?:[.,]\d+)?)\s*jt\b", re.IGNORECASE), "juta"),
43
+ # 500k, 500rb, 500 ribu
44
+ (re.compile(r"(\d+(?:[.,]\d+)?)\s*(?:k|rb|ribu)\b", re.IGNORECASE), "ribu"),
45
+ ]
46
+
47
+
48
+ def extract_prices_inline(text: str) -> list[int]:
49
+ """Extract semua harga muncul di text. Return list IDR integer.
50
+
51
+ Untuk kos listing biasanya 1-2 harga (sewa bulanan + uang muka), tapi
52
+ return list untuk fleksibilitas.
53
+ """
54
+ if not text:
55
+ return []
56
+
57
+ prices: list[int] = []
58
+ for pattern, unit in _PRICE_PATTERNS:
59
+ for match in pattern.finditer(text):
60
+ raw = match.group(1)
61
+ try:
62
+ if unit == "rupiah":
63
+ value = int(raw.replace(".", "").replace(",", ""))
64
+ elif unit == "juta":
65
+ value = int(float(raw.replace(",", ".")) * 1_000_000)
66
+ elif unit == "ribu":
67
+ value = int(float(raw.replace(",", ".")) * 1_000)
68
+ else:
69
+ continue
70
+ if value not in prices:
71
+ prices.append(value)
72
+ except ValueError:
73
+ continue
74
+ return prices
backend/app/preprocessing/pipeline.py ADDED
@@ -0,0 +1,187 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Indonesian preprocessing pipeline orchestrator.
2
+
3
+ Stage-based dengan toggle via PipelineConfig — tim Anggota B pakai untuk
4
+ benchmark `BEFORE vs AFTER tiap stage` di notebook 02_preprocessing_experiment.
5
+
6
+ Analogi Laravel: ini seperti chain of Middleware. Request (raw text) lewat
7
+ chain, tiap middleware transform.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import re
13
+ from dataclasses import dataclass, field
14
+ from typing import Optional
15
+
16
+ from .jargon import KOS_JARGON_DICT
17
+ from .normalizer import (
18
+ extract_prices_inline,
19
+ lowercase,
20
+ normalize_whitespace,
21
+ strip_html,
22
+ )
23
+ from .spelling import correct_spelling
24
+ from .stemmer import SastrawiStemmer
25
+ from .stopwords import StopwordRemover
26
+ from .tokenizer import simple_tokenize
27
+
28
+
29
+ @dataclass
30
+ class PipelineConfig:
31
+ """Toggle setiap stage untuk experiment.
32
+
33
+ Tim Anggota B: pakai config ini di notebooks/02_preprocessing_experiment.ipynb
34
+ untuk benchmark IR metric BEFORE vs AFTER tiap stage.
35
+ """
36
+ strip_html: bool = True
37
+ normalize_whitespace: bool = True
38
+ extract_prices: bool = True # always run — non-destructive
39
+ lowercase: bool = True
40
+ apply_jargon_dict: bool = True
41
+ correct_spelling: bool = True
42
+ tokenize: bool = True
43
+ remove_stopwords: bool = True
44
+ stem: bool = True
45
+ custom_stopwords: list[str] = field(default_factory=list)
46
+
47
+
48
+ @dataclass
49
+ class PipelineResult:
50
+ """Hasil preprocessing dengan tracking per-stage."""
51
+ raw: str
52
+ processed: str
53
+ tokens: list[str]
54
+ extracted_prices: list[int] = field(default_factory=list)
55
+ stages_applied: list[str] = field(default_factory=list)
56
+ # Snapshot output tiap stage (untuk visualisasi /api/preprocess).
57
+ # Diisi hanya kalau process(trace=True) — list of {stage, output}.
58
+ trace: list[dict] = field(default_factory=list)
59
+
60
+
61
+ class PreprocessingPipeline:
62
+ """Indonesian preprocessing pipeline untuk kos listings.
63
+
64
+ Sastrawi factory + stopword set di-init sekali (heavy) — instance shared
65
+ antar dokumen. Untuk batch processing, instantiate sekali, panggil
66
+ `process()` per dokumen.
67
+ """
68
+
69
+ def __init__(
70
+ self,
71
+ config: Optional[PipelineConfig] = None,
72
+ jargon_dict: Optional[dict[str, str]] = None,
73
+ ):
74
+ self.config = config or PipelineConfig()
75
+ self.jargon_dict = jargon_dict if jargon_dict is not None else KOS_JARGON_DICT
76
+ # Lazy init heavy components
77
+ self._stemmer: Optional[SastrawiStemmer] = (
78
+ SastrawiStemmer() if self.config.stem else None
79
+ )
80
+ self._stopword_remover: Optional[StopwordRemover] = (
81
+ StopwordRemover(custom=self.config.custom_stopwords)
82
+ if self.config.remove_stopwords
83
+ else None
84
+ )
85
+ # Pre-compile jargon patterns (sorted longest-first untuk hindari
86
+ # "dlm" match dulu sebelum "km dlm")
87
+ self._jargon_patterns: list[tuple[re.Pattern[str], str]] = []
88
+ if self.jargon_dict:
89
+ sorted_variants = sorted(self.jargon_dict.keys(), key=len, reverse=True)
90
+ for variant in sorted_variants:
91
+ pattern = re.compile(
92
+ r"\b" + re.escape(variant) + r"\b", re.IGNORECASE
93
+ )
94
+ self._jargon_patterns.append((pattern, self.jargon_dict[variant]))
95
+
96
+ def process(self, text: str, trace: bool = False) -> PipelineResult:
97
+ """Run full pipeline. Return PipelineResult dengan tracking stages.
98
+
99
+ trace=True merekam snapshot output tiap stage (untuk visualisasi
100
+ step-by-step di /api/preprocess); default False supaya jalur serving
101
+ tidak bayar overhead.
102
+ """
103
+ result = PipelineResult(raw=text, processed=text or "", tokens=[])
104
+ if not text:
105
+ return result
106
+
107
+ current = text
108
+
109
+ def snap(stage: str, output) -> None:
110
+ if trace:
111
+ result.trace.append({"stage": stage, "output": output})
112
+
113
+ # Stage 1: HTML strip
114
+ if self.config.strip_html:
115
+ current = strip_html(current)
116
+ result.stages_applied.append("strip_html")
117
+ snap("strip_html", current)
118
+
119
+ # Stage 2: Whitespace normalize
120
+ if self.config.normalize_whitespace:
121
+ current = normalize_whitespace(current)
122
+ result.stages_applied.append("normalize_whitespace")
123
+ snap("normalize_whitespace", current)
124
+
125
+ # Stage 3: Price extraction (PENTING — sebelum lowercase)
126
+ if self.config.extract_prices:
127
+ result.extracted_prices = extract_prices_inline(current)
128
+ result.stages_applied.append("extract_prices")
129
+ snap("extract_prices", result.extracted_prices)
130
+
131
+ # Stage 4: Lowercase
132
+ if self.config.lowercase:
133
+ current = lowercase(current)
134
+ result.stages_applied.append("lowercase")
135
+ snap("lowercase", current)
136
+
137
+ # Stage 5: Jargon dictionary substitution
138
+ if self.config.apply_jargon_dict and self._jargon_patterns:
139
+ current = self._apply_jargon(current)
140
+ result.stages_applied.append("apply_jargon_dict")
141
+ snap("apply_jargon_dict", current)
142
+
143
+ # Stage 6: Spelling correction
144
+ if self.config.correct_spelling:
145
+ current = correct_spelling(current)
146
+ result.stages_applied.append("correct_spelling")
147
+ snap("correct_spelling", current)
148
+
149
+ # Stage 7: Tokenize
150
+ if self.config.tokenize:
151
+ tokens = simple_tokenize(current)
152
+ result.stages_applied.append("tokenize")
153
+ snap("tokenize", list(tokens))
154
+ else:
155
+ tokens = current.split()
156
+
157
+ # Stage 8: Stopword removal
158
+ if self.config.remove_stopwords and self._stopword_remover:
159
+ tokens = self._stopword_remover.remove(tokens)
160
+ result.stages_applied.append("remove_stopwords")
161
+ snap("remove_stopwords", list(tokens))
162
+
163
+ # Stage 9: Stem
164
+ if self.config.stem and self._stemmer:
165
+ tokens = [self._stemmer.stem(tok) for tok in tokens]
166
+ result.stages_applied.append("stem")
167
+ snap("stem", list(tokens))
168
+
169
+ result.tokens = tokens
170
+ result.processed = " ".join(tokens)
171
+ return result
172
+
173
+ def _apply_jargon(self, text: str) -> str:
174
+ """Replace jargon variants → canonical, longest-first dengan word boundary."""
175
+ for pattern, canonical in self._jargon_patterns:
176
+ text = pattern.sub(canonical, text)
177
+ return text
178
+
179
+
180
+ def preprocess(text: str, config: Optional[PipelineConfig] = None) -> str:
181
+ """Shorthand: run pipeline dengan config default, return processed string.
182
+
183
+ Untuk batch lebih efisien, instantiate PreprocessingPipeline sekali,
184
+ panggil `.process()` per dokumen.
185
+ """
186
+ pipeline = PreprocessingPipeline(config)
187
+ return pipeline.process(text).processed
backend/app/preprocessing/spelling.py ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Domain-specific spelling correction untuk kos listings.
2
+
3
+ Approach: simple typo dictionary lookup (no statistical model untuk simplicity).
4
+ Tim Anggota B: tambah typo umum yang ditemukan saat scrape data.
5
+
6
+ Alternative future enhancement (Week 3 kalau ada waktu):
7
+ - Edit distance (Levenshtein) untuk catch typos baru
8
+ - Bigram language model untuk context-aware correction
9
+ - Library: pyspellchecker, atau custom n-gram model
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import re
15
+
16
+
17
+ # Common typos di kos listings (curated baseline)
18
+ TYPO_FIXES: dict[str, str] = {
19
+ # Fasilitas typos
20
+ "fasilitsa": "fasilitas",
21
+ "fasiltas": "fasilitas",
22
+ "fasilitas2": "fasilitas",
23
+ "fasilitas-fasilitas": "fasilitas",
24
+ # Lokasi typos
25
+ "strategis": "strategis", # canonical (kept untuk dokumentasi)
26
+ "stratejis": "strategis",
27
+ "lokasinyy": "lokasinya",
28
+ "lokasinya2": "lokasinya",
29
+ # Eksklusif variants (sering muncul di judul listing + query)
30
+ "ekslusif": "eksklusif",
31
+ "ekslusive": "eksklusif",
32
+ "exclusive": "eksklusif",
33
+ "exlusive": "eksklusif",
34
+ # Kondisi
35
+ "bagys": "bagus",
36
+ "barangny": "barangnya",
37
+ "rapih": "rapi",
38
+ "luas2": "luas",
39
+ "bersih2": "bersih",
40
+ # Bahasa gaul yang sering muncul
41
+ "pny": "punya",
42
+ "pnya": "punya",
43
+ "ada2": "ada",
44
+ }
45
+
46
+
47
+ # Filter out self-mappings (no-op) saat build pattern
48
+ _REAL_FIXES = {variant: fix for variant, fix in TYPO_FIXES.items() if variant != fix}
49
+
50
+
51
+ # Pre-compile patterns sekali (mahal kalau per-call)
52
+ _TYPO_PATTERNS: list[tuple[re.Pattern[str], str]] = [
53
+ (re.compile(r"\b" + re.escape(typo) + r"\b", re.IGNORECASE), fix)
54
+ for typo, fix in _REAL_FIXES.items()
55
+ ]
56
+
57
+
58
+ def correct_spelling(text: str) -> str:
59
+ """Replace common typos dengan bentuk standar (word-boundary regex)."""
60
+ if not text:
61
+ return text
62
+ for pattern, fix in _TYPO_PATTERNS:
63
+ text = pattern.sub(fix, text)
64
+ return text
65
+
66
+
67
+ if __name__ == "__main__":
68
+ print(f"TYPO_FIXES: {len(TYPO_FIXES)} total, {len(_REAL_FIXES)} real fixes")
backend/app/preprocessing/stemmer.py ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Sastrawi stemmer wrapper dengan LRU cache.
2
+
3
+ Sastrawi stemming bisa lambat untuk corpus besar (3000+ dokumen × N tokens) —
4
+ LRU cache di-method `stem()` mempercepat repeated tokens.
5
+
6
+ Factory di-init sekali (heavy, ~100ms), instance shared antar dokumen.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from functools import lru_cache
12
+
13
+ from Sastrawi.Stemmer.StemmerFactory import StemmerFactory
14
+
15
+
16
+ class SastrawiStemmer:
17
+ """Wrapper Sastrawi `StemmerFactory().create_stemmer()` dengan LRU cache.
18
+
19
+ Cache size 10k tokens cukup untuk corpus 3000 listings (typical
20
+ vocabulary unique tokens ~5-8k setelah preprocessing).
21
+ """
22
+
23
+ def __init__(self, cache_size: int = 10_000):
24
+ factory = StemmerFactory()
25
+ self._stemmer = factory.create_stemmer()
26
+ # Wrap stem method dengan LRU cache
27
+ self.stem = lru_cache(maxsize=cache_size)(self._stem_uncached)
28
+
29
+ def _stem_uncached(self, word: str) -> str:
30
+ """Stem single word tanpa cache (called via cached wrapper)."""
31
+ if not word:
32
+ return word
33
+ return self._stemmer.stem(word)
34
+
35
+ def stem_tokens(self, tokens: list[str]) -> list[str]:
36
+ """Stem list of tokens (gunakan cache per-token)."""
37
+ return [self.stem(tok) for tok in tokens]
38
+
39
+ def cache_info(self):
40
+ """Debug: LRU cache hit/miss stats."""
41
+ return self.stem.cache_info() # type: ignore[attr-defined]
backend/app/preprocessing/stopwords.py ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Indonesian stopword removal: Sastrawi default + custom domain stopwords.
2
+
3
+ Custom stopwords penting untuk domain kos — kata yang terlalu sering muncul
4
+ turun jadi noise (low IR signal). Tim Anggota B: setelah Anggota A scrape,
5
+ jalanin frequency analysis di `notebooks/01_eda.ipynb`:
6
+
7
+ from collections import Counter
8
+ all_tokens = [tok for listing in corpus for tok in listing['deskripsi'].split()]
9
+ Counter(all_tokens).most_common(50)
10
+
11
+ Top-50 paling sering biasanya stopword domain — tambah ke
12
+ `DEFAULT_CUSTOM_STOPWORDS` kalau memang noise (bukan informative term).
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from typing import Optional
18
+
19
+ from Sastrawi.StopWordRemover.StopWordRemoverFactory import StopWordRemoverFactory
20
+
21
+
22
+ # Domain stopwords baseline (kos-specific). Tim Anggota B: extend dari EDA.
23
+ DEFAULT_CUSTOM_STOPWORDS: list[str] = [
24
+ # Form kos
25
+ "kos", "kost", "kosan", "kostan", "kos-kosan",
26
+ # Kata generic listing
27
+ "kamar",
28
+ "sewa", "harga",
29
+ "tersedia", "ready",
30
+ "info", "informasi",
31
+ "hubungi", "kontak",
32
+ # Kata penghubung tambahan (Sastrawi sudah handle, tapi extra safety)
33
+ # "untuk", "yang", "ke", "di" # already di Sastrawi default
34
+ ]
35
+
36
+
37
+ class StopwordRemover:
38
+ """Wrapper Sastrawi StopWordRemover + custom set."""
39
+
40
+ def __init__(
41
+ self,
42
+ custom: Optional[list[str]] = None,
43
+ use_sastrawi_default: bool = True,
44
+ ):
45
+ if use_sastrawi_default:
46
+ factory = StopWordRemoverFactory()
47
+ self.sastrawi_stopwords: set[str] = set(factory.get_stop_words())
48
+ else:
49
+ self.sastrawi_stopwords = set()
50
+
51
+ self.custom_stopwords: set[str] = set(custom or DEFAULT_CUSTOM_STOPWORDS)
52
+ self.all_stopwords: set[str] = (
53
+ self.sastrawi_stopwords | self.custom_stopwords
54
+ )
55
+
56
+ def remove(self, tokens: list[str]) -> list[str]:
57
+ """Filter tokens — drop yang termasuk stopword."""
58
+ return [tok for tok in tokens if tok.lower() not in self.all_stopwords]
59
+
60
+ def is_stopword(self, word: str) -> bool:
61
+ return word.lower() in self.all_stopwords
62
+
63
+ def count(self) -> dict[str, int]:
64
+ return {
65
+ "sastrawi": len(self.sastrawi_stopwords),
66
+ "custom": len(self.custom_stopwords),
67
+ "total": len(self.all_stopwords),
68
+ }