From bc30555447c162a7fbd37bc8e360e6ebb0c204bd Mon Sep 17 00:00:00 2001 From: Jon Seager Date: Wed, 4 Feb 2026 14:36:42 +0000 Subject: [PATCH] refactor(extraction): Datastar-native AI extraction across all pages Extraction endpoints now accept FlexiblePayload (form or JSON) and return application/json signal patches for Datastar requests. Templates use data-bind for two-way signal binding and data-on:datastar-fetch with per-form guards to prevent event bubbling between forms. - extract_roaster, extract_roast_info, extract_bag_scan return signals - submit_scan extended with optional image/prompt for combined extract+save - match_roaster_id fuzzy-matches extracted roaster name to existing IDs - Templates use data-bind:_signal-name (not data-model) for form binding - Each datastar-fetch handler guards with its own in-progress signal --- src/application/routes/roasters.rs | 36 ++++- src/application/routes/roasts.rs | 80 ++++++++++- src/application/routes/scan.rs | 212 +++++++++++++++++++++++---- templates/home.html | 222 ++++++++++++----------------- templates/roasters.html | 118 ++++++++------- templates/roasts.html | 140 ++++++++---------- 6 files changed, 501 insertions(+), 307 deletions(-) diff --git a/src/application/routes/roasters.rs b/src/application/routes/roasters.rs index 84ce4fe..9218254 100644 --- a/src/application/routes/roasters.rs +++ b/src/application/routes/roasters.rs @@ -14,7 +14,7 @@ use crate::application::server::AppState; use crate::domain::ids::RoasterId; use crate::domain::listing::{ListRequest, SortDirection}; use crate::domain::roasters::{NewRoaster, Roaster, RoasterSortKey, UpdateRoaster}; -use crate::infrastructure::ai::{self, ExtractedRoaster, ExtractionInput}; +use crate::infrastructure::ai::{self, ExtractionInput}; use crate::presentation::web::templates::{ RoasterDetailTemplate, RoasterListTemplate, RoastersTemplate, }; @@ -185,22 +185,48 @@ define_delete_handler!( render_roaster_list_fragment ); -#[tracing::instrument(skip(state, _auth_user))] +#[tracing::instrument(skip(state, _auth_user, headers, payload))] pub(crate) async fn extract_roaster( State(state): State, _auth_user: AuthenticatedUser, - Json(input): Json, -) -> Result, ApiError> { + headers: HeaderMap, + payload: FlexiblePayload, +) -> Result { let api_key = state .openrouter_api_key .as_deref() .ok_or_else(|| AppError::validation("AI extraction is not configured"))?; + let (input, _) = payload.into_parts(); let result = ai::extract_roaster(&state.http_client, api_key, &state.openrouter_model, &input) .await .map_err(ApiError::from)?; - Ok(Json(result)) + if is_datastar_request(&headers) { + use serde_json::Value; + let signals = vec![ + ( + "_roaster-name", + Value::String(result.name.unwrap_or_default()), + ), + ( + "_roaster-country", + Value::String(result.country.unwrap_or_default()), + ), + ( + "_roaster-city", + Value::String(result.city.unwrap_or_default()), + ), + ( + "_roaster-homepage", + Value::String(result.homepage.unwrap_or_default()), + ), + ("_extracted", Value::Bool(true)), + ]; + crate::application::routes::support::render_signals_json(&signals).map_err(ApiError::from) + } else { + Ok(Json(result).into_response()) + } } define_list_fragment_renderer!( diff --git a/src/application/routes/roasts.rs b/src/application/routes/roasts.rs index 8880040..9648894 100644 --- a/src/application/routes/roasts.rs +++ b/src/application/routes/roasts.rs @@ -18,7 +18,7 @@ use crate::domain::bags::{BagFilter, BagSortKey}; use crate::domain::ids::{RoastId, RoasterId}; use crate::domain::listing::{ListRequest, SortDirection}; use crate::domain::roasts::{NewRoast, RoastSortKey, RoastWithRoaster, UpdateRoast}; -use crate::infrastructure::ai::{self, ExtractedRoast, ExtractionInput}; +use crate::infrastructure::ai::{self, ExtractionInput}; use crate::presentation::web::templates::{ RoastDetailTemplate, RoastListTemplate, RoastOptionsTemplate, RoastsTemplate, }; @@ -341,22 +341,92 @@ impl TastingNotesInput { } } -#[tracing::instrument(skip(state, _auth_user))] +#[tracing::instrument(skip(state, _auth_user, headers, payload))] pub(crate) async fn extract_roast_info( State(state): State, _auth_user: AuthenticatedUser, - Json(input): Json, -) -> Result, ApiError> { + headers: HeaderMap, + payload: FlexiblePayload, +) -> Result { let api_key = state .openrouter_api_key .as_deref() .ok_or_else(|| AppError::validation("AI extraction is not configured"))?; + let (input, _) = payload.into_parts(); let result = ai::extract_roast(&state.http_client, api_key, &state.openrouter_model, &input) .await .map_err(ApiError::from)?; - Ok(Json(result)) + if is_datastar_request(&headers) { + use serde_json::Value; + + // Try to match extracted roaster name to an existing roaster ID + let roaster_id = if let Some(ref roaster_name) = result.roaster_name { + match_roaster_id(&state, roaster_name) + .await + .unwrap_or_default() + } else { + String::new() + }; + + let tasting_notes = result + .tasting_notes + .as_ref() + .map(|notes| notes.join(", ")) + .unwrap_or_default(); + + let signals = vec![ + ( + "_roast-name", + Value::String(result.name.unwrap_or_default()), + ), + ("_origin", Value::String(result.origin.unwrap_or_default())), + ("_region", Value::String(result.region.unwrap_or_default())), + ( + "_producer", + Value::String(result.producer.unwrap_or_default()), + ), + ( + "_process", + Value::String(result.process.unwrap_or_default()), + ), + ("_tasting-notes", Value::String(tasting_notes)), + ("_roaster-id", Value::String(roaster_id)), + ("_extracted", Value::Bool(true)), + ]; + crate::application::routes::support::render_signals_json(&signals).map_err(ApiError::from) + } else { + Ok(Json(result).into_response()) + } +} + +/// Fuzzy-match a roaster name against existing roasters and return the matched ID. +async fn match_roaster_id(state: &AppState, roaster_name: &str) -> Option { + let roasters = state + .roaster_repo + .list_all_sorted( + crate::domain::roasters::RoasterSortKey::Name, + SortDirection::Asc, + ) + .await + .ok()?; + + let lower = roaster_name.to_lowercase(); + + // Exact match first + if let Some(roaster) = roasters.iter().find(|r| r.name.to_lowercase() == lower) { + return Some(roaster.id.to_string()); + } + + // Substring match (either direction) + roasters + .iter() + .find(|r| { + let r_lower = r.name.to_lowercase(); + r_lower.contains(&lower) || lower.contains(&r_lower) + }) + .map(|r| r.id.to_string()) } define_list_fragment_renderer!( diff --git a/src/application/routes/scan.rs b/src/application/routes/scan.rs index fb5fa49..7e3d28d 100644 --- a/src/application/routes/scan.rs +++ b/src/application/routes/scan.rs @@ -1,47 +1,119 @@ use axum::Json; use axum::extract::State; -use axum::http::StatusCode; +use axum::http::{HeaderMap, StatusCode}; use axum::response::{IntoResponse, Response}; use serde::{Deserialize, Serialize}; use crate::application::auth::AuthenticatedUser; use crate::application::errors::{ApiError, AppError}; use crate::application::routes::roasts::TastingNotesInput; +use crate::application::routes::support::{FlexiblePayload, is_datastar_request}; 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::infrastructure::ai::{self, ExtractionInput}; -#[tracing::instrument(skip(state, _auth_user))] +#[tracing::instrument(skip(state, _auth_user, headers, payload))] pub(crate) async fn extract_bag_scan( State(state): State, _auth_user: AuthenticatedUser, - Json(input): Json, -) -> Result, ApiError> { + headers: HeaderMap, + payload: FlexiblePayload, +) -> Result { let api_key = state .openrouter_api_key .as_deref() .ok_or_else(|| AppError::validation("AI extraction is not configured"))?; + let (input, _) = payload.into_parts(); let result = ai::extract_bag_scan(&state.http_client, api_key, &state.openrouter_model, &input) .await .map_err(ApiError::from)?; - Ok(Json(result)) + if is_datastar_request(&headers) { + use serde_json::Value; + + let tasting_notes = result + .roast + .tasting_notes + .as_ref() + .map(|notes| notes.join(", ")) + .unwrap_or_default(); + + let signals = vec![ + ( + "_roaster-name", + Value::String(result.roaster.name.unwrap_or_default()), + ), + ( + "_roaster-country", + Value::String(result.roaster.country.unwrap_or_default()), + ), + ( + "_roaster-city", + Value::String(result.roaster.city.unwrap_or_default()), + ), + ( + "_roaster-homepage", + Value::String(result.roaster.homepage.unwrap_or_default()), + ), + ( + "_roast-name", + Value::String(result.roast.name.unwrap_or_default()), + ), + ( + "_origin", + Value::String(result.roast.origin.unwrap_or_default()), + ), + ( + "_region", + Value::String(result.roast.region.unwrap_or_default()), + ), + ( + "_producer", + Value::String(result.roast.producer.unwrap_or_default()), + ), + ( + "_process", + Value::String(result.roast.process.unwrap_or_default()), + ), + ("_tasting-notes", Value::String(tasting_notes)), + ("_scan-extracted", Value::Bool(true)), + ]; + crate::application::routes::support::render_signals_json(&signals).map_err(ApiError::from) + } else { + Ok(Json(result).into_response()) + } +} + +fn default_tasting_notes() -> TastingNotesInput { + TastingNotesInput::Text(String::new()) } #[derive(Debug, Deserialize)] pub(crate) struct BagScanSubmission { + #[serde(default)] + image: Option, + #[serde(default)] + prompt: Option, + #[serde(default)] roaster_name: String, + #[serde(default)] roaster_country: String, roaster_city: Option, roaster_homepage: Option, + #[serde(default)] roast_name: String, + #[serde(default)] origin: String, + #[serde(default)] region: String, + #[serde(default)] producer: String, + #[serde(default)] process: String, + #[serde(default = "default_tasting_notes")] tasting_notes: TastingNotesInput, } @@ -51,12 +123,75 @@ struct ScanResult { roast_id: i64, } -#[tracing::instrument(skip(state, _auth_user))] +/// Populate a `BagScanSubmission` from AI extraction when image/prompt is provided. +async fn extract_into_submission( + state: &AppState, + submission: &mut BagScanSubmission, +) -> Result<(), ApiError> { + let api_key = state + .openrouter_api_key + .as_deref() + .ok_or_else(|| AppError::validation("AI extraction is not configured"))?; + + let input = ExtractionInput { + image: submission.image.take(), + prompt: submission.prompt.take(), + }; + let result = ai::extract_bag_scan(&state.http_client, api_key, &state.openrouter_model, &input) + .await + .map_err(ApiError::from)?; + + if let Some(name) = result.roaster.name { + submission.roaster_name = name; + } + if let Some(country) = result.roaster.country { + submission.roaster_country = country; + } + if result.roaster.city.is_some() { + submission.roaster_city = result.roaster.city; + } + if result.roaster.homepage.is_some() { + submission.roaster_homepage = result.roaster.homepage; + } + if let Some(name) = result.roast.name { + submission.roast_name = name; + } + if let Some(origin) = result.roast.origin { + submission.origin = origin; + } + if let Some(region) = result.roast.region { + submission.region = region; + } + if let Some(producer) = result.roast.producer { + submission.producer = producer; + } + if let Some(process) = result.roast.process { + submission.process = process; + } + if let Some(notes) = result.roast.tasting_notes { + submission.tasting_notes = TastingNotesInput::Text(notes.join(", ")); + } + + Ok(()) +} + +#[tracing::instrument(skip(state, _auth_user, headers, payload))] pub(crate) async fn submit_scan( State(state): State, _auth_user: AuthenticatedUser, - Json(submission): Json, + headers: HeaderMap, + payload: FlexiblePayload, ) -> Result { + let (mut submission, _) = payload.into_parts(); + + // Check for raw input (image/prompt triggers extraction first) + let has_raw_input = submission.image.as_deref().is_some_and(|s| !s.is_empty()) + || submission.prompt.as_deref().is_some_and(|s| !s.is_empty()); + + if has_raw_input { + extract_into_submission(&state, &mut submission).await?; + } + // Build and normalize the roaster let new_roaster = NewRoaster { name: submission.roaster_name, @@ -81,27 +216,44 @@ pub(crate) async fn submit_scan( // Validate and build the roast let tasting_notes = submission.tasting_notes.into_vec(); - if tasting_notes.is_empty() { + if !has_raw_input && 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 = if has_raw_input { + if submission.roast_name.trim().is_empty() { + return Err( + AppError::validation("could not extract a roast name from the image/text").into(), + ); + } + NewRoast { + roaster_id: roaster.id, + name: submission.roast_name.trim().to_string(), + origin: submission.origin.trim().to_string(), + region: submission.region.trim().to_string(), + producer: submission.producer.trim().to_string(), + process: submission.process.trim().to_string(), + tasting_notes, + } + } else { + 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, + 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 @@ -112,5 +264,15 @@ pub(crate) async fn submit_scan( let redirect = format!("/roasters/{}/roasts/{}", roaster.slug, roast.slug); let roast_id = roast.id.into_inner(); - Ok((StatusCode::CREATED, Json(ScanResult { redirect, roast_id })).into_response()) + + if is_datastar_request(&headers) { + use serde_json::Value; + let signals = vec![ + ("_roast-id", Value::String(roast_id.to_string())), + ("_scan-success", Value::String(roast.name.clone())), + ]; + crate::application::routes::support::render_signals_json(&signals).map_err(ApiError::from) + } else { + Ok((StatusCode::CREATED, Json(ScanResult { redirect, roast_id })).into_response()) + } } diff --git a/templates/home.html b/templates/home.html index 570125e..99ffdbf 100644 --- a/templates/home.html +++ b/templates/home.html @@ -1,85 +1,8 @@ {% extends "base.html" %} {% block title %}Brewlog{% endblock %} {% block head %} -{% if is_authenticated && has_ai_extract %} - +{% if is_authenticated %} - -{% endif %} -{% endblock %} - {% block content %} -
+

Roasters

@@ -48,56 +32,65 @@ Provide the core details and Brewlog will keep track of everything for you.

+ + {% if has_ai_extract %} + + + + +
+ +
+ + or +
+ + +
+
+ + +
+ {% endif %} +
- {% if has_ai_extract %} -
-
- - or -
- - -
-
- - -
- {% endif %}
diff --git a/templates/roasts.html b/templates/roasts.html index 24760f6..7b172e1 100644 --- a/templates/roasts.html +++ b/templates/roasts.html @@ -1,39 +1,7 @@ {% extends "base.html" %} {% block title %}Brewlog · Roasts{% endblock %} -{% block head %} -{% if is_authenticated && has_ai_extract %} - - -{% endif %} -{% endblock %} - {% block content %} -
+

Roasts

@@ -78,60 +46,69 @@ Select a roaster, describe the roast, and Brewlog will take care of the rest.

+ + {% if has_ai_extract %} + + + + + + +
+ + or +
+ + +
+
+ + + + {% endif %} +
- {% if has_ai_extract %} -
-
- - or -
- - -
-
- - -
- {% endif %}