feat: fold quality review into queue tabs

This commit is contained in:
Sean McElwain 2026-04-06 17:56:58 -05:00
parent 4f10978989
commit 5cef8f9b59
2 changed files with 209 additions and 68 deletions

View File

@ -9,6 +9,7 @@ from sqlalchemy.orm import Session, selectinload
from app.db.deps import get_db from app.db.deps import get_db
from app.models.document import Document from app.models.document import Document
from app.models.extracted_field import ExtractedField from app.models.extracted_field import ExtractedField
from app.models.receipt_line_item import ReceiptLineItem
router = APIRouter(prefix="/queue", tags=["queue"]) router = APIRouter(prefix="/queue", tags=["queue"])
@ -16,11 +17,61 @@ BASE_DIR = Path(__file__).resolve().parent.parent
templates = Jinja2Templates(directory=str(BASE_DIR / "templates")) templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
def _needs_quality_review(item: ReceiptLineItem) -> bool:
if (item.item_category or "").lower() != "cocktail":
return False
extra = item.extra_json or {}
status = str(extra.get("quality_status") or "").strip().lower()
rating = str(extra.get("quality_rating") or "").strip()
if status == "na":
return False
if rating:
return False
return True
def _quality_row(item: ReceiptLineItem) -> dict | None:
document = item.document
if document is None:
return None
extracted = document.extracted_fields[0] if document.extracted_fields else None
transaction_date = ""
merchant = ""
if extracted is not None:
if extracted.transaction_date:
transaction_date = extracted.transaction_date.isoformat()
merchant = extracted.merchant_normalized or extracted.merchant_raw or ""
extra = item.extra_json or {}
return {
"line_item_id": item.id,
"document_id": document.document_id,
"transaction_date": transaction_date,
"merchant": merchant,
"description": item.normalized_description or item.raw_description or "",
"raw_description": item.raw_description or "",
"line_total": str(item.line_total) if item.line_total is not None else "",
"category": item.item_category or "",
"quality_rating": str(extra.get("quality_rating") or ""),
"quality_note": str(extra.get("quality_note") or ""),
}
@router.get("/", response_class=HTMLResponse) @router.get("/", response_class=HTMLResponse)
def review_queue(request: Request, db: Session = Depends(get_db)): def review_queue(request: Request, tab: str = "ocr", db: Session = Depends(get_db)):
if tab not in {"ocr", "fields", "quality", "recent"}:
tab = "ocr"
needs_ocr_review = ( needs_ocr_review = (
db.query(Document) db.query(Document)
.filter(Document.is_trashed.is_(False)).filter(Document.review_status != "reviewed") .filter(Document.is_trashed.is_(False))
.filter(Document.review_status != "reviewed")
.order_by(Document.created_at.asc()) .order_by(Document.created_at.asc())
.all() .all()
) )
@ -28,7 +79,8 @@ def review_queue(request: Request, db: Session = Depends(get_db)):
needs_field_extraction = ( needs_field_extraction = (
db.query(Document) db.query(Document)
.options(selectinload(Document.extracted_fields)) .options(selectinload(Document.extracted_fields))
.filter(Document.is_trashed.is_(False)).filter(Document.review_status == "reviewed") .filter(Document.is_trashed.is_(False))
.filter(Document.review_status == "reviewed")
.filter(~exists().where(ExtractedField.document_id == Document.id)) .filter(~exists().where(ExtractedField.document_id == Document.id))
.order_by(Document.updated_at.asc()) .order_by(Document.updated_at.asc())
.all() .all()
@ -36,13 +88,31 @@ def review_queue(request: Request, db: Session = Depends(get_db)):
recently_updated = ( recently_updated = (
db.query(Document) db.query(Document)
.filter(Document.is_trashed.is_(False)).order_by(Document.updated_at.desc()) .filter(Document.is_trashed.is_(False))
.order_by(Document.updated_at.desc())
.limit(25) .limit(25)
.all() .all()
) )
quality_candidates = (
db.query(ReceiptLineItem)
.options(
selectinload(ReceiptLineItem.document).selectinload(Document.extracted_fields)
)
.order_by(ReceiptLineItem.id.asc())
.all()
)
needs_quality_review = []
for item in quality_candidates:
if _needs_quality_review(item):
row = _quality_row(item)
if row is not None:
needs_quality_review.append(row)
next_ocr = needs_ocr_review[0] if needs_ocr_review else None next_ocr = needs_ocr_review[0] if needs_ocr_review else None
next_fields = needs_field_extraction[0] if needs_field_extraction else None next_fields = needs_field_extraction[0] if needs_field_extraction else None
next_quality = needs_quality_review[0] if needs_quality_review else None
return templates.TemplateResponse( return templates.TemplateResponse(
request=request, request=request,
@ -51,9 +121,12 @@ def review_queue(request: Request, db: Session = Depends(get_db)):
"request": request, "request": request,
"needs_ocr_review": needs_ocr_review, "needs_ocr_review": needs_ocr_review,
"needs_field_extraction": needs_field_extraction, "needs_field_extraction": needs_field_extraction,
"needs_quality_review": needs_quality_review,
"recently_updated": recently_updated, "recently_updated": recently_updated,
"next_ocr": next_ocr, "next_ocr": next_ocr,
"next_fields": next_fields, "next_fields": next_fields,
"next_quality": next_quality,
"active_page": "queue", "active_page": "queue",
"active_tab": tab,
}, },
) )

