feat: fold quality review into queue tabs
This commit is contained in:
parent
4f10978989
commit
5cef8f9b59
|
|
@ -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,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue