Detail modul ai_generation.py - variasi soal via OpenRouter, caching & reuse per user, prompt template.
Modul: AI Generation
Source: backend/app/services/ai_generation.py
Lihat juga: Alur AI Question Generation
Tanggung Jawab
Generate varian soal dengan tingkat kesulitan berbeda (Mudah/Sulit) dari basis soal Sedang, via API OpenRouter:
- 1 request = 1 soal (bukan batch)
- Caching & reuse per
(tryout, slot, level) + per user
- Playground admin (preview tanpa simpan)
- Tracking cost & usage token
Sumber Soal & Variasi
| Sumber | Variasi | Tujuan |
|---|
| Sedang | Mudah | Buat versi lebih mudah dari basis |
| Sedang | Sulit | Buat versi lebih sulit dari basis |
| Sedang | Sedang | Buat alternatif di level yang sama |
Basis selalu dari level Sedang — karena Sedang adalah "ground truth" yang dipakai untuk kalibrasi p.
Model AI yang Didukung
via OpenRouter:
| Model | Use Case | Biaya |
|---|
| Qwen 2.5 32B | Seimbang (default) | Medium |
| Mistral Small | Low cost, simple question | Rendah |
| Llama 3.3 70B | Premium, soal kompleks | Tinggi |
Pricing diambil dinamis dari API OpenRouter (get_model_pricing).
Prompt Template
Fungsi get_prompt_template membangun prompt standard:
def get_prompt_template(
basis_stem: str,
basis_options: Dict[str, str],
basis_correct: str,
basis_explanation: Optional[str],
target_level: Literal["mudah", "sulit"],
operator_notes: Optional[str] = None,
) -> str:
Struktur Prompt
You are an educational content creator...
Given a "Sedang" question, generate a new question at a different difficulty level.
BASIS QUESTION (Sedang level):
Question: {basis_stem}
Options: {basis_options}
Correct Answer: {basis_correct}
Explanation: {basis_explanation}
TASK:
Generate 1 new question that is {level_desc} than the basis.
REQUIREMENTS:
1. Keep the SAME topic/subject matter
2. Use similar context and terminology
3. Create exactly {option_count} answer options
4. Preserve basis option count and labels (jangan merge/rename)
5. Only ONE correct answer
6. Include explanation
7. Make it noticeably {level_desc}
8. Preserve inline HTML style dari basis
OUTPUT FORMAT:
Return ONLY valid JSON:
{"stem": "...", "options": {"A":"...","B":"...","C":"...","D":"..."},
"correct": "A", "explanation": "..."}
Operator Notes
Admin bisa tambahkan catatan style opsional (operator_notes) — mis. "Gunakan bahasa informal" atau "Hindari jargon medis". Akan di-inject sebagai ADDITIONAL OPERATOR NOTES block.
Parsing Response
def parse_ai_response(response_text: str) -> Optional[GeneratedQuestion]:
"""Parse response OpenRouter jadi GeneratedQuestion object."""
cleaned = response_text.strip()
candidates = _extract_json_candidates(cleaned)
for candidate in candidates:
try:
data = json.loads(candidate)
return validate_and_create_question(data)
except json.JSONDecodeError:
continue
return None
Helper penting:
_extract_json_candidates(text) — extract kandidat JSON dari berbagai format
_extract_first_balanced_object(text) — cari JSON dengan bracket matching
_sanitize_json_candidate(candidate) — bersihkan trailing comma, dll.
_normalize_options(raw_options) — pastikan format {A: "...", B: "..."}
_normalize_correct_answer(raw_correct) — uppercase + validasi label
Validasi Soal
def validate_and_create_question(data: Dict) -> Optional[GeneratedQuestion]:
"""
Validasi:
- stem tidak kosong
- options minimal 2 (typically 4)
- correct adalah salah satu label options
- explanation ada (recommended)
"""
Cross-check tambahan:
generated_matches_basis_options(generated, basis_item) — pastikan label dan jumlah option sama dengan basis
Caching & Reuse
async def check_cache_reuse(
db, tryout_id, slot, target_level, wp_user_id, website_id
) -> Optional[Item]:
"""
Cek apakah sudah ada soal AI di (tryout, slot, target_level) yang
BELUM pernah dijawab user ini.
Returns:
- Item jika ditemukan cache hit yang belum dipakai user
- None jika cache miss
"""
Strategi Reuse
flowchart TD
A["Permintaan soal level X untuk user Y"] --> B["Cari Item existing di tryout+slot+level X"]
B -->|"Tidak ada"| C["Cache MISS - Generate baru via AI"]
B -->|"Ada satu atau lebih"| D{"User Y pernah jawab Item ini?"}
D -->|"Belum"| E["Cache HIT - Return Item"]
D -->|"Sudah pernah"| F{"Ada Item lain di level X yang belum dijawab?"}
F -->|"Ada"| E
F -->|"Tidak ada"| C
Tujuan:
- Hemat biaya API — jangan generate soal yang sudah ada
- Personalisasi — user tidak melihat soal yang sama di attempt berbeda
Endpoint Playground
Admin playground via POST /ai/generate mendukung:
- Preview mode: generate tanpa save ke DB
- Re-request: unlimited retry sampai puas
- Edit before save: admin bisa edit konten sebelum commit
- Save mode: commit ke DB dengan
generated_by='ai', link basis_item_id
Tujuan: build trust admin terhadap kualitas AI sebelum diaktifkan untuk siswa.
Toggle Global
ai_generation_enabled: bool
| Nilai | Perilaku |
|---|
false (default) | Modul AI tidak dipanggil; reuse saja soal existing |
true | Cache miss → call OpenRouter untuk generate baru |
Admin toggle on/off berdasarkan budget/cost consideration.
Tracking Usage & Cost
async def build_usage_info(raw_usage: dict, model_id: str) -> AIUsageInfo:
"""Parse OpenRouter usage response + hitung cost."""
def combine_usage(usages: list[AIUsageInfo]) -> AIUsageInfo:
"""Aggregate usage dari multiple AI call (untuk batch)."""
Disimpan di tabel ai_generation_runs (lihat Data Models) untuk audit dan cost analysis.
Fungsi Publik
| Fungsi | Tujuan |
|---|
get_prompt_template(...) | Build prompt standard |
parse_ai_response(text) | Parse JSON response AI |
validate_and_create_question(data) | Validasi struktur GeneratedQuestion |
generated_matches_basis_options(generated, basis) | Cross-check konsistensi option |
call_openrouter_api(...) | HTTP call ke OpenRouter |
generate_question(...) | End-to-end: prompt → call → parse → validate |
check_cache_reuse(...) | Cek DB untuk reuse |
get_model_pricing(model_id) | Ambil pricing dinamis dari OpenRouter |
build_usage_info(raw_usage, model_id) | Hitung cost per call |
combine_usage(usages) | Aggregate usage batch |
Edge Cases
| Kasus | Penanganan |
|---|
| OpenRouter timeout / 429 rate limit | Retry dengan backoff, lalu raise error |
| Response bukan JSON valid | parse_ai_response return None, admin bisa re-request |
| Option count mismatch dengan basis | generated_matches_basis_options return False, reject |
correct bukan salah satu label | validate_and_create_question return None |
basis_item.level != 'sedang' | Reject (basis wajib Sedang) |
| Cache hit tapi user sudah jawab semua | Cache miss → generate baru |
Dependency
openai SDK (kompatibel dengan API OpenRouter)
httpx async HTTP client
- Model:
Item, AIGenerationRun, UserAnswer
Bacaan Lanjutan