Improve AI playground review UX
This commit is contained in:
140
app/admin_web.py
140
app/admin_web.py
@@ -228,6 +228,10 @@ def _render_admin_page(request: Request, title: str, page_title: str, body: str)
|
|||||||
.status-stale {{ background: #fef3c7; color: #92400e; }}
|
.status-stale {{ background: #fef3c7; color: #92400e; }}
|
||||||
.button-link {{ display: inline-block; padding: 9px 12px; border-radius: 8px; background: #0f172a; color: #fff; text-decoration: none; font-size: 13px; font-weight: 700; }}
|
.button-link {{ display: inline-block; padding: 9px 12px; border-radius: 8px; background: #0f172a; color: #fff; text-decoration: none; font-size: 13px; font-weight: 700; }}
|
||||||
.secondary-link {{ display: inline-block; padding: 10px 12px; border-radius: 8px; background: #e2e8f0; color: #0f172a; text-decoration: none; font-size: 14px; font-weight: 700; }}
|
.secondary-link {{ display: inline-block; padding: 10px 12px; border-radius: 8px; background: #e2e8f0; color: #0f172a; text-decoration: none; font-size: 14px; font-weight: 700; }}
|
||||||
|
.question-block {{ margin: 16px 0; padding: 16px; border: 1px solid #e2e8f0; border-radius: 8px; background: #f8fafc; }}
|
||||||
|
.question-block h3 {{ margin-top: 0; }}
|
||||||
|
.option-key {{ width: 56px; font-weight: 800; color: #0f172a; }}
|
||||||
|
.correct-option td {{ background: #ecfdf5; color: #166534; font-weight: 700; }}
|
||||||
@media (max-width: 860px) {{
|
@media (max-width: 860px) {{
|
||||||
.layout {{ grid-template-columns: 1fr; }}
|
.layout {{ grid-template-columns: 1fr; }}
|
||||||
.sidebar {{ position: static; }}
|
.sidebar {{ position: static; }}
|
||||||
@@ -2261,6 +2265,15 @@ def _ai_generate_tab(
|
|||||||
<button type="submit">Seed Demo Basis Item</button>
|
<button type="submit">Seed Demo Basis Item</button>
|
||||||
</form>
|
</form>
|
||||||
"""
|
"""
|
||||||
|
selected_basis_id = str(basis_item_id or "")
|
||||||
|
basis_options = ['<option value="">Select a sedang basis item</option>']
|
||||||
|
for item in basis_items:
|
||||||
|
item_id = str(item.id)
|
||||||
|
selected = _selected_option(item_id, selected_basis_id)
|
||||||
|
stem_preview = _truncate(_html_to_text(item.stem), 82)
|
||||||
|
basis_options.append(
|
||||||
|
f'<option value="{item.id}" {selected}>#{item.id} | Tryout {escape(str(item.tryout_id))} | Slot {item.slot} | {escape(stem_preview)}</option>'
|
||||||
|
)
|
||||||
|
|
||||||
return f"""
|
return f"""
|
||||||
<section class="tab-panel">
|
<section class="tab-panel">
|
||||||
@@ -2269,8 +2282,10 @@ def _ai_generate_tab(
|
|||||||
<form method="post" action="/admin/ai-playground?tab=generate" autocomplete="off">
|
<form method="post" action="/admin/ai-playground?tab=generate" autocomplete="off">
|
||||||
<div class="field-grid">
|
<div class="field-grid">
|
||||||
<div>
|
<div>
|
||||||
<label for="basis_item_id">Basis Item ID</label>
|
<label for="basis_item_id">Basis Item</label>
|
||||||
<input id="basis_item_id" name="basis_item_id" type="number" value="{escape(basis_item_id)}">
|
<select id="basis_item_id" name="basis_item_id" required>
|
||||||
|
{"".join(basis_options)}
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="target_level">Target Level</label>
|
<label for="target_level">Target Level</label>
|
||||||
@@ -2397,12 +2412,13 @@ def _ai_review_tab(
|
|||||||
f"<td>{escape(_truncate(item.ai_model or '-', 42))}</td>"
|
f"<td>{escape(_truncate(item.ai_model or '-', 42))}</td>"
|
||||||
f"<td>{escape(stem_preview)}</td>"
|
f"<td>{escape(stem_preview)}</td>"
|
||||||
f"<td>{escape(str(item.created_at))}</td>"
|
f"<td>{escape(str(item.created_at))}</td>"
|
||||||
|
f"<td><a class=\"secondary-link\" href=\"/admin/ai-playground/variants/{item.id}\">View</a></td>"
|
||||||
"</tr>"
|
"</tr>"
|
||||||
)
|
)
|
||||||
variant_table_rows = (
|
variant_table_rows = (
|
||||||
"".join(variant_rows)
|
"".join(variant_rows)
|
||||||
if variant_rows
|
if variant_rows
|
||||||
else '<tr><td colspan="9">No AI-generated variants match this view.</td></tr>'
|
else '<tr><td colspan="10">No AI-generated variants match this view.</td></tr>'
|
||||||
)
|
)
|
||||||
|
|
||||||
return f"""
|
return f"""
|
||||||
@@ -2435,7 +2451,7 @@ def _ai_review_tab(
|
|||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr><th><input type="checkbox" onclick="document.querySelectorAll('input[name="item_ids"]').forEach(el => el.checked = this.checked)"></th><th>Item ID</th><th>Run ID</th><th>Basis</th><th>Level</th><th>Status</th><th>Model</th><th>Stem</th><th>Created At</th></tr>
|
<tr><th><input type="checkbox" onclick="document.querySelectorAll('input[name="item_ids"]').forEach(el => el.checked = this.checked)"></th><th>Item ID</th><th>Run ID</th><th>Basis</th><th>Level</th><th>Status</th><th>Model</th><th>Stem</th><th>Created At</th><th>Action</th></tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{variant_table_rows}
|
{variant_table_rows}
|
||||||
@@ -2507,6 +2523,88 @@ def _ai_form_body(
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _options_table(options: Any, correct_answer: str | None) -> str:
|
||||||
|
normalized_correct = str(correct_answer or "").strip().upper()
|
||||||
|
rows = []
|
||||||
|
if isinstance(options, dict):
|
||||||
|
options_by_key = {str(key).strip().upper(): value for key, value in options.items()}
|
||||||
|
option_keys = [key for key in ("A", "B", "C", "D") if key in options_by_key]
|
||||||
|
option_keys.extend(sorted(key for key in options_by_key.keys() if key not in option_keys))
|
||||||
|
for key in option_keys:
|
||||||
|
value = options_by_key.get(key)
|
||||||
|
row_class = ' class="correct-option"' if str(key).upper() == normalized_correct else ""
|
||||||
|
rows.append(
|
||||||
|
f"<tr{row_class}>"
|
||||||
|
f'<td class="option-key">{escape(str(key).upper())}</td>'
|
||||||
|
f"<td>{escape(_html_to_text(str(value)))}</td>"
|
||||||
|
"</tr>"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
rows.append(
|
||||||
|
f'<tr><td colspan="2">{escape(_html_to_text(str(options or "")))}</td></tr>'
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
'<div class="table-wrap"><table><thead><tr><th>Option</th><th>Text</th></tr></thead><tbody>'
|
||||||
|
+ ("".join(rows) if rows else '<tr><td colspan="2">No options stored.</td></tr>')
|
||||||
|
+ "</tbody></table></div>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _ai_variant_detail_body(variant: Item, basis_item: Item | None) -> str:
|
||||||
|
explanation = _html_to_text(variant.explanation) if variant.explanation else "-"
|
||||||
|
basis_preview = "-"
|
||||||
|
if basis_item is not None:
|
||||||
|
basis_preview = (
|
||||||
|
f"#{basis_item.id} | Tryout {escape(str(basis_item.tryout_id))} | "
|
||||||
|
f"Slot {basis_item.slot} | {escape(_truncate(_html_to_text(basis_item.stem), 160))}"
|
||||||
|
)
|
||||||
|
review_url = "/admin/ai-playground?tab=review"
|
||||||
|
if variant.generation_run_id:
|
||||||
|
review_url = f"{review_url}&run_id={variant.generation_run_id}"
|
||||||
|
|
||||||
|
return f"""
|
||||||
|
<div class="compact-strip">
|
||||||
|
<div class="compact-stat"><span>Item</span><strong>{variant.id}</strong></div>
|
||||||
|
<div class="compact-stat"><span>Run</span><strong>{variant.generation_run_id or "-"}</strong></div>
|
||||||
|
<div class="compact-stat"><span>Level</span><strong>{escape(variant.level)}</strong></div>
|
||||||
|
<div class="compact-stat"><span>Status</span><strong>{escape(variant.variant_status)}</strong></div>
|
||||||
|
</div>
|
||||||
|
<div class="question-block">
|
||||||
|
<h3>Question</h3>
|
||||||
|
<p>{escape(_html_to_text(variant.stem))}</p>
|
||||||
|
</div>
|
||||||
|
<h3>Answer Options</h3>
|
||||||
|
{_options_table(variant.options, variant.correct_answer)}
|
||||||
|
<div class="question-block">
|
||||||
|
<h3>Correct Answer</h3>
|
||||||
|
<p><strong>{escape(variant.correct_answer)}</strong></p>
|
||||||
|
<h3>Pembahasan</h3>
|
||||||
|
<p>{escape(explanation)}</p>
|
||||||
|
</div>
|
||||||
|
<div class="question-block">
|
||||||
|
<h3>Generation Context</h3>
|
||||||
|
<p class="muted">Basis item: <strong>{basis_preview}</strong></p>
|
||||||
|
<p class="muted">Model: <strong>{escape(variant.ai_model or "-")}</strong></p>
|
||||||
|
<p class="muted">Created at: <strong>{escape(str(variant.created_at))}</strong></p>
|
||||||
|
</div>
|
||||||
|
<form method="post" action="/admin/ai-playground/review-bulk?tab=review">
|
||||||
|
<input type="hidden" name="item_ids" value="{variant.id}">
|
||||||
|
<div class="actions">
|
||||||
|
<select name="action" style="max-width:260px">
|
||||||
|
<option value="approved">Approve this item</option>
|
||||||
|
<option value="rejected">Reject this item</option>
|
||||||
|
<option value="archived">Archive this item</option>
|
||||||
|
<option value="stale">Mark stale</option>
|
||||||
|
<option value="active">Activate this item</option>
|
||||||
|
</select>
|
||||||
|
<button type="submit">Apply</button>
|
||||||
|
<a class="secondary-link" href="{review_url}">Back to Review Queue</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
@router.get("/ai-playground", include_in_schema=False)
|
@router.get("/ai-playground", include_in_schema=False)
|
||||||
async def ai_playground_view(request: Request, db: AsyncSession = Depends(get_db)):
|
async def ai_playground_view(request: Request, db: AsyncSession = Depends(get_db)):
|
||||||
admin = await _current_admin(request)
|
admin = await _current_admin(request)
|
||||||
@@ -2553,6 +2651,40 @@ async def ai_playground_view(request: Request, db: AsyncSession = Depends(get_db
|
|||||||
return _render_admin_page(request, "AI Playground", "AI Playground", body)
|
return _render_admin_page(request, "AI Playground", "AI Playground", body)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/ai-playground/variants/{item_id}", include_in_schema=False)
|
||||||
|
async def ai_playground_variant_detail(
|
||||||
|
item_id: int,
|
||||||
|
request: Request,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
admin = await _current_admin(request)
|
||||||
|
if not admin:
|
||||||
|
return _login_redirect()
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
select(Item).where(Item.id == item_id, Item.generated_by == "ai")
|
||||||
|
)
|
||||||
|
variant = result.scalar_one_or_none()
|
||||||
|
if variant is None:
|
||||||
|
body = """
|
||||||
|
<div class="error">Generated variant was not found.</div>
|
||||||
|
<a class="secondary-link" href="/admin/ai-playground?tab=review">Back to Review Queue</a>
|
||||||
|
"""
|
||||||
|
return _render_admin_page(request, "Generated Variant", "Generated Variant", body)
|
||||||
|
|
||||||
|
basis_item = None
|
||||||
|
if variant.basis_item_id:
|
||||||
|
basis_item = await db.get(Item, variant.basis_item_id)
|
||||||
|
|
||||||
|
body = _ai_variant_detail_body(variant, basis_item)
|
||||||
|
return _render_admin_page(
|
||||||
|
request,
|
||||||
|
f"Generated Variant #{variant.id}",
|
||||||
|
f"Generated Variant #{variant.id}",
|
||||||
|
body,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/ai-playground/seed-demo", include_in_schema=False)
|
@router.post("/ai-playground/seed-demo", include_in_schema=False)
|
||||||
async def ai_playground_seed_demo(request: Request, db: AsyncSession = Depends(get_db)):
|
async def ai_playground_seed_demo(request: Request, db: AsyncSession = Depends(get_db)):
|
||||||
admin = await _current_admin(request)
|
admin = await _current_admin(request)
|
||||||
|
|||||||
Reference in New Issue
Block a user