diff --git a/app/admin_web.py b/app/admin_web.py
index 3cc1651..c149ab3 100644
--- a/app/admin_web.py
+++ b/app/admin_web.py
@@ -9,6 +9,7 @@ import secrets
import uuid
from dataclasses import dataclass
from html import escape
+import json
from typing import Any
import aioredis
@@ -20,11 +21,12 @@ from starlette.status import HTTP_303_SEE_OTHER, HTTP_401_UNAUTHORIZED
from app.core.config import get_settings
from app.database import get_db
-from app.models import Item, Session, Tryout
+from app.models import Item, Session, Tryout, Website
from app.services.ai_generation import (
SUPPORTED_MODELS,
generate_question,
get_ai_stats,
+ save_ai_question,
validate_ai_model,
)
from app.services.irt_calibration import get_calibration_status
@@ -156,6 +158,12 @@ def _render_admin_page(title: str, page_title: str, body: str) -> HTMLResponse:
table {{ width: 100%; border-collapse: collapse; margin-top: 16px; background: #fff; border-radius: 12px; overflow: hidden; }}
th, td {{ padding: 12px 14px; border-bottom: 1px solid #e5e7eb; text-align: left; vertical-align: top; font-size: 14px; }}
th {{ background: #f8fafc; font-weight: 600; }}
+ input, select, textarea {{ width: 100%; box-sizing: border-box; border: 1px solid #cbd5e1; border-radius: 10px; padding: 12px 14px; font-size: 15px; }}
+ label {{ display: block; font-size: 14px; font-weight: 600; margin: 14px 0 8px; }}
+ button {{ border: 0; border-radius: 10px; padding: 12px 14px; background: #0f172a; color: #fff; font-size: 15px; font-weight: 600; cursor: pointer; }}
+ .actions {{ display: flex; gap: 12px; flex-wrap: wrap; margin-top: 18px; }}
+ .error {{ margin: 0 0 16px; padding: 12px 14px; border-radius: 10px; background: #fef2f2; color: #991b1b; border: 1px solid #fecaca; }}
+ .success {{ margin: 0 0 16px; padding: 12px 14px; border-radius: 10px; background: #ecfdf5; color: #166534; border: 1px solid #86efac; }}
.muted {{ color: #64748b; font-size: 14px; }}
@@ -193,6 +201,93 @@ def _table(headers: list[str], rows: list[list[Any]]) -> str:
return f"
"
+def _truncate(text: str | None, max_length: int = 120) -> str:
+ if not text:
+ return ""
+ if len(text) <= max_length:
+ return text
+ return f"{text[: max_length - 3]}..."
+
+
+async def _basis_items_for_playground(db: AsyncSession, limit: int = 20) -> list[Item]:
+ result = await db.execute(
+ select(Item)
+ .where(Item.level == "sedang")
+ .order_by(Item.created_at.desc(), Item.id.desc())
+ .limit(limit)
+ )
+ return list(result.scalars().all())
+
+
+async def _find_or_create_demo_basis_item(db: AsyncSession) -> Item:
+ result = await db.execute(
+ select(Item)
+ .where(
+ Item.level == "sedang",
+ Item.generated_by == "manual",
+ Item.tryout_id == "demo-tryout",
+ )
+ .order_by(Item.id.asc())
+ .limit(1)
+ )
+ existing_item = result.scalar_one_or_none()
+ if existing_item:
+ return existing_item
+
+ website_result = await db.execute(
+ select(Website).where(Website.site_url == "https://demo.local").limit(1)
+ )
+ website = website_result.scalar_one_or_none()
+ if website is None:
+ website = Website(site_url="https://demo.local", site_name="Demo Website")
+ db.add(website)
+ await db.flush()
+
+ tryout_result = await db.execute(
+ select(Tryout)
+ .where(Tryout.website_id == website.id, Tryout.tryout_id == "demo-tryout")
+ .limit(1)
+ )
+ tryout = tryout_result.scalar_one_or_none()
+ if tryout is None:
+ tryout = Tryout(
+ website_id=website.id,
+ tryout_id="demo-tryout",
+ name="Demo AI Playground Tryout",
+ description="Seed data for the AI playground.",
+ scoring_mode="ctt",
+ selection_mode="fixed",
+ normalization_mode="static",
+ ai_generation_enabled=True,
+ )
+ db.add(tryout)
+ await db.flush()
+
+ item = Item(
+ tryout_id=tryout.tryout_id,
+ website_id=website.id,
+ slot=1,
+ level="sedang",
+ stem="Sebuah toko memberi diskon 20% untuk sebuah tas. Jika harga setelah diskon adalah Rp240.000, berapakah harga tas sebelum diskon?",
+ options={
+ "A": "Rp260.000",
+ "B": "Rp300.000",
+ "C": "Rp320.000",
+ "D": "Rp360.000",
+ },
+ correct_answer="B",
+ explanation="Harga setelah diskon 20% berarti 80% dari harga awal. Jadi harga awal = 240.000 / 0,8 = 300.000.",
+ generated_by="manual",
+ calibrated=False,
+ calibration_sample_size=0,
+ )
+ db.add(item)
+ await db.flush()
+ await db.commit()
+ await db.refresh(item)
+ return item
+
+
@router.get("", include_in_schema=False)
@router.get("/", include_in_schema=False)
async def admin_root(request: Request):
@@ -463,33 +558,89 @@ def _ai_form_body(
key_configured: bool,
stats: dict[str, Any],
error: str | None = None,
+ success: str | None = None,
result: dict[str, Any] | None = None,
+ basis_items: list[Item] | None = None,
basis_item_id: str = "",
target_level: str = "mudah",
ai_model: str = settings.OPENROUTER_MODEL_QWEN,
) -> str:
error_html = f'{escape(error)}
' if error else ""
+ success_html = f'{escape(success)}
' if success else ""
options_html = "".join(
f'{escape(label)} '
for model, label in SUPPORTED_MODELS.items()
)
+ basis_items = basis_items or []
+ basis_rows = [
+ [
+ item.id,
+ item.tryout_id,
+ item.slot,
+ item.website_id,
+ _truncate(item.stem, 90),
+ ]
+ for item in basis_items
+ ]
+ basis_table = _table(
+ ["Item ID", "Tryout", "Slot", "Website", "Stem"],
+ basis_rows,
+ )
+ seed_callout = ""
+ if not basis_items:
+ seed_callout = """
+
+ No sedang basis items found yet. Seed one demo website, tryout, and basis item to test AI generation immediately.
+
+
+ """
result_html = ""
if result:
options = result.get("options") or {}
+ save_html = ""
+ if result.get("basis_item_id") and not result.get("existing_item_id"):
+ save_html = f"""
+
+ """
+ elif result.get("existing_item_id"):
+ save_html = f"""
+
+ Slot {escape(str(result.get("slot", "")))} already has a {escape(str(result.get("target_level", "")))} item
+ for this tryout. Existing item ID: {escape(str(result.get("existing_item_id")))} .
+
+ """
result_html = f"""
Preview Result
Model: {escape(result.get("ai_model", ""))}
+ Basis Item: #{escape(str(result.get("basis_item_id", "")))} | Tryout: {escape(result.get("tryout_id", ""))} | Slot: {escape(str(result.get("slot", "")))}
Stem: {escape(result.get("stem", ""))}
Options:
{_table(["Key", "Text"], [[key, value] for key, value in options.items()])}
Correct: {escape(result.get("correct", ""))}
Explanation: {escape(result.get("explanation", ""))}
+ {save_html}
"""
return f"""
OpenRouter key configured: {"Yes" if key_configured else "No"}
Total AI-generated items: {stats.get("total_ai_items", 0)}
+ {success_html}
{error_html}
+ {seed_callout}
- This is a preview only. Save-to-database UI is not added yet.
+ Available Sedang Basis Items
+ The generator needs a sedang item. Use one of these IDs, or seed demo data if the table is empty.
+ {basis_table}
{result_html}
"""
@@ -516,7 +669,27 @@ async def ai_playground_view(request: Request, db: AsyncSession = Depends(get_db
return _login_redirect()
stats = await get_ai_stats(db)
- body = _ai_form_body(bool(settings.OPENROUTER_API_KEY), stats)
+ basis_items = await _basis_items_for_playground(db)
+ body = _ai_form_body(bool(settings.OPENROUTER_API_KEY), stats, basis_items=basis_items)
+ return _render_admin_page("AI Playground", "AI Playground", body)
+
+
+@router.post("/ai-playground/seed-demo", include_in_schema=False)
+async def ai_playground_seed_demo(request: Request, db: AsyncSession = Depends(get_db)):
+ admin = await _current_admin(request)
+ if not admin:
+ return _login_redirect()
+
+ demo_item = await _find_or_create_demo_basis_item(db)
+ stats = await get_ai_stats(db)
+ basis_items = await _basis_items_for_playground(db)
+ body = _ai_form_body(
+ bool(settings.OPENROUTER_API_KEY),
+ stats,
+ success=f"Demo basis item is ready: item #{demo_item.id}, tryout {demo_item.tryout_id}, slot {demo_item.slot}.",
+ basis_items=basis_items,
+ basis_item_id=str(demo_item.id),
+ )
return _render_admin_page("AI Playground", "AI Playground", body)
@@ -533,12 +706,14 @@ async def ai_playground_submit(
return _login_redirect()
stats = await get_ai_stats(db)
+ basis_items = await _basis_items_for_playground(db)
if not settings.OPENROUTER_API_KEY:
body = _ai_form_body(
False,
stats,
error="OPENROUTER_API_KEY is not configured in the environment.",
+ basis_items=basis_items,
basis_item_id=str(basis_item_id),
target_level=target_level,
ai_model=ai_model,
@@ -550,6 +725,7 @@ async def ai_playground_submit(
True,
stats,
error="Target level must be mudah or sulit.",
+ basis_items=basis_items,
basis_item_id=str(basis_item_id),
target_level=target_level,
ai_model=ai_model,
@@ -561,6 +737,7 @@ async def ai_playground_submit(
True,
stats,
error="Unsupported AI model.",
+ basis_items=basis_items,
basis_item_id=str(basis_item_id),
target_level=target_level,
ai_model=ai_model,
@@ -574,6 +751,7 @@ async def ai_playground_submit(
True,
stats,
error=f"Basis item not found: {basis_item_id}",
+ basis_items=basis_items,
basis_item_id=str(basis_item_id),
target_level=target_level,
ai_model=ai_model,
@@ -585,6 +763,7 @@ async def ai_playground_submit(
True,
stats,
error=f"Basis item must be sedang level, got: {basis_item.level}",
+ basis_items=basis_items,
basis_item_id=str(basis_item_id),
target_level=target_level,
ai_model=ai_model,
@@ -601,21 +780,39 @@ async def ai_playground_submit(
True,
stats,
error="AI generation failed. Check OPENROUTER_API_KEY, model availability, and server logs.",
+ basis_items=basis_items,
basis_item_id=str(basis_item_id),
target_level=target_level,
ai_model=ai_model,
)
return _render_admin_page("AI Playground", "AI Playground", body)
+ existing_item_result = await db.execute(
+ select(Item.id).where(
+ Item.tryout_id == basis_item.tryout_id,
+ Item.website_id == basis_item.website_id,
+ Item.slot == basis_item.slot,
+ Item.level == target_level,
+ )
+ )
+ existing_item_id = existing_item_result.scalar_one_or_none()
+
body = _ai_form_body(
True,
stats,
+ basis_items=basis_items,
result={
+ "basis_item_id": basis_item.id,
+ "tryout_id": basis_item.tryout_id,
+ "website_id": basis_item.website_id,
+ "slot": basis_item.slot,
+ "target_level": target_level,
"stem": generated.stem,
"options": generated.options,
"correct": generated.correct,
"explanation": generated.explanation or "",
"ai_model": ai_model,
+ "existing_item_id": existing_item_id,
},
basis_item_id=str(basis_item_id),
target_level=target_level,
@@ -624,6 +821,107 @@ async def ai_playground_submit(
return _render_admin_page("AI Playground", "AI Playground", body)
+@router.post("/ai-playground/save", include_in_schema=False)
+async def ai_playground_save(
+ request: Request,
+ db: AsyncSession = Depends(get_db),
+ basis_item_id: int = Form(...),
+ tryout_id: str = Form(...),
+ website_id: int = Form(...),
+ slot: int = Form(...),
+ target_level: str = Form(...),
+ ai_model: str = Form(...),
+ stem: str = Form(...),
+ options_json: str = Form(...),
+ correct: str = Form(...),
+ explanation: str = Form(""),
+):
+ admin = await _current_admin(request)
+ if not admin:
+ return _login_redirect()
+
+ stats = await get_ai_stats(db)
+ basis_items = await _basis_items_for_playground(db)
+
+ if target_level not in {"mudah", "sulit"}:
+ body = _ai_form_body(
+ bool(settings.OPENROUTER_API_KEY),
+ stats,
+ error="Only mudah or sulit generated items can be saved from the playground.",
+ basis_items=basis_items,
+ )
+ return _render_admin_page("AI Playground", "AI Playground", body)
+
+ try:
+ options = json.loads(options_json)
+ except json.JSONDecodeError:
+ body = _ai_form_body(
+ bool(settings.OPENROUTER_API_KEY),
+ stats,
+ error="Generated options payload is invalid.",
+ basis_items=basis_items,
+ )
+ return _render_admin_page("AI Playground", "AI Playground", body)
+
+ existing_result = await db.execute(
+ select(Item.id).where(
+ Item.tryout_id == tryout_id,
+ Item.website_id == website_id,
+ Item.slot == slot,
+ Item.level == target_level,
+ )
+ )
+ existing_item_id = existing_result.scalar_one_or_none()
+ if existing_item_id:
+ body = _ai_form_body(
+ bool(settings.OPENROUTER_API_KEY),
+ stats,
+ error=f"Item already exists at tryout={tryout_id}, slot={slot}, level={target_level} (item #{existing_item_id}).",
+ basis_items=basis_items,
+ )
+ return _render_admin_page("AI Playground", "AI Playground", body)
+
+ from app.schemas.ai import GeneratedQuestion
+
+ item_id = await save_ai_question(
+ generated_data=GeneratedQuestion(
+ stem=stem,
+ options=options,
+ correct=correct,
+ explanation=explanation or None,
+ ),
+ tryout_id=tryout_id,
+ website_id=website_id,
+ basis_item_id=basis_item_id,
+ slot=slot,
+ level=target_level,
+ ai_model=ai_model,
+ db=db,
+ )
+ if not item_id:
+ body = _ai_form_body(
+ bool(settings.OPENROUTER_API_KEY),
+ stats,
+ error="Failed to save generated item. Check server logs for the database error.",
+ basis_items=basis_items,
+ )
+ return _render_admin_page("AI Playground", "AI Playground", body)
+
+ await db.commit()
+ updated_stats = await get_ai_stats(db)
+ updated_basis_items = await _basis_items_for_playground(db)
+ body = _ai_form_body(
+ bool(settings.OPENROUTER_API_KEY),
+ updated_stats,
+ success=f"Generated item saved successfully as item #{item_id}.",
+ basis_items=updated_basis_items,
+ basis_item_id=str(basis_item_id),
+ target_level=target_level,
+ ai_model=ai_model,
+ )
+ return _render_admin_page("AI Playground", "AI Playground", body)
+
+
@router.get("/tryout/list", include_in_schema=False)
@router.get("/item/list", include_in_schema=False)
@router.get("/user/list", include_in_schema=False)