perf: aggregate batch status statistics in a single query
This commit is contained in:
@@ -249,6 +249,38 @@ def test_last_review_updates_batch_and_keeps_zero(sqlite_db):
|
|||||||
assert sheet["D2"].value == 0
|
assert sheet["D2"].value == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("batch_count", [0, 20])
|
||||||
|
def test_batch_list_status_statistics_use_constant_query_count(sqlite_db, batch_count):
|
||||||
|
from wisegrade.api import list_batches
|
||||||
|
db = sqlite_db
|
||||||
|
item = project(db)
|
||||||
|
expected = {}
|
||||||
|
states = ["scored", "reviewed", "needs_review", "failed", "unscorable"]
|
||||||
|
for index in range(batch_count):
|
||||||
|
created = services.create_batch(SimpleNamespace(course_version_id=1, rubric_version_id=1), db)
|
||||||
|
batch = db.get(ScoringBatch, created["id"])
|
||||||
|
criterion = batch.rubric_snapshot["criteria"][0]["criterion_id"]
|
||||||
|
state = states[index % len(states)]
|
||||||
|
db.add(CriterionScore(batch_id=batch.id, project_id=item.id, criterion_id=criterion, status=state))
|
||||||
|
expected[batch.id] = {state: 1}
|
||||||
|
db.commit()
|
||||||
|
queries = []
|
||||||
|
def trace(_conn, _cursor, statement, _parameters, _context, _many):
|
||||||
|
if statement.lstrip().upper().startswith("SELECT"):
|
||||||
|
queries.append(statement)
|
||||||
|
event.listen(db.get_bind(), "before_cursor_execute", trace)
|
||||||
|
try:
|
||||||
|
listed = list_batches(db)
|
||||||
|
finally:
|
||||||
|
event.remove(db.get_bind(), "before_cursor_execute", trace)
|
||||||
|
assert len(queries) <= (2 if batch_count else 1), len(queries)
|
||||||
|
assert [row["id"] for row in listed] == sorted(expected, reverse=True)
|
||||||
|
for row in listed:
|
||||||
|
assert row["status_counts"] == expected[row["id"]]
|
||||||
|
for state in ("needs_review", "failed", "unscorable"):
|
||||||
|
assert row[state] == expected[row["id"]].get(state, 0)
|
||||||
|
|
||||||
|
|
||||||
def test_pdf_html_preserves_zero_and_none():
|
def test_pdf_html_preserves_zero_and_none():
|
||||||
batch = SimpleNamespace(id=1, course_version_id=1, rubric_version_id=1, model_config_snapshot={},
|
batch = SimpleNamespace(id=1, course_version_id=1, rubric_version_id=1, model_config_snapshot={},
|
||||||
embedding_model_name="test", embedding_model_version="test")
|
embedding_model_name="test", embedding_model_version="test")
|
||||||
|
|||||||
@@ -468,12 +468,15 @@ def create_batch(data: BatchInput, db: Session = Depends(get_db)):
|
|||||||
return services.create_batch(data, db)
|
return services.create_batch(data, db)
|
||||||
|
|
||||||
|
|
||||||
|
def status_count_dict(counts: dict) -> dict:
|
||||||
|
return {"status_counts": counts, "needs_review": counts.get("needs_review", 0),
|
||||||
|
"unscorable": counts.get("unscorable", 0), "failed": counts.get("failed", 0)}
|
||||||
|
|
||||||
|
|
||||||
def batch_status_counts(db, batch_id: int) -> dict:
|
def batch_status_counts(db, batch_id: int) -> dict:
|
||||||
rows = db.execute(select(CriterionScore.status, func.count(CriterionScore.id))
|
rows = db.execute(select(CriterionScore.status, func.count(CriterionScore.id))
|
||||||
.where(CriterionScore.batch_id == batch_id).group_by(CriterionScore.status)).all()
|
.where(CriterionScore.batch_id == batch_id).group_by(CriterionScore.status)).all()
|
||||||
counts = {status: count for status, count in rows}
|
return status_count_dict({status: count for status, count in rows})
|
||||||
return {"status_counts": counts, "needs_review": counts.get("needs_review", 0),
|
|
||||||
"unscorable": counts.get("unscorable", 0), "failed": counts.get("failed", 0)}
|
|
||||||
|
|
||||||
def batch_version_metadata(batch: ScoringBatch) -> dict:
|
def batch_version_metadata(batch: ScoringBatch) -> dict:
|
||||||
"""Display the immutable rule/course versions, not their database IDs."""
|
"""Display the immutable rule/course versions, not their database IDs."""
|
||||||
@@ -485,12 +488,20 @@ def batch_version_metadata(batch: ScoringBatch) -> dict:
|
|||||||
|
|
||||||
@router.get("/scoring-batches")
|
@router.get("/scoring-batches")
|
||||||
def list_batches(db: Session = Depends(get_db)):
|
def list_batches(db: Session = Depends(get_db)):
|
||||||
|
batches = db.scalars(select(ScoringBatch).order_by(ScoringBatch.id.desc())).all()
|
||||||
|
counts_by_batch = {}
|
||||||
|
if batches:
|
||||||
|
rows = db.execute(select(CriterionScore.batch_id, CriterionScore.status, func.count(CriterionScore.id))
|
||||||
|
.where(CriterionScore.batch_id.in_([batch.id for batch in batches]))
|
||||||
|
.group_by(CriterionScore.batch_id, CriterionScore.status)).all()
|
||||||
|
for batch_id, status, count in rows:
|
||||||
|
counts_by_batch.setdefault(batch_id, {})[status] = count
|
||||||
return [{**{key: getattr(batch, key) for key in ("id", "course_version_id", "rubric_version_id", "status", "progress_done", "progress_total", "error", "created_at", "finished_at")},
|
return [{**{key: getattr(batch, key) for key in ("id", "course_version_id", "rubric_version_id", "status", "progress_done", "progress_total", "error", "created_at", "finished_at")},
|
||||||
"status_label": BATCH_STATUS_LABELS.get(batch.status, batch.status),
|
"status_label": BATCH_STATUS_LABELS.get(batch.status, batch.status),
|
||||||
"project_ids": batch.project_ids, "legacy_membership": batch.project_ids is None,
|
"project_ids": batch.project_ids, "legacy_membership": batch.project_ids is None,
|
||||||
**batch_version_metadata(batch),
|
**batch_version_metadata(batch),
|
||||||
**batch_status_counts(db, batch.id)}
|
**status_count_dict(counts_by_batch.get(batch.id, {}))}
|
||||||
for batch in db.scalars(select(ScoringBatch).order_by(ScoringBatch.id.desc()))]
|
for batch in batches]
|
||||||
|
|
||||||
|
|
||||||
@router.get("/scoring-batches/{batch_id}/progress")
|
@router.get("/scoring-batches/{batch_id}/progress")
|
||||||
|
|||||||
Reference in New Issue
Block a user