feat(scan): add bag scanning page with combined AI extraction

Add /scan page that lets authenticated users photograph or describe a
coffee bag, extracts both roaster and roast data via a single AI call,
and creates both entities on submit. Existing roasters are matched by
slug to avoid duplicates. Camera icon added to nav bar.
This commit is contained in:
Jon Seager 2026-02-03 19:15:39 +00:00
parent 023ad1ac31
commit 84ffe36afc
No known key found for this signature in database
6 changed files with 466 additions and 2 deletions

View file

@ -7,6 +7,7 @@ pub mod gear;
mod macros;
pub mod roasters;
pub mod roasts;
pub mod scan;
pub mod support;
pub mod timeline;
pub mod tokens;
@ -77,6 +78,8 @@ pub fn app_router(state: AppState) -> axum::Router {
.route("/nearby-cafes", get(cafes::nearby_cafes))
.route("/extract-roaster", post(roasters::extract_roaster))
.route("/extract-roast", post(roasts::extract_roast_info))
.route("/extract-bag-scan", post(scan::extract_bag_scan))
.route("/scan", post(scan::submit_scan))
.route("/cups", get(cups::list_cups).post(cups::create_cup))
.route(
"/cups/:id",
@ -107,6 +110,7 @@ pub fn app_router(state: AppState) -> axum::Router {
.route("/cafes", get(cafes::cafes_page))
.route("/cafes/:slug", get(cafes::cafe_page))
.route("/cups", get(cups::cups_page))
.route("/scan", get(scan::scan_page))
.route("/timeline", get(timeline::timeline_page))
.route("/styles.css", get(styles))
.route("/favicon.ico", get(favicon))

View file

@ -319,13 +319,13 @@ impl NewRoastSubmission {
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum TastingNotesInput {
pub(crate) enum TastingNotesInput {
List(Vec<String>),
Text(String),
}
impl TastingNotesInput {
fn into_vec(self) -> Vec<String> {
pub(crate) fn into_vec(self) -> Vec<String> {
match self {
TastingNotesInput::List(values) => values
.into_iter()

View file

@ -0,0 +1,137 @@
use axum::Json;
use axum::extract::State;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Redirect, Response};
use serde::{Deserialize, Serialize};
use crate::application::auth::AuthenticatedUser;
use crate::application::errors::{ApiError, AppError};
use crate::application::routes::render_html;
use crate::application::routes::roasts::TastingNotesInput;
use crate::application::server::AppState;
use crate::domain::errors::RepositoryError;
use crate::domain::roasters::NewRoaster;
use crate::domain::roasts::NewRoast;
use crate::infrastructure::ai::{self, ExtractedBagScan, ExtractionInput};
use crate::presentation::web::templates::ScanTemplate;
#[tracing::instrument(skip(state, cookies))]
pub(crate) async fn scan_page(
State(state): State<AppState>,
cookies: tower_cookies::Cookies,
) -> Result<Response, StatusCode> {
let is_authenticated = super::is_authenticated(&state, &cookies).await;
if !is_authenticated || !state.has_ai_extract() {
return Ok(Redirect::to("/timeline").into_response());
}
let template = ScanTemplate {
nav_active: "scan",
is_authenticated: true,
has_ai_extract: true,
};
render_html(template).map(IntoResponse::into_response)
}
#[tracing::instrument(skip(state, _auth_user))]
pub(crate) async fn extract_bag_scan(
State(state): State<AppState>,
_auth_user: AuthenticatedUser,
Json(input): Json<ExtractionInput>,
) -> Result<Json<ExtractedBagScan>, ApiError> {
let api_key = state
.openrouter_api_key
.as_deref()
.ok_or_else(|| AppError::validation("AI extraction is not configured"))?;
let result = ai::extract_bag_scan(&state.http_client, api_key, &state.openrouter_model, &input)
.await
.map_err(ApiError::from)?;
Ok(Json(result))
}
#[derive(Debug, Deserialize)]
pub(crate) struct BagScanSubmission {
roaster_name: String,
roaster_country: String,
roaster_city: Option<String>,
roaster_homepage: Option<String>,
roaster_notes: Option<String>,
roast_name: String,
origin: String,
region: String,
producer: String,
process: String,
tasting_notes: TastingNotesInput,
}
#[derive(Debug, Serialize)]
struct ScanResult {
redirect: String,
}
#[tracing::instrument(skip(state, _auth_user))]
pub(crate) async fn submit_scan(
State(state): State<AppState>,
_auth_user: AuthenticatedUser,
Json(submission): Json<BagScanSubmission>,
) -> Result<Response, ApiError> {
// Build and normalize the roaster
let new_roaster = NewRoaster {
name: submission.roaster_name,
country: submission.roaster_country,
city: submission.roaster_city,
homepage: submission.roaster_homepage,
notes: submission.roaster_notes,
}
.normalize();
let slug = new_roaster.slug();
// Try to find existing roaster by slug, otherwise create
let roaster = match state.roaster_repo.get_by_slug(&slug).await {
Ok(existing) => existing,
Err(RepositoryError::NotFound) => state
.roaster_repo
.insert(new_roaster)
.await
.map_err(AppError::from)?,
Err(err) => return Err(AppError::from(err).into()),
};
// Validate and build the roast
let tasting_notes = submission.tasting_notes.into_vec();
if tasting_notes.is_empty() {
return Err(AppError::validation("tasting notes are required").into());
}
fn require(field: &str, value: &str) -> Result<String, AppError> {
let trimmed = value.trim();
if trimmed.is_empty() {
Err(AppError::validation(format!("{field} is required")))
} else {
Ok(trimmed.to_string())
}
}
let new_roast = NewRoast {
roaster_id: roaster.id,
name: require("roast name", &submission.roast_name)?,
origin: require("origin", &submission.origin)?,
region: require("region", &submission.region)?,
producer: require("producer", &submission.producer)?,
process: require("process", &submission.process)?,
tasting_notes,
};
let roast = state
.roast_repo
.insert(new_roast)
.await
.map_err(AppError::from)?;
let redirect = format!("/roasters/{}/roasts/{}", roaster.slug, roast.slug);
Ok((StatusCode::CREATED, Json(ScanResult { redirect })).into_response())
}

View file

@ -28,6 +28,28 @@ const ROAST_PROMPT: &str = r#"Extract coffee roast information from this input.
Return ONLY the JSON object, no other text."#;
const SCAN_PROMPT: &str = r#"Extract both the coffee roaster and the roast information from this input. Use web search to look up any details you cannot determine from the input alone (e.g. the roaster's website, location, tasting notes, processing method). Return a JSON object with two top-level keys:
{
"roaster": {
"name": "the roaster's name",
"country": "country the roaster is based in",
"city": "city the roaster is based in",
"homepage": "the roaster's website URL",
"notes": "a single sentence describing the roaster"
},
"roast": {
"name": "the name of this specific coffee/roast",
"origin": "the country of origin of the beans",
"region": "the region within the origin country",
"producer": "the farm, estate, or cooperative",
"process": "processing method (e.g. Washed, Natural, Honey, Anaerobic)",
"tasting_notes": ["Array", "Of", "Flavour Notes In Title Case"]
}
}
Only include fields you can identify with confidence. Each tasting note must be in Title Case. Return ONLY the JSON object, no other text."#;
// --- Public types ---
#[derive(Debug, Deserialize)]
@ -56,6 +78,12 @@ pub struct ExtractedRoast {
pub tasting_notes: Option<Vec<String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExtractedBagScan {
pub roaster: ExtractedRoaster,
pub roast: ExtractedRoast,
}
// --- Public functions ---
pub async fn extract_roaster(
@ -86,6 +114,20 @@ pub async fn extract_roast(
})
}
pub async fn extract_bag_scan(
client: &reqwest::Client,
api_key: &str,
model: &str,
input: &ExtractionInput,
) -> Result<ExtractedBagScan, AppError> {
let content = call_openrouter(client, api_key, model, SCAN_PROMPT, input).await?;
let json = extract_json(&content);
serde_json::from_str(json).map_err(|e| {
AppError::unexpected(format!("Failed to parse AI response as bag scan data: {e}"))
})
}
// --- Internal helpers ---
async fn call_openrouter(

View file

@ -11,6 +11,13 @@
<a class="border-b-2 pb-1 transition {% if nav_active == "cups" %}text-amber-700 border-amber-500{% else %}text-stone-500 border-transparent hover:text-amber-600 hover:border-amber-400{% endif %}" href="/cups">Cups</a>
<a class="border-b-2 pb-1 transition {% if nav_active == "gear" %}text-amber-700 border-amber-500{% else %}text-stone-500 border-transparent hover:text-amber-600 hover:border-amber-400{% endif %}" href="/gear">Gear</a>
<a class="border-b-2 pb-1 transition {% if nav_active == "timeline" %}text-amber-700 border-amber-500{% else %}text-stone-500 border-transparent hover:text-amber-600 hover:border-amber-400{% endif %}" href="/timeline">Timeline</a>
{% if is_authenticated && has_ai_extract %}
<a class="border-b-2 pb-1 transition {% if nav_active == "scan" %}text-amber-700 border-amber-500{% else %}text-stone-500 border-transparent hover:text-amber-600 hover:border-amber-400{% endif %}" href="/scan" aria-label="Scan bag">
<svg class="inline h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M1 8a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 018.07 3h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0016.07 6H17a2 2 0 012 2v7a2 2 0 01-2 2H3a2 2 0 01-2-2V8zm13.5 3a4.5 4.5 0 11-9 0 4.5 4.5 0 019 0zM10 14a3 3 0 100-6 3 3 0 000 6z" clip-rule="evenodd" />
</svg>
</a>
{% endif %}
{% if is_authenticated %}
<form method="post" action="/logout" class="inline">
<button type="submit" class="border-b-2 pb-1 transition text-stone-500 border-transparent hover:text-amber-600 hover:border-amber-400">Logout</button>
@ -39,6 +46,14 @@
<a class="py-1 transition {% if nav_active == "cups" %}text-amber-700 font-medium{% else %}text-stone-500 hover:text-amber-600{% endif %}" href="/cups">Cups</a>
<a class="py-1 transition {% if nav_active == "gear" %}text-amber-700 font-medium{% else %}text-stone-500 hover:text-amber-600{% endif %}" href="/gear">Gear</a>
<a class="py-1 transition {% if nav_active == "timeline" %}text-amber-700 font-medium{% else %}text-stone-500 hover:text-amber-600{% endif %}" href="/timeline">Timeline</a>
{% if is_authenticated && has_ai_extract %}
<a class="py-1 transition inline-flex items-center gap-1 {% if nav_active == "scan" %}text-amber-700 font-medium{% else %}text-stone-500 hover:text-amber-600{% endif %}" href="/scan">
<svg class="inline h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M1 8a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 018.07 3h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0016.07 6H17a2 2 0 012 2v7a2 2 0 01-2 2H3a2 2 0 01-2-2V8zm13.5 3a4.5 4.5 0 11-9 0 4.5 4.5 0 019 0zM10 14a3 3 0 100-6 3 3 0 000 6z" clip-rule="evenodd" />
</svg>
Scan
</a>
{% endif %}
{% if is_authenticated %}
<form method="post" action="/logout" class="inline">
<button type="submit" class="py-1 transition text-stone-500 hover:text-amber-600 text-left">Logout</button>

266
templates/scan.html Normal file
View file

@ -0,0 +1,266 @@
{% extends "base.html" %} {% block title %}Brewlog · Scan Bag{% endblock %}
{% block head %}
<script>
var _extracting = false;
var _submitting = false;
function triggerScanPhoto() {
var input = document.createElement('input');
input.type = 'file';
input.accept = 'image/*';
input.capture = 'environment';
input.onchange = function () {
if (input.files.length === 0) return;
var reader = new FileReader();
reader.onload = function () {
doScanExtract({ image: reader.result });
};
reader.readAsDataURL(input.files[0]);
};
input.click();
}
function scanFromText() {
var input = document.getElementById('scan-extract-text');
var prompt = input.value.trim();
if (prompt.length < 3) return;
doScanExtract({ prompt: prompt });
}
async function doScanExtract(body) {
if (_extracting) return;
_extracting = true;
var errorEl = document.getElementById('scan-extract-error');
var spinnerEl = document.getElementById('scan-extract-spinner');
errorEl.classList.add('hidden');
spinnerEl.classList.remove('hidden');
try {
var resp = await fetch('/api/v1/extract-bag-scan', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify(body),
});
if (!resp.ok) {
var errData = await resp.json().catch(function () { return {}; });
throw new Error(errData.message || 'Server returned ' + resp.status);
}
var data = await resp.json();
fillScanForms(data);
document.getElementById('scan-input-section').style.display = 'none';
document.getElementById('scan-form-section').style.display = 'block';
} catch (e) {
errorEl.textContent = 'Extraction failed: ' + e.message;
errorEl.classList.remove('hidden');
} finally {
spinnerEl.classList.add('hidden');
_extracting = false;
}
}
function fillScanForms(data) {
var form = document.getElementById('scan-form');
if (!form) return;
if (data.roaster) {
if (data.roaster.name) form.querySelector('[name="roaster_name"]').value = data.roaster.name;
if (data.roaster.country) form.querySelector('[name="roaster_country"]').value = data.roaster.country;
if (data.roaster.city) form.querySelector('[name="roaster_city"]').value = data.roaster.city;
if (data.roaster.homepage) form.querySelector('[name="roaster_homepage"]').value = data.roaster.homepage;
if (data.roaster.notes) form.querySelector('[name="roaster_notes"]').value = data.roaster.notes;
}
if (data.roast) {
if (data.roast.name) form.querySelector('[name="roast_name"]').value = data.roast.name;
if (data.roast.origin) form.querySelector('[name="origin"]').value = data.roast.origin;
if (data.roast.region) form.querySelector('[name="region"]').value = data.roast.region;
if (data.roast.producer) form.querySelector('[name="producer"]').value = data.roast.producer;
if (data.roast.process) form.querySelector('[name="process"]').value = data.roast.process;
if (data.roast.tasting_notes && data.roast.tasting_notes.length > 0) {
form.querySelector('[name="tasting_notes"]').value = data.roast.tasting_notes.join(', ');
}
}
}
function resetScan() {
document.getElementById('scan-input-section').style.display = 'block';
document.getElementById('scan-form-section').style.display = 'none';
document.getElementById('scan-form').reset();
document.getElementById('scan-submit-error').classList.add('hidden');
}
async function submitScan(event) {
event.preventDefault();
if (_submitting) return;
_submitting = true;
var errorEl = document.getElementById('scan-submit-error');
errorEl.classList.add('hidden');
var form = document.getElementById('scan-form');
var formData = new FormData(form);
var body = {};
formData.forEach(function (value, key) { body[key] = value; });
try {
var resp = await fetch('/api/v1/scan', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify(body),
});
if (!resp.ok) {
var errData = await resp.json().catch(function () { return {}; });
throw new Error(errData.message || 'Server returned ' + resp.status);
}
var data = await resp.json();
window.location.href = data.redirect || '/roasts';
} catch (e) {
errorEl.textContent = 'Save failed: ' + e.message;
errorEl.classList.remove('hidden');
} finally {
_submitting = false;
}
return false;
}
</script>
{% endblock %}
{% block content %}
<section>
<header class="flex flex-col gap-2">
<h1 class="text-3xl font-semibold">Scan Bag</h1>
<p class="max-w-2xl text-sm text-stone-600">
Take a photo of a coffee bag or describe it, and Brewlog will extract the roaster and roast details for you.
</p>
</header>
<!-- Input section: photo or text -->
<div id="scan-input-section" class="mt-6 rounded-lg border border-amber-300 bg-amber-100/80 p-5 shadow-sm">
<div class="flex flex-wrap items-center gap-3">
<button
type="button"
onclick="triggerScanPhoto()"
class="inline-flex items-center gap-2 rounded-md border border-amber-400 bg-amber-50 px-4 py-3 text-sm font-medium text-amber-800 transition hover:bg-amber-100"
>
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M1 8a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 018.07 3h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0016.07 6H17a2 2 0 012 2v7a2 2 0 01-2 2H3a2 2 0 01-2-2V8zm13.5 3a4.5 4.5 0 11-9 0 4.5 4.5 0 019 0zM10 14a3 3 0 100-6 3 3 0 000 6z" clip-rule="evenodd" />
</svg>
Take Photo
</button>
<span class="text-xs text-stone-400">or</span>
<div class="flex flex-1 min-w-[200px] gap-2">
<input
type="text"
id="scan-extract-text"
class="input-field w-full text-sm"
placeholder="Describe the coffee bag&hellip;"
onkeydown="if(event.key==='Enter'){event.preventDefault();scanFromText()}"
/>
<button
type="button"
onclick="scanFromText()"
class="rounded-md border border-amber-400 bg-amber-50 px-3 py-2 text-sm font-medium text-amber-800 transition hover:bg-amber-100"
>
Go
</button>
</div>
<svg id="scan-extract-spinner" class="hidden h-5 w-5 animate-spin text-amber-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" aria-hidden="true">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
</div>
<p id="scan-extract-error" class="hidden mt-2 text-sm text-red-600"></p>
</div>
<!-- Form section: pre-filled roaster + roast forms -->
<div id="scan-form-section" style="display: none">
<form
id="scan-form"
class="mt-6 flex flex-col gap-6"
onsubmit="return submitScan(event)"
>
<!-- Roaster section -->
<div class="rounded-lg border border-amber-300 bg-amber-100/80 p-5 shadow-sm">
<div>
<h2 class="text-lg font-semibold text-amber-700">Roaster</h2>
<p class="mt-1 text-sm text-stone-600">If this roaster already exists, it will be matched automatically.</p>
</div>
<div class="mt-4 grid gap-4 sm:grid-cols-2">
<label class="flex flex-col gap-1 text-sm">
<span class="text-stone-700">Name *</span>
<input type="text" name="roaster_name" required class="input-field" placeholder="Example Coffee Roasters" />
</label>
<label class="flex flex-col gap-1 text-sm">
<span class="text-stone-700">Country *</span>
<input type="text" name="roaster_country" required class="input-field" placeholder="United States" />
</label>
<label class="flex flex-col gap-1 text-sm">
<span class="text-stone-700">City</span>
<input type="text" name="roaster_city" class="input-field" placeholder="Portland" />
</label>
<label class="flex flex-col gap-1 text-sm">
<span class="text-stone-700">Homepage</span>
<input type="url" name="roaster_homepage" class="input-field" placeholder="https://example.coffee" />
</label>
<label class="sm:col-span-2 flex flex-col gap-1 text-sm">
<span class="text-stone-700">Notes</span>
<textarea name="roaster_notes" rows="2" class="input-field" placeholder="Short description of the roaster"></textarea>
</label>
</div>
</div>
<!-- Roast section -->
<div class="rounded-lg border border-amber-300 bg-amber-100/80 p-5 shadow-sm">
<div>
<h2 class="text-lg font-semibold text-amber-700">Roast</h2>
<p class="mt-1 text-sm text-stone-600">Details about this specific coffee.</p>
</div>
<div class="mt-4 grid gap-4 sm:grid-cols-2">
<label class="flex flex-col gap-1 text-sm">
<span class="text-stone-700">Roast Name *</span>
<input type="text" name="roast_name" required class="input-field" placeholder="Ethiopia Yirgacheffe" />
</label>
<label class="flex flex-col gap-1 text-sm">
<span class="text-stone-700">Origin *</span>
<input type="text" name="origin" required class="input-field" placeholder="Ethiopia" />
</label>
<label class="flex flex-col gap-1 text-sm">
<span class="text-stone-700">Region *</span>
<input type="text" name="region" required class="input-field" placeholder="Guji" />
</label>
<label class="flex flex-col gap-1 text-sm">
<span class="text-stone-700">Producer *</span>
<input type="text" name="producer" required class="input-field" placeholder="Chelbesa Cooperative" />
</label>
<label class="flex flex-col gap-1 text-sm">
<span class="text-stone-700">Process *</span>
<input type="text" name="process" required class="input-field" placeholder="Washed" />
</label>
<label class="flex flex-col gap-1 text-sm">
<span class="text-stone-700">Tasting Notes * (comma separated)</span>
<textarea name="tasting_notes" rows="2" required class="input-field" placeholder="Blueberry, Jasmine"></textarea>
</label>
</div>
</div>
<p id="scan-submit-error" class="hidden text-sm text-red-600"></p>
<div class="flex items-center justify-end gap-2">
<button
type="button"
onclick="resetScan()"
class="rounded-md border border-amber-300 px-4 py-2 text-sm font-semibold text-stone-700 transition hover:border-amber-400 hover:text-stone-800"
>
Start Over
</button>
<button
type="submit"
class="rounded-md bg-amber-600 px-4 py-2 text-sm font-semibold text-amber-50 transition hover:bg-amber-500"
>
Save Roaster &amp; Roast
</button>
</div>
</form>
</div>
</section>
{% endblock %}