Add demo seed and save flow to AI playground

This commit is contained in:
dwindown
2026-04-01 21:38:46 +07:00
parent 4f35387c71
commit ccd0ffb8e9

View File

@@ -9,6 +9,7 @@ import secrets
import uuid import uuid
from dataclasses import dataclass from dataclasses import dataclass
from html import escape from html import escape
import json
from typing import Any from typing import Any
import aioredis 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.core.config import get_settings
from app.database import get_db 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 ( from app.services.ai_generation import (
SUPPORTED_MODELS, SUPPORTED_MODELS,
generate_question, generate_question,
get_ai_stats, get_ai_stats,
save_ai_question,
validate_ai_model, validate_ai_model,
) )
from app.services.irt_calibration import get_calibration_status 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; }} 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, td {{ padding: 12px 14px; border-bottom: 1px solid #e5e7eb; text-align: left; vertical-align: top; font-size: 14px; }}
th {{ background: #f8fafc; font-weight: 600; }} 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; }} .muted {{ color: #64748b; font-size: 14px; }}
</style> </style>
</head> </head>
@@ -193,6 +201,93 @@ def _table(headers: list[str], rows: list[list[Any]]) -> str:
return f"<table><thead><tr>{head}</tr></thead><tbody>{body}</tbody></table>" return f"<table><thead><tr>{head}</tr></thead><tbody>{body}</tbody></table>"
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)
@router.get("/", include_in_schema=False) @router.get("/", include_in_schema=False)
async def admin_root(request: Request): async def admin_root(request: Request):
@@ -463,33 +558,89 @@ def _ai_form_body(
key_configured: bool, key_configured: bool,
stats: dict[str, Any], stats: dict[str, Any],
error: str | None = None, error: str | None = None,
success: str | None = None,
result: dict[str, Any] | None = None, result: dict[str, Any] | None = None,
basis_items: list[Item] | None = None,
basis_item_id: str = "", basis_item_id: str = "",
target_level: str = "mudah", target_level: str = "mudah",
ai_model: str = settings.OPENROUTER_MODEL_QWEN, ai_model: str = settings.OPENROUTER_MODEL_QWEN,
) -> str: ) -> str:
error_html = f'<div class="error">{escape(error)}</div>' if error else "" error_html = f'<div class="error">{escape(error)}</div>' if error else ""
success_html = f'<div class="success">{escape(success)}</div>' if success else ""
options_html = "".join( options_html = "".join(
f'<option value="{escape(model)}" {"selected" if model == ai_model else ""}>{escape(label)}</option>' f'<option value="{escape(model)}" {"selected" if model == ai_model else ""}>{escape(label)}</option>'
for model, label in SUPPORTED_MODELS.items() 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 = """
<div class="success">
No <code>sedang</code> basis items found yet. Seed one demo website, tryout, and basis item to test AI generation immediately.
</div>
<form method="post" action="/admin/ai-playground/seed-demo">
<button type="submit">Seed Demo Basis Item</button>
</form>
"""
result_html = "" result_html = ""
if result: if result:
options = result.get("options") or {} options = result.get("options") or {}
save_html = ""
if result.get("basis_item_id") and not result.get("existing_item_id"):
save_html = f"""
<form method="post" action="/admin/ai-playground/save">
<input type="hidden" name="basis_item_id" value="{escape(str(result.get("basis_item_id", "")))}">
<input type="hidden" name="tryout_id" value="{escape(str(result.get("tryout_id", "")))}">
<input type="hidden" name="website_id" value="{escape(str(result.get("website_id", "")))}">
<input type="hidden" name="slot" value="{escape(str(result.get("slot", "")))}">
<input type="hidden" name="target_level" value="{escape(str(result.get("target_level", "")))}">
<input type="hidden" name="ai_model" value="{escape(str(result.get("ai_model", "")))}">
<input type="hidden" name="stem" value="{escape(str(result.get("stem", "")))}">
<input type="hidden" name="options_json" value="{escape(json.dumps(options))}">
<input type="hidden" name="correct" value="{escape(str(result.get("correct", "")))}">
<input type="hidden" name="explanation" value="{escape(str(result.get("explanation", "")))}">
<button type="submit">Save Generated Item</button>
</form>
"""
elif result.get("existing_item_id"):
save_html = f"""
<div class="error">
Slot {escape(str(result.get("slot", "")))} already has a <code>{escape(str(result.get("target_level", "")))}</code> item
for this tryout. Existing item ID: <strong>{escape(str(result.get("existing_item_id")))}</strong>.
</div>
"""
result_html = f""" result_html = f"""
<h3>Preview Result</h3> <h3>Preview Result</h3>
<p><strong>Model:</strong> {escape(result.get("ai_model", ""))}</p> <p><strong>Model:</strong> {escape(result.get("ai_model", ""))}</p>
<p><strong>Basis Item:</strong> #{escape(str(result.get("basis_item_id", "")))} | <strong>Tryout:</strong> {escape(result.get("tryout_id", ""))} | <strong>Slot:</strong> {escape(str(result.get("slot", "")))}</p>
<p><strong>Stem:</strong><br>{escape(result.get("stem", ""))}</p> <p><strong>Stem:</strong><br>{escape(result.get("stem", ""))}</p>
<p><strong>Options:</strong></p> <p><strong>Options:</strong></p>
{_table(["Key", "Text"], [[key, value] for key, value in options.items()])} {_table(["Key", "Text"], [[key, value] for key, value in options.items()])}
<p><strong>Correct:</strong> {escape(result.get("correct", ""))}</p> <p><strong>Correct:</strong> {escape(result.get("correct", ""))}</p>
<p><strong>Explanation:</strong><br>{escape(result.get("explanation", ""))}</p> <p><strong>Explanation:</strong><br>{escape(result.get("explanation", ""))}</p>
<div class="actions">{save_html}</div>
""" """
return f""" return f"""
<p class="muted">OpenRouter key configured: <strong>{"Yes" if key_configured else "No"}</strong></p> <p class="muted">OpenRouter key configured: <strong>{"Yes" if key_configured else "No"}</strong></p>
<p class="muted">Total AI-generated items: <strong>{stats.get("total_ai_items", 0)}</strong></p> <p class="muted">Total AI-generated items: <strong>{stats.get("total_ai_items", 0)}</strong></p>
{success_html}
{error_html} {error_html}
{seed_callout}
<form method="post" action="/admin/ai-playground" autocomplete="off"> <form method="post" action="/admin/ai-playground" autocomplete="off">
<label for="basis_item_id">Basis Item ID</label> <label for="basis_item_id">Basis Item ID</label>
<input id="basis_item_id" name="basis_item_id" type="number" value="{escape(basis_item_id)}"> <input id="basis_item_id" name="basis_item_id" type="number" value="{escape(basis_item_id)}">
@@ -504,7 +655,9 @@ def _ai_form_body(
</select> </select>
<button type="submit">Generate Preview</button> <button type="submit">Generate Preview</button>
</form> </form>
<p class="muted" style="margin-top:18px">This is a preview only. Save-to-database UI is not added yet.</p> <h3 style="margin-top:24px">Available Sedang Basis Items</h3>
<p class="muted">The generator needs a <code>sedang</code> item. Use one of these IDs, or seed demo data if the table is empty.</p>
{basis_table}
{result_html} {result_html}
""" """
@@ -516,7 +669,27 @@ async def ai_playground_view(request: Request, db: AsyncSession = Depends(get_db
return _login_redirect() return _login_redirect()
stats = await get_ai_stats(db) 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) return _render_admin_page("AI Playground", "AI Playground", body)
@@ -533,12 +706,14 @@ async def ai_playground_submit(
return _login_redirect() return _login_redirect()
stats = await get_ai_stats(db) stats = await get_ai_stats(db)
basis_items = await _basis_items_for_playground(db)
if not settings.OPENROUTER_API_KEY: if not settings.OPENROUTER_API_KEY:
body = _ai_form_body( body = _ai_form_body(
False, False,
stats, stats,
error="OPENROUTER_API_KEY is not configured in the environment.", error="OPENROUTER_API_KEY is not configured in the environment.",
basis_items=basis_items,
basis_item_id=str(basis_item_id), basis_item_id=str(basis_item_id),
target_level=target_level, target_level=target_level,
ai_model=ai_model, ai_model=ai_model,
@@ -550,6 +725,7 @@ async def ai_playground_submit(
True, True,
stats, stats,
error="Target level must be mudah or sulit.", error="Target level must be mudah or sulit.",
basis_items=basis_items,
basis_item_id=str(basis_item_id), basis_item_id=str(basis_item_id),
target_level=target_level, target_level=target_level,
ai_model=ai_model, ai_model=ai_model,
@@ -561,6 +737,7 @@ async def ai_playground_submit(
True, True,
stats, stats,
error="Unsupported AI model.", error="Unsupported AI model.",
basis_items=basis_items,
basis_item_id=str(basis_item_id), basis_item_id=str(basis_item_id),
target_level=target_level, target_level=target_level,
ai_model=ai_model, ai_model=ai_model,
@@ -574,6 +751,7 @@ async def ai_playground_submit(
True, True,
stats, stats,
error=f"Basis item not found: {basis_item_id}", error=f"Basis item not found: {basis_item_id}",
basis_items=basis_items,
basis_item_id=str(basis_item_id), basis_item_id=str(basis_item_id),
target_level=target_level, target_level=target_level,
ai_model=ai_model, ai_model=ai_model,
@@ -585,6 +763,7 @@ async def ai_playground_submit(
True, True,
stats, stats,
error=f"Basis item must be sedang level, got: {basis_item.level}", error=f"Basis item must be sedang level, got: {basis_item.level}",
basis_items=basis_items,
basis_item_id=str(basis_item_id), basis_item_id=str(basis_item_id),
target_level=target_level, target_level=target_level,
ai_model=ai_model, ai_model=ai_model,
@@ -601,21 +780,39 @@ async def ai_playground_submit(
True, True,
stats, stats,
error="AI generation failed. Check OPENROUTER_API_KEY, model availability, and server logs.", error="AI generation failed. Check OPENROUTER_API_KEY, model availability, and server logs.",
basis_items=basis_items,
basis_item_id=str(basis_item_id), basis_item_id=str(basis_item_id),
target_level=target_level, target_level=target_level,
ai_model=ai_model, ai_model=ai_model,
) )
return _render_admin_page("AI Playground", "AI Playground", body) 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( body = _ai_form_body(
True, True,
stats, stats,
basis_items=basis_items,
result={ 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, "stem": generated.stem,
"options": generated.options, "options": generated.options,
"correct": generated.correct, "correct": generated.correct,
"explanation": generated.explanation or "", "explanation": generated.explanation or "",
"ai_model": ai_model, "ai_model": ai_model,
"existing_item_id": existing_item_id,
}, },
basis_item_id=str(basis_item_id), basis_item_id=str(basis_item_id),
target_level=target_level, target_level=target_level,
@@ -624,6 +821,107 @@ async def ai_playground_submit(
return _render_admin_page("AI Playground", "AI Playground", body) 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("/tryout/list", include_in_schema=False)
@router.get("/item/list", include_in_schema=False) @router.get("/item/list", include_in_schema=False)
@router.get("/user/list", include_in_schema=False) @router.get("/user/list", include_in_schema=False)