Add demo seed and save flow to AI playground
This commit is contained in:
304
app/admin_web.py
304
app/admin_web.py
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user