View File

@ -13,7 +13,7 @@
<div class="topbar"> <div class="topbar">
<div> <div>
<h1 class="page-title">Review Queue</h1> <h1 class="page-title">Review Queue</h1>
<p class="page-subtitle">Work through OCR review and field extraction in order.</p> <p class="page-subtitle">Work through OCR review, field extraction, quality review, and recent activity.</p>
</div> </div>
</div> </div>
@ -25,75 +25,143 @@
{% if next_fields %} {% if next_fields %}
<a class="button-link" href="/documents/{{ next_fields.document_id }}?queue=fields">Next needing field extraction</a> <a class="button-link" href="/documents/{{ next_fields.document_id }}?queue=fields">Next needing field extraction</a>
{% endif %} {% endif %}
{% if next_quality %}
<a class="button-link" href="/queue/?tab=quality#quality-{{ next_quality.line_item_id }}">Next needing quality review</a>
{% endif %}
</div> </div>
</div> </div>
<div class="card"> <div class="card">
<h2 class="card-title">Needs OCR review ({{ needs_ocr_review|length }})</h2> <div class="right-pane-tabs">
{% if needs_ocr_review %} <a class="tab-button{% if active_tab == 'ocr' %} active{% endif %}" href="/queue/?tab=ocr">OCR Review</a>
<div class="table-wrap"> <a class="tab-button{% if active_tab == 'fields' %} active{% endif %}" href="/queue/?tab=fields">Field Extraction</a>
<table> <a class="tab-button{% if active_tab == 'quality' %} active{% endif %}" href="/queue/?tab=quality">Quality Review</a>
<thead><tr><th>Document</th><th>Type</th><th>Review status</th><th>Updated</th></tr></thead> <a class="tab-button{% if active_tab == 'recent' %} active{% endif %}" href="/queue/?tab=recent">Recently Updated</a>
<tbody> </div>
{% for doc in needs_ocr_review %}
<tr>
<td><a href="/documents/{{ doc.document_id }}?queue=ocr">{{ doc.document_id }}</a></td>
<td>{{ doc.document_type }}</td>
<td><span class="badge pending">{{ doc.review_status }}</span></td>
<td>{{ doc.updated_at }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="empty-state">No documents currently need OCR review.</p>
{% endif %}
</div>
<div class="card"> <div class="tab-panel{% if active_tab == 'ocr' %} active{% endif %}">
<h2 class="card-title">Needs field extraction ({{ needs_field_extraction|length }})</h2> <h2 class="card-title">Needs OCR review ({{ needs_ocr_review|length }})</h2>
{% if needs_field_extraction %} {% if needs_ocr_review %}
<div class="table-wrap"> <div class="table-wrap">
<table> <table>
<thead><tr><th>Document</th><th>Type</th><th>Review status</th><th>Updated</th></tr></thead> <thead>
<tbody> <tr><th>Document</th><th>Type</th><th>Review status</th><th>Updated</th></tr>
{% for doc in needs_field_extraction %} </thead>
<tr> <tbody>
<td><a href="/documents/{{ doc.document_id }}?queue=fields">{{ doc.document_id }}</a></td> {% for doc in needs_ocr_review %}
<td>{{ doc.document_type }}</td> <tr>
<td><span class="badge reviewed">{{ doc.review_status }}</span></td> <td><a href="/documents/{{ doc.document_id }}?queue=ocr">{{ doc.document_id }}</a></td>
<td>{{ doc.updated_at }}</td> <td>{{ doc.document_type }}</td>
</tr> <td><span class="badge pending">{{ doc.review_status }}</span></td>
{% endfor %} <td>{{ doc.updated_at }}</td>
</tbody> </tr>
</table> {% endfor %}
</div> </tbody>
{% else %} </table>
<p class="empty-state">No reviewed documents are waiting on field extraction.</p> </div>
{% endif %} {% else %}
</div> <p class="empty-state">No documents currently need OCR review.</p>
{% endif %}
</div>
<div class="card"> <div class="tab-panel{% if active_tab == 'fields' %} active{% endif %}">
<h2 class="card-title">Recently updated</h2> <h2 class="card-title">Needs field extraction ({{ needs_field_extraction|length }})</h2>
{% if recently_updated %} {% if needs_field_extraction %}
<div class="table-wrap"> <div class="table-wrap">
<table> <table>
<thead><tr><th>Document</th><th>Type</th><th>Review status</th><th>Current path</th><th>Updated</th></tr></thead> <thead>
<tbody> <tr><th>Document</th><th>Type</th><th>Review status</th><th>Updated</th></tr>
{% for doc in recently_updated %} </thead>
<tr> <tbody>
<td><a href="/documents/{{ doc.document_id }}?queue=recent">{{ doc.document_id }}</a></td> {% for doc in needs_field_extraction %}
<td>{{ doc.document_type }}</td> <tr>
<td><span class="badge {% if doc.review_status == 'reviewed' %}reviewed{% else %}pending{% endif %}">{{ doc.review_status }}</span></td> <td><a href="/documents/{{ doc.document_id }}?queue=fields">{{ doc.document_id }}</a></td>
<td>{{ doc.current_path }}</td> <td>{{ doc.document_type }}</td>
<td>{{ doc.updated_at }}</td> <td><span class="badge reviewed">{{ doc.review_status }}</span></td>
</tr> <td>{{ doc.updated_at }}</td>
{% endfor %} </tr>
</tbody> {% endfor %}
</table> </tbody>
</div> </table>
{% endif %} </div>
{% else %}
<p class="empty-state">No reviewed documents are waiting on field extraction.</p>
{% endif %}
</div>
<div class="tab-panel{% if active_tab == 'quality' %} active{% endif %}">
<h2 class="card-title">Needs cocktail quality review ({{ needs_quality_review|length }})</h2>
{% if needs_quality_review %}
{% for row in needs_quality_review %}
<div class="card" id="quality-{{ row.line_item_id }}" style="margin-bottom: 1rem;">
<div class="topbar" style="margin-bottom: 0.75rem;">
<div>
<div class="page-subtitle">{{ row.transaction_date }} · {{ row.merchant }}</div>
<h3 class="card-title" style="margin: 0.2rem 0 0 0;">{{ row.description }}</h3>
<div class="page-subtitle">{{ row.raw_description }}</div>
</div>
<div class="badges">
{% if row.category %}
<span class="badge">{{ row.category }}</span>
{% endif %}
{% if row.line_total %}
<span class="badge">${{ row.line_total }}</span>
{% endif %}
</div>
</div>
<form method="post" action="/line-items/{{ row.line_item_id }}/review">
<input type="hidden" name="return_to" value="quality_queue">
<div class="form-grid">
<div class="form-field">
<label for="quality_rating_{{ row.line_item_id }}">Quality rating</label>
<input id="quality_rating_{{ row.line_item_id }}" type="text" name="quality_rating" value="{{ row.quality_rating }}" placeholder="e.g. 8">
</div>
<div class="form-field full">
<label for="quality_note_{{ row.line_item_id }}">Quality note</label>
<textarea id="quality_note_{{ row.line_item_id }}" name="quality_note" rows="3" placeholder="Taste, sweetness, strength, food notes...">{{ row.quality_note }}</textarea>
</div>
</div>
<div class="button-row" style="margin-top: 1rem;">
<button class="primary" type="submit" name="quality_status" value="rated">Save rating</button>
<button type="submit" name="quality_status" value="na">Mark N/A</button>
<a class="button-link" href="/documents/{{ row.document_id }}?tab=extracted-fields">Open document</a>
</div>
</form>
</div>
{% endfor %}
{% else %}
<p class="empty-state">No cocktail line items are waiting for quality review.</p>
{% endif %}
</div>
<div class="tab-panel{% if active_tab == 'recent' %} active{% endif %}">
<h2 class="card-title">Recently updated</h2>
{% if recently_updated %}
<div class="table-wrap">
<table>
<thead>
<tr><th>Document</th><th>Type</th><th>Review status</th><th>Current path</th><th>Updated</th></tr>
</thead>
<tbody>
{% for doc in recently_updated %}
<tr>
<td><a href="/documents/{{ doc.document_id }}?queue=recent">{{ doc.document_id }}</a></td>
<td>{{ doc.document_type }}</td>
<td><span class="badge {% if doc.review_status == 'reviewed' %}reviewed{% else %}pending{% endif %}">{{ doc.review_status }}</span></td>
<td>{{ doc.current_path }}</td>
<td>{{ doc.updated_at }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="empty-state">No recent documents found.</p>
{% endif %}
</div>
</div> </div>
</main> </main>
</div> </div>