Add bulk snapshot promotion action
This commit is contained in:
276
app/admin_web.py
276
app/admin_web.py
@@ -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="snapshot_question_ids"]').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)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user