- POST /api/recipe-scan/import — bulk ingest from Kiwi scanner pipeline, idempotent by item id
- GET /api/recipe-scan/next — oldest-first pending item for review
- POST /api/recipe-scan/items/{id}/approve|edit|reject — label actions
- GET /api/recipe-scan/stats — counts by status and modality
- GET /api/recipe-scan/export — JSONL training pairs (messages chat format, Option B: correction prompt + extracted draft → corrected ground truth)
- GET /api/recipe-scan/image — path-traversal-safe image serving from /Library/Assets/kiwi/
- SQLite at data/recipe_scan.db with WAL mode; separate from corpus.db lifecycle
- set_db_path() testability seam; 18 tests, all passing
- RecipeScanView.vue: two-column review UI (image left, JSON diff right), keyboard shortcuts A/E/R, toast feedback, stats header, export download
- Route /data/recipe-scan and sidebar nav entry added
227 lines
8.3 KiB
Python
227 lines
8.3 KiB
Python
"""Tests for app/data/recipe_scan.py — recipe scan labeling endpoints."""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import uuid
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
from app.data import recipe_scan as rs
|
|
|
|
|
|
EXTRACTED = {"title": "Shepherd's Pie", "ingredients": ["lamb", "potato"], "steps": ["brown meat", "mash potato"]}
|
|
GROUND_TRUTH = {"title": "Shepherd's Pie", "ingredients": ["ground lamb", "mashed potato", "peas"], "steps": ["brown meat", "add veg", "mash potato", "bake"]}
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def isolated_db(tmp_path, monkeypatch):
|
|
monkeypatch.setattr(rs, "_DB_PATH", tmp_path / "recipe_scan.db")
|
|
rs._init_db()
|
|
|
|
|
|
@pytest.fixture()
|
|
def client():
|
|
from fastapi import FastAPI
|
|
app = FastAPI()
|
|
app.include_router(rs.router, prefix="/api/recipe-scan")
|
|
return TestClient(app)
|
|
|
|
|
|
def _item(**kwargs) -> dict:
|
|
return {
|
|
"id": str(uuid.uuid4()),
|
|
"image_path": "/Library/Assets/kiwi/scans/pc_test.jpg",
|
|
"modality": kwargs.get("modality", "scanner"),
|
|
"source": kwargs.get("source", "purple_carrot"),
|
|
"extracted": kwargs.get("extracted", EXTRACTED),
|
|
"ground_truth": kwargs.get("ground_truth", GROUND_TRUTH),
|
|
}
|
|
|
|
|
|
def _import(client, items: list[dict]) -> None:
|
|
resp = client.post("/api/recipe-scan/import", json={"items": items})
|
|
assert resp.status_code == 200
|
|
|
|
|
|
# ── Import ─────────────────────────────────────────────────────────────────────
|
|
|
|
def test_import_stores_items(client):
|
|
_import(client, [_item()])
|
|
stats = client.get("/api/recipe-scan/stats").json()
|
|
assert stats["total"] == 1
|
|
assert stats["by_status"]["pending"] == 1
|
|
|
|
|
|
def test_import_rejects_unknown_modality(client):
|
|
bad = _item()
|
|
bad["modality"] = "telepathy"
|
|
resp = client.post("/api/recipe-scan/import", json={"items": [bad]})
|
|
assert resp.status_code == 422
|
|
|
|
|
|
def test_import_is_idempotent(client):
|
|
item = _item()
|
|
_import(client, [item])
|
|
_import(client, [item]) # same id — should not duplicate
|
|
stats = client.get("/api/recipe-scan/stats").json()
|
|
assert stats["total"] == 1
|
|
|
|
|
|
def test_import_multiple_items(client):
|
|
_import(client, [_item(), _item(), _item()])
|
|
assert client.get("/api/recipe-scan/stats").json()["total"] == 3
|
|
|
|
|
|
# ── Next ───────────────────────────────────────────────────────────────────────
|
|
|
|
def test_next_returns_404_when_queue_empty(client):
|
|
resp = client.get("/api/recipe-scan/next")
|
|
assert resp.status_code == 404
|
|
|
|
|
|
def test_next_returns_pending_item(client):
|
|
item = _item()
|
|
_import(client, [item])
|
|
resp = client.get("/api/recipe-scan/next")
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["id"] == item["id"]
|
|
assert data["status"] == "pending"
|
|
assert "extracted" in data
|
|
assert "ground_truth" in data
|
|
|
|
|
|
def test_next_skips_non_pending(client):
|
|
item = _item()
|
|
_import(client, [item])
|
|
client.post(f"/api/recipe-scan/items/{item['id']}/reject")
|
|
resp = client.get("/api/recipe-scan/next")
|
|
assert resp.status_code == 404
|
|
|
|
|
|
# ── Approve ────────────────────────────────────────────────────────────────────
|
|
|
|
def test_approve_marks_item_approved(client):
|
|
item = _item()
|
|
_import(client, [item])
|
|
resp = client.post(f"/api/recipe-scan/items/{item['id']}/approve")
|
|
assert resp.status_code == 200
|
|
assert resp.json()["status"] == "approved"
|
|
stats = client.get("/api/recipe-scan/stats").json()
|
|
assert stats["by_status"]["approved"] == 1
|
|
|
|
|
|
def test_approve_returns_404_for_unknown_id(client):
|
|
resp = client.post("/api/recipe-scan/items/no-such-id/approve")
|
|
assert resp.status_code == 404
|
|
|
|
|
|
# ── Edit ───────────────────────────────────────────────────────────────────────
|
|
|
|
def test_edit_stores_corrected_json(client):
|
|
item = _item()
|
|
_import(client, [item])
|
|
corrected = {**GROUND_TRUTH, "servings": 4}
|
|
resp = client.post(
|
|
f"/api/recipe-scan/items/{item['id']}/edit",
|
|
json={"corrected": corrected},
|
|
)
|
|
assert resp.status_code == 200
|
|
assert resp.json()["status"] == "edited"
|
|
stats = client.get("/api/recipe-scan/stats").json()
|
|
assert stats["by_status"]["edited"] == 1
|
|
|
|
|
|
def test_edit_requires_corrected_field(client):
|
|
item = _item()
|
|
_import(client, [item])
|
|
resp = client.post(f"/api/recipe-scan/items/{item['id']}/edit", json={})
|
|
assert resp.status_code == 422
|
|
|
|
|
|
# ── Reject ─────────────────────────────────────────────────────────────────────
|
|
|
|
def test_reject_marks_item_rejected(client):
|
|
item = _item()
|
|
_import(client, [item])
|
|
resp = client.post(
|
|
f"/api/recipe-scan/items/{item['id']}/reject",
|
|
json={"reason": "OCR completely unreadable"},
|
|
)
|
|
assert resp.status_code == 200
|
|
assert resp.json()["status"] == "rejected"
|
|
|
|
|
|
def test_reject_without_reason_is_valid(client):
|
|
item = _item()
|
|
_import(client, [item])
|
|
resp = client.post(f"/api/recipe-scan/items/{item['id']}/reject")
|
|
assert resp.status_code == 200
|
|
|
|
|
|
# ── Export ─────────────────────────────────────────────────────────────────────
|
|
|
|
def test_export_empty_when_nothing_approved(client):
|
|
item = _item()
|
|
_import(client, [item])
|
|
resp = client.get("/api/recipe-scan/export")
|
|
assert resp.status_code == 200
|
|
assert resp.text.strip() == ""
|
|
|
|
|
|
def test_export_includes_approved_item(client):
|
|
item = _item()
|
|
_import(client, [item])
|
|
client.post(f"/api/recipe-scan/items/{item['id']}/approve")
|
|
resp = client.get("/api/recipe-scan/export")
|
|
lines = [l for l in resp.text.strip().splitlines() if l]
|
|
assert len(lines) == 1
|
|
pair = json.loads(lines[0])
|
|
assert pair["id"] == item["id"]
|
|
assert pair["modality"] == "scanner"
|
|
assert "messages" in pair
|
|
assert len(pair["messages"]) == 2
|
|
assert pair["messages"][0]["role"] == "user"
|
|
assert pair["messages"][1]["role"] == "assistant"
|
|
|
|
|
|
def test_export_includes_edited_item_with_correction(client):
|
|
item = _item()
|
|
_import(client, [item])
|
|
corrected = {**GROUND_TRUTH, "servings": 4}
|
|
client.post(
|
|
f"/api/recipe-scan/items/{item['id']}/edit",
|
|
json={"corrected": corrected},
|
|
)
|
|
resp = client.get("/api/recipe-scan/export")
|
|
lines = [l for l in resp.text.strip().splitlines() if l]
|
|
pair = json.loads(lines[0])
|
|
assistant_content = json.loads(pair["messages"][1]["content"])
|
|
assert assistant_content["servings"] == 4
|
|
|
|
|
|
def test_export_excludes_rejected_items(client):
|
|
item = _item()
|
|
_import(client, [item])
|
|
client.post(f"/api/recipe-scan/items/{item['id']}/reject")
|
|
resp = client.get("/api/recipe-scan/export")
|
|
assert resp.text.strip() == ""
|
|
|
|
|
|
# ── Stats ──────────────────────────────────────────────────────────────────────
|
|
|
|
def test_stats_counts_all_statuses(client):
|
|
items = [_item(), _item(), _item(), _item()]
|
|
_import(client, items)
|
|
client.post(f"/api/recipe-scan/items/{items[0]['id']}/approve")
|
|
client.post(f"/api/recipe-scan/items/{items[1]['id']}/edit", json={"corrected": GROUND_TRUTH})
|
|
client.post(f"/api/recipe-scan/items/{items[2]['id']}/reject")
|
|
stats = client.get("/api/recipe-scan/stats").json()
|
|
assert stats["total"] == 4
|
|
assert stats["by_status"]["pending"] == 1
|
|
assert stats["by_status"]["approved"] == 1
|
|
assert stats["by_status"]["edited"] == 1
|
|
assert stats["by_status"]["rejected"] == 1
|
|
assert stats["export_ready"] == 2 # approved + edited
|