Add snapshot question promotion to item bank

This commit is contained in:
dwindown
2026-04-03 23:35:18 +07:00
parent c6f958879d
commit 0700b4991c

View File

@@ -22,7 +22,7 @@ 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, TryoutImportSnapshot, Website from app.models import Item, Session, Tryout, TryoutImportSnapshot, TryoutSnapshotQuestion, Website
from app.services.ai_generation import ( from app.services.ai_generation import (
SUPPORTED_MODELS, SUPPORTED_MODELS,
generate_question, generate_question,
@@ -320,20 +320,24 @@ def _tryout_import_form_body(
) )
website_map = {website.id: website.site_name for website in websites} website_map = {website.id: website.site_name for website in websites}
snapshot_rows = [ snapshot_rows = []
[ for snapshot in recent_snapshots:
snapshot.id, snapshot_rows.append(
f'{website_map.get(snapshot.website_id, "Unknown")} (#{snapshot.website_id})', "<tr>"
snapshot.source_tryout_id, f"<td>{snapshot.id}</td>"
snapshot.title, f"<td>{escape(website_map.get(snapshot.website_id, 'Unknown'))} (#{snapshot.website_id})</td>"
snapshot.question_count, f"<td>{escape(snapshot.source_tryout_id)}</td>"
snapshot.created_at, f"<td>{escape(snapshot.title)}</td>"
] f"<td>{snapshot.question_count}</td>"
for snapshot in recent_snapshots f"<td>{escape(str(snapshot.created_at))}</td>"
] f"<td><a href=\"/admin/snapshot-questions?snapshot_id={snapshot.id}\" "
snapshots_table = _table( "style=\"display:inline-block;padding:8px 12px;border-radius:8px;background:#0f172a;color:#fff;text-decoration:none;\">Browse</a></td>"
["Snapshot ID", "Website", "Tryout ID", "Title", "Questions", "Imported At"], "</tr>"
snapshot_rows, )
snapshots_table = (
"<table><thead><tr><th>Snapshot ID</th><th>Website</th><th>Tryout ID</th><th>Title</th><th>Questions</th><th>Imported At</th><th>Actions</th></tr></thead><tbody>"
+ ("".join(snapshot_rows) if snapshot_rows else "<tr><td colspan=\"7\">No data</td></tr>")
+ "</tbody></table>"
) )
preview_html = "" preview_html = ""
@@ -401,6 +405,81 @@ def _tryout_import_form_body(
""" """
def _snapshot_slot_map(snapshot: TryoutImportSnapshot) -> dict[str, int]:
slot_map: dict[str, int] = {}
questions = (snapshot.raw_payload or {}).get("questions") or []
for index, question in enumerate(questions, start=1):
source_question_id = str((question or {}).get("id") or "").strip()
if source_question_id:
slot_map[source_question_id] = index
return slot_map
def _snapshot_options_to_item_options(raw_options: list[dict[str, Any]] | list[Any]) -> dict[str, str]:
item_options: dict[str, str] = {}
for option in raw_options or []:
if not isinstance(option, dict):
continue
increment = str(option.get("increment") or "").strip().upper()
text = str(option.get("text") or option.get("label") or "").strip()
if increment and text:
item_options[increment] = text
return item_options
def _snapshot_questions_body(
snapshot: TryoutImportSnapshot,
questions: list[TryoutSnapshotQuestion],
promoted_items_by_slot: dict[int, Item],
error: str | None = None,
success: str | None = None,
) -> str:
error_html = f'<div class="error">{escape(error)}</div>' if error else ""
success_html = f'<div class="success">{escape(success)}</div>' if success else ""
slot_map = _snapshot_slot_map(snapshot)
rows = []
for question in questions:
slot = slot_map.get(question.source_question_id, 0)
promoted_item = promoted_items_by_slot.get(slot)
if promoted_item:
action_html = (
f'Item #{promoted_item.id} already exists. '
f'<a href="/admin/ai-playground?basis_item_id={promoted_item.id}">Open in AI Playground</a>'
)
else:
action_html = (
f'<form method="post" action="/admin/snapshot-questions/promote" style="margin:0">'
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(
"<tr>"
f"<td>{slot or '-'}</td>"
f"<td>{escape(question.source_question_id)}</td>"
f"<td>{escape(question.correct_answer)}</td>"
f"<td>{question.option_count}</td>"
f"<td>{'Yes' if question.is_active else 'No'}</td>"
f"<td>{escape(_truncate(question.question_title or question.question_html, 100))}</td>"
f"<td>{action_html}</td>"
"</tr>"
)
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>"
+ ("".join(rows) if rows else "<tr><td colspan=\"7\">No data</td></tr>")
+ "</tbody></table>"
)
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">Promote selected snapshot questions into the live <code>items</code> table as <code>sedang</code> basis items for AI generation.</p>
{success_html}
{error_html}
{questions_table}
<p style="margin-top:20px"><a href="/admin/tryout-import">Back to Tryout Import</a></p>
"""
async def _basis_items_for_playground(db: AsyncSession, limit: int = 20) -> list[Item]: async def _basis_items_for_playground(db: AsyncSession, limit: int = 20) -> list[Item]:
result = await db.execute( result = await db.execute(
select(Item) select(Item)
@@ -492,6 +571,32 @@ async def _recent_snapshots(db: AsyncSession, limit: int = 20) -> list[TryoutImp
return list(result.scalars().all()) return list(result.scalars().all())
async def _ensure_operational_tryout(snapshot: TryoutImportSnapshot, db: AsyncSession) -> Tryout:
result = await db.execute(
select(Tryout).where(
Tryout.website_id == snapshot.website_id,
Tryout.tryout_id == snapshot.source_tryout_id,
)
)
tryout = result.scalar_one_or_none()
if tryout:
return tryout
tryout = Tryout(
website_id=snapshot.website_id,
tryout_id=snapshot.source_tryout_id,
name=snapshot.title,
description=f"Operational tryout basis created from imported snapshot #{snapshot.id}.",
scoring_mode="ctt",
selection_mode="fixed",
normalization_mode="static",
ai_generation_enabled=True,
)
db.add(tryout)
await db.flush()
return tryout
@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):
@@ -990,6 +1095,184 @@ async def tryout_import_submit(
return _render_admin_page("Tryout Import", "Tryout Import", body) return _render_admin_page("Tryout Import", "Tryout Import", body)
@router.get("/snapshot-questions", include_in_schema=False)
async def snapshot_questions_view(
request: Request,
snapshot_id: int,
db: AsyncSession = Depends(get_db),
):
admin = await _current_admin(request)
if not admin:
return _login_redirect()
snapshot = await db.get(TryoutImportSnapshot, snapshot_id)
if snapshot is None:
websites = await _load_websites(db)
snapshots = await _recent_snapshots(db)
body = _tryout_import_form_body(
websites,
snapshots,
error=f"Snapshot not found: {snapshot_id}",
)
return _render_admin_page("Tryout Import", "Tryout Import", body)
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(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)
return _render_admin_page("Snapshot Questions", "Snapshot Questions", body)
@router.post("/snapshot-questions/promote", include_in_schema=False)
async def snapshot_question_promote(
request: Request,
snapshot_id: int = Form(...),
snapshot_question_id: int = Form(...),
db: AsyncSession = Depends(get_db),
):
admin = await _current_admin(request)
if not admin:
return _login_redirect()
snapshot = await db.get(TryoutImportSnapshot, snapshot_id)
question = await db.get(TryoutSnapshotQuestion, snapshot_question_id)
if snapshot is None or question is None:
websites = await _load_websites(db)
snapshots = await _recent_snapshots(db)
body = _tryout_import_form_body(
websites,
snapshots,
error="Snapshot or snapshot question not found.",
)
return _render_admin_page("Tryout Import", "Tryout Import", body)
if (
question.website_id != snapshot.website_id
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(
snapshot,
questions,
promoted_items_by_slot,
error="Snapshot question has no usable option text, so it cannot be promoted into the live item bank.",
)
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(
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()}
questions.sort(key=lambda row: (slot_map.get(row.source_question_id, 10**9), row.source_question_id))
body = _snapshot_questions_body(snapshot, questions, promoted_items_by_slot, success=success_message)
return _render_admin_page("Snapshot Questions", "Snapshot Questions", body)
@router.get("/calibration-status", include_in_schema=False) @router.get("/calibration-status", include_in_schema=False)
async def calibration_status_view(request: Request, db: AsyncSession = Depends(get_db)): async def calibration_status_view(request: Request, db: AsyncSession = Depends(get_db)):
admin = await _current_admin(request) admin = await _current_admin(request)
@@ -1201,7 +1484,13 @@ async def ai_playground_view(request: Request, db: AsyncSession = Depends(get_db
stats = await get_ai_stats(db) stats = await get_ai_stats(db)
basis_items = await _basis_items_for_playground(db) basis_items = await _basis_items_for_playground(db)
body = _ai_form_body(bool(settings.OPENROUTER_API_KEY), stats, basis_items=basis_items) basis_item_id = request.query_params.get("basis_item_id", "")
body = _ai_form_body(
bool(settings.OPENROUTER_API_KEY),
stats,
basis_items=basis_items,
basis_item_id=str(basis_item_id or ""),
)
return _render_admin_page("AI Playground", "AI Playground", body) return _render_admin_page("AI Playground", "AI Playground", body)