Add plain admin AI playground

This commit is contained in:
dwindown
2026-04-01 21:29:46 +07:00
parent 1e4e9128d7
commit 4f35387c71

View File

@@ -21,6 +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
from app.services.ai_generation import (
SUPPORTED_MODELS,
generate_question,
get_ai_stats,
validate_ai_model,
)
from app.services.irt_calibration import get_calibration_status from app.services.irt_calibration import get_calibration_status
settings = get_settings() settings = get_settings()
@@ -161,6 +167,7 @@ def _render_admin_page(title: str, page_title: str, body: str) -> HTMLResponse:
<a href="/admin/calibration-status">Calibration Status</a> <a href="/admin/calibration-status">Calibration Status</a>
<a href="/admin/item-statistics">Item Statistics</a> <a href="/admin/item-statistics">Item Statistics</a>
<a href="/admin/session-overview">Session Overview</a> <a href="/admin/session-overview">Session Overview</a>
<a href="/admin/ai-playground">AI Playground</a>
<a href="/admin/password">Password Info</a> <a href="/admin/password">Password Info</a>
<a href="/admin/logout">Logout</a> <a href="/admin/logout">Logout</a>
</aside> </aside>
@@ -352,6 +359,7 @@ async def dashboard_view(request: Request, db: AsyncSession = Depends(get_db)):
<div class="stat">Sessions<strong>{sessions}</strong></div> <div class="stat">Sessions<strong>{sessions}</strong></div>
<div class="stat">Completed Sessions<strong>{completed_sessions}</strong></div> <div class="stat">Completed Sessions<strong>{completed_sessions}</strong></div>
</div> </div>
<p style="margin-top:20px"><a href="/admin/ai-playground">Open AI Playground</a></p>
""" """
return _render_admin_page("IRT Bank Soal Admin", "Dashboard", body) return _render_admin_page("IRT Bank Soal Admin", "Dashboard", body)
@@ -451,6 +459,171 @@ async def session_overview_view(request: Request, db: AsyncSession = Depends(get
return _render_admin_page("Session Overview", "Session Overview", body) return _render_admin_page("Session Overview", "Session Overview", body)
def _ai_form_body(
key_configured: bool,
stats: dict[str, Any],
error: str | None = None,
result: dict[str, Any] | None = None,
basis_item_id: str = "",
target_level: str = "mudah",
ai_model: str = settings.OPENROUTER_MODEL_QWEN,
) -> str:
error_html = f'<div class="error">{escape(error)}</div>' if error else ""
options_html = "".join(
f'<option value="{escape(model)}" {"selected" if model == ai_model else ""}>{escape(label)}</option>'
for model, label in SUPPORTED_MODELS.items()
)
result_html = ""
if result:
options = result.get("options") or {}
result_html = f"""
<h3>Preview Result</h3>
<p><strong>Model:</strong> {escape(result.get("ai_model", ""))}</p>
<p><strong>Stem:</strong><br>{escape(result.get("stem", ""))}</p>
<p><strong>Options:</strong></p>
{_table(["Key", "Text"], [[key, value] for key, value in options.items()])}
<p><strong>Correct:</strong> {escape(result.get("correct", ""))}</p>
<p><strong>Explanation:</strong><br>{escape(result.get("explanation", ""))}</p>
"""
return f"""
<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>
{error_html}
<form method="post" action="/admin/ai-playground" autocomplete="off">
<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)}">
<label for="target_level">Target Level</label>
<select id="target_level" name="target_level" style="width:100%;box-sizing:border-box;border:1px solid #cbd5e1;border-radius:10px;padding:12px 14px;font-size:15px;">
<option value="mudah" {"selected" if target_level == "mudah" else ""}>mudah</option>
<option value="sulit" {"selected" if target_level == "sulit" else ""}>sulit</option>
</select>
<label for="ai_model">Model</label>
<select id="ai_model" name="ai_model" style="width:100%;box-sizing:border-box;border:1px solid #cbd5e1;border-radius:10px;padding:12px 14px;font-size:15px;">
{options_html}
</select>
<button type="submit">Generate Preview</button>
</form>
<p class="muted" style="margin-top:18px">This is a preview only. Save-to-database UI is not added yet.</p>
{result_html}
"""
@router.get("/ai-playground", include_in_schema=False)
async def ai_playground_view(request: Request, db: AsyncSession = Depends(get_db)):
admin = await _current_admin(request)
if not admin:
return _login_redirect()
stats = await get_ai_stats(db)
body = _ai_form_body(bool(settings.OPENROUTER_API_KEY), stats)
return _render_admin_page("AI Playground", "AI Playground", body)
@router.post("/ai-playground", include_in_schema=False)
async def ai_playground_submit(
request: Request,
db: AsyncSession = Depends(get_db),
basis_item_id: int = Form(...),
target_level: str = Form(...),
ai_model: str = Form(...),
):
admin = await _current_admin(request)
if not admin:
return _login_redirect()
stats = await get_ai_stats(db)
if not settings.OPENROUTER_API_KEY:
body = _ai_form_body(
False,
stats,
error="OPENROUTER_API_KEY is not configured in the environment.",
basis_item_id=str(basis_item_id),
target_level=target_level,
ai_model=ai_model,
)
return _render_admin_page("AI Playground", "AI Playground", body)
if target_level not in {"mudah", "sulit"}:
body = _ai_form_body(
True,
stats,
error="Target level must be mudah or sulit.",
basis_item_id=str(basis_item_id),
target_level=target_level,
ai_model=ai_model,
)
return _render_admin_page("AI Playground", "AI Playground", body)
if not validate_ai_model(ai_model):
body = _ai_form_body(
True,
stats,
error="Unsupported AI model.",
basis_item_id=str(basis_item_id),
target_level=target_level,
ai_model=ai_model,
)
return _render_admin_page("AI Playground", "AI Playground", body)
result = await db.execute(select(Item).where(Item.id == basis_item_id))
basis_item = result.scalar_one_or_none()
if not basis_item:
body = _ai_form_body(
True,
stats,
error=f"Basis item not found: {basis_item_id}",
basis_item_id=str(basis_item_id),
target_level=target_level,
ai_model=ai_model,
)
return _render_admin_page("AI Playground", "AI Playground", body)
if basis_item.level != "sedang":
body = _ai_form_body(
True,
stats,
error=f"Basis item must be sedang level, got: {basis_item.level}",
basis_item_id=str(basis_item_id),
target_level=target_level,
ai_model=ai_model,
)
return _render_admin_page("AI Playground", "AI Playground", body)
generated = await generate_question(
basis_item=basis_item,
target_level=target_level,
ai_model=ai_model,
)
if not generated:
body = _ai_form_body(
True,
stats,
error="AI generation failed. Check OPENROUTER_API_KEY, model availability, and server logs.",
basis_item_id=str(basis_item_id),
target_level=target_level,
ai_model=ai_model,
)
return _render_admin_page("AI Playground", "AI Playground", body)
body = _ai_form_body(
True,
stats,
result={
"stem": generated.stem,
"options": generated.options,
"correct": generated.correct,
"explanation": generated.explanation or "",
"ai_model": ai_model,
},
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)