AI Generation

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

SumberVariasiTujuan
SedangMudahBuat versi lebih mudah dari basis
SedangSulitBuat versi lebih sulit dari basis
SedangSedangBuat 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:

ModelUse CaseBiaya
Qwen 2.5 32BSeimbang (default)Medium
Mistral SmallLow cost, simple questionRendah
Llama 3.3 70BPremium, soal kompleksTinggi

Pricing diambil dinamis dari API OpenRouter (get_model_pricing).

Prompt Template

Fungsi get_prompt_template membangun prompt standard:

python
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

text
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

python
def parse_ai_response(response_text: str) -> Optional[GeneratedQuestion]:
    """Parse response OpenRouter jadi GeneratedQuestion object."""
    cleaned = response_text.strip()

    # Coba beberapa strategi extract JSON:
    candidates = _extract_json_candidates(cleaned)
    # 1. JSON murni
    # 2. JSON di code block ```json ... ```
    # 3. JSON embedded di teks

    for candidate in candidates:
        try:
            data = json.loads(candidate)
            return validate_and_create_question(data)
        except json.JSONDecodeError:
            continue

    return None  # Gagal parse

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

python
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

python
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:

  1. Hemat biaya API — jangan generate soal yang sudah ada
  2. 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

python
ai_generation_enabled: bool  # di Tryout
NilaiPerilaku
false (default)Modul AI tidak dipanggil; reuse saja soal existing
trueCache miss → call OpenRouter untuk generate baru

Admin toggle on/off berdasarkan budget/cost consideration.

Tracking Usage & Cost

python
async def build_usage_info(raw_usage: dict, model_id: str) -> AIUsageInfo:
    """Parse OpenRouter usage response + hitung cost."""
    # raw_usage berisi: prompt_tokens, completion_tokens, total_tokens
    # pricing diambil dari get_model_pricing(model_id)
    # cost = (prompt_tokens × input_price) + (completion_tokens × output_price)

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

FungsiTujuan
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

KasusPenanganan
OpenRouter timeout / 429 rate limitRetry dengan backoff, lalu raise error
Response bukan JSON validparse_ai_response return None, admin bisa re-request
Option count mismatch dengan basisgenerated_matches_basis_options return False, reject
correct bukan salah satu labelvalidate_and_create_question return None
basis_item.level != 'sedang'Reject (basis wajib Sedang)
Cache hit tapi user sudah jawab semuaCache miss → generate baru

Dependency

  • openai SDK (kompatibel dengan API OpenRouter)
  • httpx async HTTP client
  • Model: Item, AIGenerationRun, UserAnswer

Bacaan Lanjutan

Last updated Jul 25, 2026