From ccd0ffb8e98211d233eb09b738e0751edd3aa12b Mon Sep 17 00:00:00 2001 From: dwindown Date: Wed, 1 Apr 2026 21:38:46 +0700 Subject: [PATCH] Add demo seed and save flow to AI playground --- app/admin_web.py | 304 ++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 301 insertions(+), 3 deletions(-) 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"{head}{body}
" +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'' 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}
@@ -504,7 +655,9 @@ def _ai_form_body(
-

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)