1 line
14 KiB
JSON
1 line
14 KiB
JSON
{"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"}}}} |