{"repo": "captiveau-playground/mulai-plus", "files": {"apps/ai/README.md": {"ff4136d15af2f80cdb7414465c3b959813c25f56": {"t": 1785846947.0563972, "n": 11143, "h": "724512bc54f3f651", "c": "# πŸ€– MULAI+ AI Service β€” Chatbot Engine\n\nFastAPI service untuk chatbot MULAI+. Menangani LLM chat, tool calling (universitas, prodi, passing grade), answer caching, session management, dan feedback.\n\n## Arsitektur\n\n```\nβ”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”\nβ”‚ FRONTEND (Next.js) β”‚\nβ”‚ chatbot-widget.tsx β”‚\nβ”‚ β”œβ”€β”€ fetch(API_ENDPOINT) β†’ POST /ai/chat (SSE streaming) β”‚\nβ”‚ β”œβ”€β”€ fetch(/ai/quota) β†’ GET /ai/quota (sisa chat) β”‚\nβ”‚ β”œβ”€β”€ fetch(/ai/history) β†’ GET /ai/history (riwayat chat) β”‚\nβ”‚ β”œβ”€β”€ fetch(/ai/feedback) β†’ POST feedback (thumbs up/down) β”‚\nβ”‚ └── fetch(/ai/track/...) β†’ POST tracking β”‚\nβ””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜\n β”‚ POST /ai/chat (x-session-id)\n β–Ό\nβ”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”\nβ”‚ SERVER PROXY (Hono) β”‚\nβ”‚ apps/server/src/index.ts β”‚\nβ”‚ β”œβ”€β”€ CORS middleware β”‚\nβ”‚ β”œβ”€β”€ requireAdmin middleware (/ai/admin/*) β”‚\nβ”‚ β”œβ”€β”€ Public proxy (/ai/*) β†’ forward ke AI service β”‚\nβ”‚ └── Forward: x-user-id, x-session-id, Authorization: Bearer β”‚\nβ””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜\n β”‚ http://ai:8000/api/chat\n β–Ό\nβ”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”\nβ”‚ AI SERVICE (FastAPI / Python) β”‚\nβ”‚ β”‚\nβ”‚ routes.py cache.py β”‚\nβ”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚\nβ”‚ β”‚ POST /api/chat β”‚ β”‚ Exact match β”‚ β”‚\nβ”‚ β”‚ β”œβ”€ Check cache ────┼─ HIT ──▢│ (SHA256 hash) β”‚ β”‚\nβ”‚ β”‚ β”œβ”€ (if miss) quota β”‚ β”‚ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ β”‚\nβ”‚ β”‚ β”œβ”€ Call LLM β”‚ β”‚ β”‚ Fuzzy match β”‚ β”‚\nβ”‚ β”‚ β”œβ”€ Save messages β”‚ β”‚ β”‚ (token overlap + β”‚ β”‚\nβ”‚ β”‚ └─ SSE response β”‚ β”‚ β”‚ SequenceMatcher,β”‚ β”‚\nβ”‚ β”‚ β”‚ β”‚ β”‚ threshold 0.82) β”‚ β”‚\nβ”‚ β”‚ GET /api/quota β”‚ β”‚ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ β”‚\nβ”‚ β”‚ GET /api/history β”‚ β”‚ β”‚ LFU/LRU eviction β”‚ β”‚\nβ”‚ β”‚ POST /api/feedback β”‚ β”‚ β”‚ (max 1000 entry) β”‚ β”‚\nβ”‚ β”‚ POST /api/lead β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚\nβ”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚\nβ”‚ β”‚\nβ”‚ engine/responder.py β”‚\nβ”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚\nβ”‚ β”‚ 1. Build messages (system prompt + history + user) β”‚ β”‚\nβ”‚ β”‚ 2. Call LLM (AsyncOpenAI with tool calling) β”‚ β”‚\nβ”‚ β”‚ 3. Process tool calls (search_universities, etc.) β”‚ β”‚\nβ”‚ β”‚ 4. Call LLM again with tool results β”‚ β”‚\nβ”‚ β”‚ 5. Extract follow-up topics from response β”‚ β”‚\nβ”‚ β”‚ 6. Return (reply, follow_ups, token_usage) β”‚ β”‚\nβ”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚\nβ””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜\n```\n\n---\n\n## Struktur File\n\n```\napps/ai/\nβ”œβ”€β”€ src/\nβ”‚ β”œβ”€β”€ main.py # FastAPI app, CORS, auth middleware, startup\nβ”‚ β”œβ”€β”€ routes.py # API endpoints: chat, quota, history, admin sessions\nβ”‚ β”œβ”€β”€ config.py # Settings (env vars)\nβ”‚ β”œβ”€β”€ schemas.py # Pydantic models (request/response validation)\nβ”‚ β”œβ”€β”€ db.py # DB pool + read-only query helper (tools)\nβ”‚ β”œβ”€β”€ chatbot_db.py # Session, message, feedback, admin DB operations\nβ”‚ β”œβ”€β”€ cache.py # Answer cache: exact + fuzzy match, LFU/LRU\nβ”‚ β”œβ”€β”€ tools.py # LLM tool definitions & handlers (search DB)\nβ”‚ └── engine/\nβ”‚ └── responder.py # LLM wrapper (AsyncOpenAI, tool calling, cost)\nβ”œβ”€β”€ tests/\nβ”‚ β”œβ”€β”€ test_cache.py # 13 tests (normalize, similarity)\nβ”‚ β”œβ”€β”€ test_responder.py # 13 tests (topics, cost, fallback)\nβ”‚ └── test_schemas.py # 17 tests (Pydantic validation)\nβ”œβ”€β”€ requirements.txt\n└── README.md # ← ini\n```\n\n---\n\n## Flow Chat β€” Skenario\n\n### 🟒 Skenario 1: Chat Pertama (Guest, cache miss)\n\n```\nUser: \"Cari universitas negeri di Jakarta\"\n ↓\nWidget: generate session_id β†’ localStorage\n ↓\nWidget: POST /ai/chat (header: x-session-id)\n ↓\nServer: forward ke AI Service (Authorization: Bearer + x-session-id)\n ↓\nAI Service:\n 1. get_or_create_session β†’ session baru (message_count=0)\n 2. cache.get_cached_answer β†’ MISS (pertanyaan pertama)\n 3. _check_session_allowed β†’ GUEST_LIMIT=1, 0 < 1 β†’ OK\n 4. get_history β†’ []\n 5. get_response() β†’ LLM call + tool calling\n β”œβ”€ SYSTEM_PROMPT + user message\n β”œβ”€ LLM decides: perlu data real-time?\n β”‚ YES β†’ call search_universities(\"Jakarta\")\n β”‚ β”‚ β†’ DB query β†’ format hasil\n β”‚ β”‚ β†’ LLM response with results\n β”‚ NO β†’ langsung jawab dari pengetahuan\n β”œβ”€ content = \"Berikut universitas negeri di Jakarta: UI, UNJ...\"\n └─ follow_ups = [\"Info passing grade UI\", \"Jurusan favorit\", ...]\n 6. cache.set_cached_answer() β†’ simpan\n 7. save_message (user + assistant)\n 8. increment_message_count β†’ 1/1\n 9. return SSE stream\n ↓\nWidget: terima SSE β†’ streaming typewriter β†’ tampilkan\n sisa quota = 0\n```\n\n**Hasil:** βœ… Chat sukses, jawaban tampil. Cache terisi, quota guest habis.\n\n---\n\n### 🟒 Skenario 2: Chat Kedua (Guest, cache HIT β€” pertanyaan mirip)\n\n```\nUser: \"Universitas negeri Jakarta\"\n ↓\nAI Service:\n 1. cache.get_cached_answer β†’ HIT (fuzzy match 0.85 β‰₯ 0.82)\n └─ langsung return SSE tanpa LLM, tanpa quota check\n (jawaban dari chat pertama)\n ↓\nWidget: tampilkan jawaban instan (< 50ms)\n```\n\n**Hasil:** βœ… Langsung balas dari cache. Gak kena quota, gak kena LLM cost.\n\n---\n\n### 🟑 Skenario 3: Quota Guest Habis (pertanyaan beda)\n\n```\nUser: \"Info beasiswa LPDP\"\n ↓\nAI Service:\n 1. cache.get_cached_answer β†’ MISS\n 2. quota check: message_count(1) >= GUEST_LIMIT(1) β†’ HABIS\n 3. return JSON {\"reply\": \"Kamu sudah menggunakan chat gratis!...\", \"requires_auth\": true}\n ↓\nWidget: lihat contentType = application/json\n tampilkan AUTH_GATE β†’ button \"Login / Daftar Gratis\"\n klik β†’ redirect ke /login?callbackUrl=...\n```\n\n**Hasil:** βœ… User diarahkan login. Data click tercatat (`/ai/track/login-click`).\n\n---\n\n### 🟑 Skenario 4: Auth user dengan quota override\n\n```\nUser: \"Program mentoring\" (login, quota 5 habis)\n ↓\nAI Service:\n 1. x-user-id terdeteksi β†’ is_auth = True\n 2. get_active_limit β†’ cek credit_limit di session\n β”œβ”€ admin set credit_limit = 10 β†’ pakai 10\n └─ admin set -1 β†’ unlimited\n```\n\n**Hasil:** βœ… Admin override limit via `PUT /api/admin/sessions/{id}/credit`.\n\n---\n\n### πŸ”΄ Skenario 5: User kena ban\n\n```\nUser: \"Halo\"\n ↓\nAI Service:\n 1. is_banned β†’ TRUE\n 2. return {\"reply\": \"Akun kamu telah dibatasi...\"}\n```\n\n**Hasil:** βœ… User diblokir. Admin unban via Portainer.\n\n---\n\n### πŸ”΄ Skenario 6: LLM error / timeout\n\n```\nUser: \"Cari prodi teknik\"\n ↓\nget_response β†’ AsyncOpenAI timeout (30s) β†’ Exception\n ↓\ncatch β†’ random.choice(FALLBACK_REPLIES)\n └─ \"Maaf, layanan sedang sibuk...\" + 3 suggestions\n```\n\n**Hasil:** βœ… Graceful fallback.\n\n---\n\n### πŸ”΄ Skenario 7: Invalid input\n\n```\nUser: \"\" (empty)\n ↓\nFastAPI: Pydantic ValidationError β†’ 422\n ↓\nWidget: catch β†’ tampilkan \"Maaf, terjadi kesalahan\"\n```\n\n**Hasil:** ❌ Request ditolak. Widget tampilkan error.\n\n---\n\n### πŸ”΄ Skenario 8: Cache / DB down\n\n```\nUser: \"Info UI\"\n ↓\ncache.get_cached_answer error (DB timeout)\n ↓\nException β†’ skip cache, langsung call LLM\n```\n\n**Hasil:** βœ… Graceful degradation. Cache offline β†’ fallback ke LLM.\n\n---\n\n## State Machine Widget\n\n```\n β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”\n β”‚ IDLE β”‚\n β”‚ (menunggu input) β”‚\n β””β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜\n β”‚ user mengirim chat\n β–Ό\n β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”\n β”‚ LOADING β”‚\n β”‚ (spinner + \"Mengetik...\")β”‚\n β””β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜\n β”‚\n β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”\n β”‚ β”‚ β”‚\n β–Ό β–Ό β–Ό\n β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”\n β”‚ SSE STREAM β”‚ β”‚ JSON 200 β”‚ β”‚ ERROR β”‚\n β”‚ (text/event- β”‚ β”‚(quota β”‚ β”‚(fetch β”‚\n β”‚ stream) β”‚ β”‚ habis) β”‚ β”‚ gagal) β”‚\n β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜\n β”‚ β”‚ β”‚\n β–Ό β–Ό β–Ό\n β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”\n β”‚ TAMPILKAN β”‚ β”‚ AUTH β”‚ β”‚ TAMPILKANβ”‚\n β”‚ + typewriter β”‚ β”‚ GATE β”‚ β”‚ ERROR β”‚\n β”‚ + follow-up β”‚ β”‚ (login) β”‚ β”‚ MESSAGE β”‚\n β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜\n β”‚\n β–Ό\n β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”\n β”‚ IDLE β”‚\n β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜\n```\n\n---\n\n## Kunci Performa\n\n| Aspek | Strategi |\n|-------|----------|\n| **Cache** | Exact match (SHA256) + fuzzy (token overlap + SequenceMatcher, threshold 0.82) |\n| **Eviction** | LFU (hit_count ASC) + LRU (last_accessed_at ASC). Max 1000 entries |\n| **Quota** | Cache HIT tidak kena quota. Guest limit 1, auth limit 5 (admin override) |\n| **LLM** | `AsyncOpenAI` timeout 30s, max_retries 1. Follow-up dari response, bukan LLM call baru |\n| **Error** | 3 fallback random. Graceful degradation tiap layer |\n\n---\n\n## Setup & Test\n\n```bash\n# Setup\ncd apps/ai\npython3 -m venv .venv\n.venv/bin/pip install -r requirements.txt\n\n# Run (development)\nAI_API_KEY=test-key .venv/bin/uvicorn src.main:app --host 0.0.0.0 --port 8000\n\n# Test\n.venv/bin/python3 -m pytest tests/ -v\n# 43 tests: cache (13), responder (13), schemas (17)\n```\n\n### Environment Variables\n\n| Variable | Default | Keterangan |\n|----------|---------|------------|\n| `AI_API_KEY` | `\"\"` | Shared secret. Wajib diset, kalo kosong semua request 503 |\n| `AI_HOST` | `0.0.0.0` | Bind address |\n| `AI_PORT` | `8000` | Port |\n| `DATABASE_URL` | – | PostgreSQL connection string |\n| `OPENAI_API_KEY` | – | API key LLM provider |\n| `OPENAI_BASE_URL` | `https://opencode.ai/zen/go/v1` | LLM endpoint |\n| `OPENAI_MODEL` | `deepseek-v4-flash` | Model name |\n| `CORS_ORIGIN` | `http://localhost:3001` | Frontend URL untuk CORS |\n\n### Docker\n\n```bash\n# Build\ndocker build -t mulai-ai:local .\n\n# Run\ndocker run -p 8000:8000 --env-file .env mulai-ai:local\n```\n"}}}}