Add bulk snapshot promotion action

This commit is contained in:
dwindown
2026-04-04 06:32:21 +07:00
parent 0700b4991c
commit 34f0848abf

View File

@@ -442,20 +442,17 @@ def _snapshot_questions_body(
slot = slot_map.get(question.source_question_id, 0) slot = slot_map.get(question.source_question_id, 0)
promoted_item = promoted_items_by_slot.get(slot) promoted_item = promoted_items_by_slot.get(slot)
if promoted_item: if promoted_item:
select_html = ""
action_html = ( action_html = (
f'Item #{promoted_item.id} already exists. ' f'Item #{promoted_item.id} already exists. '
f'<a href="/admin/ai-playground?basis_item_id={promoted_item.id}">Open in AI Playground</a>' f'<a href="/admin/ai-playground?basis_item_id={promoted_item.id}">Open in AI Playground</a>'
) )
else: else:
action_html = ( select_html = f'<input type="checkbox" name="snapshot_question_ids" value="{question.id}">'
f'<form method="post" action="/admin/snapshot-questions/promote" style="margin:0">' action_html = "Ready to promote"
f'<input type="hidden" name="snapshot_id" value="{snapshot.id}">'
f'<input type="hidden" name="snapshot_question_id" value="{question.id}">'
'<button type="submit">Promote as Basis Item</button>'
'</form>'
)
rows.append( rows.append(
"<tr>" "<tr>"
f"<td>{select_html}</td>"
f"<td>{slot or '-'}</td>" f"<td>{slot or '-'}</td>"
f"<td>{escape(question.source_question_id)}</td>" f"<td>{escape(question.source_question_id)}</td>"
f"<td>{escape(question.correct_answer)}</td>" f"<td>{escape(question.correct_answer)}</td>"
@@ -466,9 +463,15 @@ def _snapshot_questions_body(
"</tr>" "</tr>"
) )
questions_table = ( questions_table = (
"<table><thead><tr><th>Slot</th><th>Source Question ID</th><th>Correct</th><th>Options</th><th>Active</th><th>Stem</th><th>Action</th></tr></thead><tbody>" "<form method=\"post\" action=\"/admin/snapshot-questions/promote-bulk\">"
+ ("".join(rows) if rows else "<tr><td colspan=\"7\">No data</td></tr>") f"<input type=\"hidden\" name=\"snapshot_id\" value=\"{snapshot.id}\">"
"<div class=\"actions\" style=\"margin:16px 0\">"
"<button type=\"submit\">Promote Selected as Basis Items</button>"
"</div>"
"<table><thead><tr><th><input type=\"checkbox\" onclick=\"document.querySelectorAll('input[name=&quot;snapshot_question_ids&quot;]').forEach(el => el.checked = this.checked)\"></th><th>Slot</th><th>Source Question ID</th><th>Correct</th><th>Options</th><th>Active</th><th>Stem</th><th>Action</th></tr></thead><tbody>"
+ ("".join(rows) if rows else "<tr><td colspan=\"8\">No data</td></tr>")
+ "</tbody></table>" + "</tbody></table>"
"</form>"
) )
return f""" return f"""
<p class="muted">Snapshot ID: <strong>{snapshot.id}</strong> | Website: <strong>{snapshot.website_id}</strong> | Tryout: <strong>{escape(snapshot.source_tryout_id)}</strong></p> <p class="muted">Snapshot ID: <strong>{snapshot.id}</strong> | Website: <strong>{snapshot.website_id}</strong> | Tryout: <strong>{escape(snapshot.source_tryout_id)}</strong></p>
@@ -597,6 +600,94 @@ async def _ensure_operational_tryout(snapshot: TryoutImportSnapshot, db: AsyncSe
return tryout return tryout
async def _load_snapshot_question_context(
snapshot: TryoutImportSnapshot,
db: AsyncSession,
) -> tuple[list[TryoutSnapshotQuestion], dict[int, Item], dict[str, int]]:
question_result = await db.execute(
select(TryoutSnapshotQuestion)
.where(
TryoutSnapshotQuestion.website_id == snapshot.website_id,
TryoutSnapshotQuestion.source_tryout_id == snapshot.source_tryout_id,
)
.order_by(TryoutSnapshotQuestion.source_question_id.asc())
)
questions = list(question_result.scalars().all())
item_result = await db.execute(
select(Item).where(
Item.website_id == snapshot.website_id,
Item.tryout_id == snapshot.source_tryout_id,
Item.level == "sedang",
)
)
promoted_items_by_slot = {item.slot: item for item in item_result.scalars().all()}
slot_map = _snapshot_slot_map(snapshot)
questions.sort(key=lambda row: (slot_map.get(row.source_question_id, 10**9), row.source_question_id))
return questions, promoted_items_by_slot, slot_map
async def _promote_snapshot_question_to_item(
snapshot: TryoutImportSnapshot,
question: TryoutSnapshotQuestion,
db: AsyncSession,
) -> tuple[Item | None, str]:
if (
question.website_id != snapshot.website_id
or question.source_tryout_id != snapshot.source_tryout_id
):
return None, "mismatch"
slot_map = _snapshot_slot_map(snapshot)
slot = slot_map.get(question.source_question_id)
if not slot:
max_slot = (
await db.scalar(
select(func.max(Item.slot)).where(
Item.website_id == snapshot.website_id,
Item.tryout_id == snapshot.source_tryout_id,
Item.level == "sedang",
)
)
or 0
)
slot = max_slot + 1
options = _snapshot_options_to_item_options(question.raw_options)
if not options:
return None, "missing_options"
await _ensure_operational_tryout(snapshot, db)
existing_item_result = await db.execute(
select(Item).where(
Item.website_id == snapshot.website_id,
Item.tryout_id == snapshot.source_tryout_id,
Item.slot == slot,
Item.level == "sedang",
)
)
existing_item = existing_item_result.scalar_one_or_none()
if existing_item is not None:
return existing_item, "existing"
item = Item(
tryout_id=snapshot.source_tryout_id,
website_id=snapshot.website_id,
slot=slot,
level="sedang",
stem=question.question_html,
options=options,
correct_answer=question.correct_answer,
explanation=question.explanation_html,
generated_by="manual",
calibrated=False,
calibration_sample_size=0,
)
db.add(item)
await db.flush()
return item, "created"
@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):
@@ -1116,35 +1207,16 @@ async def snapshot_questions_view(
) )
return _render_admin_page("Tryout Import", "Tryout Import", body) return _render_admin_page("Tryout Import", "Tryout Import", body)
result = await db.execute( questions, promoted_items_by_slot, _ = await _load_snapshot_question_context(snapshot, db)
select(TryoutSnapshotQuestion)
.where(
TryoutSnapshotQuestion.website_id == snapshot.website_id,
TryoutSnapshotQuestion.source_tryout_id == snapshot.source_tryout_id,
)
.order_by(TryoutSnapshotQuestion.source_question_id.asc())
)
questions = list(result.scalars().all())
slot_map = _snapshot_slot_map(snapshot)
item_result = await db.execute(
select(Item).where(
Item.website_id == snapshot.website_id,
Item.tryout_id == snapshot.source_tryout_id,
Item.level == "sedang",
)
)
promoted_items = list(item_result.scalars().all())
promoted_items_by_slot = {item.slot: item for item in promoted_items}
questions.sort(key=lambda question: (slot_map.get(question.source_question_id, 10**9), question.source_question_id))
body = _snapshot_questions_body(snapshot, questions, promoted_items_by_slot) body = _snapshot_questions_body(snapshot, questions, promoted_items_by_slot)
return _render_admin_page("Snapshot Questions", "Snapshot Questions", body) return _render_admin_page("Snapshot Questions", "Snapshot Questions", body)
@router.post("/snapshot-questions/promote", include_in_schema=False) @router.post("/snapshot-questions/promote-bulk", include_in_schema=False)
async def snapshot_question_promote( async def snapshot_question_promote_bulk(
request: Request, request: Request,
snapshot_id: int = Form(...), snapshot_id: int = Form(...),
snapshot_question_id: int = Form(...), snapshot_question_ids: list[int] | None = Form(None),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
admin = await _current_admin(request) admin = await _current_admin(request)
@@ -1152,123 +1224,65 @@ async def snapshot_question_promote(
return _login_redirect() return _login_redirect()
snapshot = await db.get(TryoutImportSnapshot, snapshot_id) snapshot = await db.get(TryoutImportSnapshot, snapshot_id)
question = await db.get(TryoutSnapshotQuestion, snapshot_question_id) if snapshot is None:
if snapshot is None or question is None:
websites = await _load_websites(db) websites = await _load_websites(db)
snapshots = await _recent_snapshots(db) snapshots = await _recent_snapshots(db)
body = _tryout_import_form_body( body = _tryout_import_form_body(
websites, websites,
snapshots, snapshots,
error="Snapshot or snapshot question not found.", error=f"Snapshot not found: {snapshot_id}",
) )
return _render_admin_page("Tryout Import", "Tryout Import", body) return _render_admin_page("Tryout Import", "Tryout Import", body)
if ( if not snapshot_question_ids:
question.website_id != snapshot.website_id questions, promoted_items_by_slot, _ = await _load_snapshot_question_context(snapshot, db)
or question.source_tryout_id != snapshot.source_tryout_id
):
body = _snapshot_questions_body(snapshot, [], {}, error="Snapshot question does not belong to the selected snapshot.")
return _render_admin_page("Snapshot Questions", "Snapshot Questions", body)
slot_map = _snapshot_slot_map(snapshot)
slot = slot_map.get(question.source_question_id)
if not slot:
max_slot = (
await db.scalar(
select(func.max(Item.slot)).where(
Item.website_id == snapshot.website_id,
Item.tryout_id == snapshot.source_tryout_id,
Item.level == "sedang",
)
)
or 0
)
slot = max_slot + 1
options = _snapshot_options_to_item_options(question.raw_options)
if not options:
item_result = await db.execute(
select(Item).where(
Item.website_id == snapshot.website_id,
Item.tryout_id == snapshot.source_tryout_id,
Item.level == "sedang",
)
)
promoted_items_by_slot = {item.slot: item for item in item_result.scalars().all()}
question_result = await db.execute(
select(TryoutSnapshotQuestion)
.where(
TryoutSnapshotQuestion.website_id == snapshot.website_id,
TryoutSnapshotQuestion.source_tryout_id == snapshot.source_tryout_id,
)
.order_by(TryoutSnapshotQuestion.source_question_id.asc())
)
questions = list(question_result.scalars().all())
questions.sort(key=lambda row: (slot_map.get(row.source_question_id, 10**9), row.source_question_id))
body = _snapshot_questions_body( body = _snapshot_questions_body(
snapshot, snapshot,
questions, questions,
promoted_items_by_slot, promoted_items_by_slot,
error="Snapshot question has no usable option text, so it cannot be promoted into the live item bank.", error="Select at least one snapshot question to promote.",
) )
return _render_admin_page("Snapshot Questions", "Snapshot Questions", body) return _render_admin_page("Snapshot Questions", "Snapshot Questions", body)
await _ensure_operational_tryout(snapshot, db)
existing_item_result = await db.execute(
select(Item).where(
Item.website_id == snapshot.website_id,
Item.tryout_id == snapshot.source_tryout_id,
Item.slot == slot,
Item.level == "sedang",
)
)
existing_item = existing_item_result.scalar_one_or_none()
if existing_item is None:
existing_item = Item(
tryout_id=snapshot.source_tryout_id,
website_id=snapshot.website_id,
slot=slot,
level="sedang",
stem=question.question_html,
options=options,
correct_answer=question.correct_answer,
explanation=question.explanation_html,
generated_by="manual",
calibrated=False,
calibration_sample_size=0,
)
db.add(existing_item)
await db.commit()
await db.refresh(existing_item)
success_message = (
f"Snapshot question {question.source_question_id} promoted successfully as Item #{existing_item.id}. "
f"Use that Basis Item ID in AI Playground."
)
else:
success_message = (
f"Snapshot question {question.source_question_id} is already available as Item #{existing_item.id}. "
f"Use that Basis Item ID in AI Playground."
)
question_result = await db.execute( question_result = await db.execute(
select(TryoutSnapshotQuestion) select(TryoutSnapshotQuestion).where(
.where( TryoutSnapshotQuestion.id.in_(snapshot_question_ids)
TryoutSnapshotQuestion.website_id == snapshot.website_id,
TryoutSnapshotQuestion.source_tryout_id == snapshot.source_tryout_id,
)
.order_by(TryoutSnapshotQuestion.source_question_id.asc())
)
questions = list(question_result.scalars().all())
item_result = await db.execute(
select(Item).where(
Item.website_id == snapshot.website_id,
Item.tryout_id == snapshot.source_tryout_id,
Item.level == "sedang",
) )
) )
promoted_items_by_slot = {item.slot: item for item in item_result.scalars().all()} selected_questions = list(question_result.scalars().all())
questions.sort(key=lambda row: (slot_map.get(row.source_question_id, 10**9), row.source_question_id))
created_items: list[Item] = []
existing_items: list[Item] = []
missing_option_count = 0
mismatch_count = 0
for question in selected_questions:
item, status = await _promote_snapshot_question_to_item(snapshot, question, db)
if status == "created" and item is not None:
created_items.append(item)
elif status == "existing" and item is not None:
existing_items.append(item)
elif status == "missing_options":
missing_option_count += 1
elif status == "mismatch":
mismatch_count += 1
await db.commit()
questions, promoted_items_by_slot, _ = await _load_snapshot_question_context(snapshot, db)
success_parts = []
if created_items:
success_parts.append(f"created {len(created_items)} item(s)")
if existing_items:
success_parts.append(f"reused {len(existing_items)} existing item(s)")
if missing_option_count:
success_parts.append(f"skipped {missing_option_count} question(s) with missing option text")
if mismatch_count:
success_parts.append(f"skipped {mismatch_count} mismatched question(s)")
success_message = "Bulk promote finished: " + ", ".join(success_parts) + "."
if created_items:
success_message += f" Latest basis item ID: {created_items[-1].id}."
body = _snapshot_questions_body(snapshot, questions, promoted_items_by_slot, success=success_message) body = _snapshot_questions_body(snapshot, questions, promoted_items_by_slot, success=success_message)
return _render_admin_page("Snapshot Questions", "Snapshot Questions", body) return _render_admin_page("Snapshot Questions", "Snapshot Questions", body)