From 84ffe36afc58d787eac749c030579edb5dab561e Mon Sep 17 00:00:00 2001 From: Jon Seager Date: Tue, 3 Feb 2026 19:15:39 +0000 Subject: [PATCH] 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. --- src/application/routes/mod.rs | 4 + src/application/routes/roasts.rs | 4 +- src/application/routes/scan.rs | 137 ++++++++++++++++ src/infrastructure/ai.rs | 42 +++++ templates/nav.html | 15 ++ templates/scan.html | 266 +++++++++++++++++++++++++++++++ 6 files changed, 466 insertions(+), 2 deletions(-) create mode 100644 src/application/routes/scan.rs create mode 100644 templates/scan.html diff --git a/src/application/routes/mod.rs b/src/application/routes/mod.rs index ea4584e..6d5833f 100644 --- a/src/application/routes/mod.rs +++ b/src/application/routes/mod.rs @@ -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)) diff --git a/src/application/routes/roasts.rs b/src/application/routes/roasts.rs index 250a5da..8880040 100644 --- a/src/application/routes/roasts.rs +++ b/src/application/routes/roasts.rs @@ -319,13 +319,13 @@ impl NewRoastSubmission { #[derive(Debug, Deserialize)] #[serde(untagged)] -enum TastingNotesInput { +pub(crate) enum TastingNotesInput { List(Vec), Text(String), } impl TastingNotesInput { - fn into_vec(self) -> Vec { + pub(crate) fn into_vec(self) -> Vec { match self { TastingNotesInput::List(values) => values .into_iter() diff --git a/src/application/routes/scan.rs b/src/application/routes/scan.rs new file mode 100644 index 0000000..1920ae4 --- /dev/null +++ b/src/application/routes/scan.rs @@ -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, + cookies: tower_cookies::Cookies, +) -> Result { + 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, + _auth_user: AuthenticatedUser, + Json(input): Json, +) -> Result, 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, + roaster_homepage: Option, + roaster_notes: Option, + 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, + _auth_user: AuthenticatedUser, + Json(submission): Json, +) -> Result { + // 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 { + 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()) +} diff --git a/src/infrastructure/ai.rs b/src/infrastructure/ai.rs index 2112f02..e856e1a 100644 --- a/src/infrastructure/ai.rs +++ b/src/infrastructure/ai.rs @@ -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>, } +#[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 { + 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( diff --git a/templates/nav.html b/templates/nav.html index a2cf4f2..4ec5b5b 100644 --- a/templates/nav.html +++ b/templates/nav.html @@ -11,6 +11,13 @@ Cups Gear Timeline + {% if is_authenticated && has_ai_extract %} + + + + {% endif %} {% if is_authenticated %}
@@ -39,6 +46,14 @@ Cups Gear Timeline + {% if is_authenticated && has_ai_extract %} + + + Scan + + {% endif %} {% if is_authenticated %} diff --git a/templates/scan.html b/templates/scan.html new file mode 100644 index 0000000..8658d7f --- /dev/null +++ b/templates/scan.html @@ -0,0 +1,266 @@ +{% extends "base.html" %} {% block title %}Brewlog ยท Scan Bag{% endblock %} + +{% block head %} + +{% endblock %} + +{% block content %} +
+
+

Scan Bag

+

+ Take a photo of a coffee bag or describe it, and Brewlog will extract the roaster and roast details for you. +

+
+ + +
+
+ + or +
+ + +
+ +
+ +
+ + + +
+{% endblock %}