diff --git a/Cargo.lock b/Cargo.lock index 6bca330..001a853 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "ahash" version = "0.8.12" @@ -161,6 +167,18 @@ dependencies = [ "serde_json", ] +[[package]] +name = "async-compression" +version = "0.4.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d10e4f991a553474232bc0a31799f6d24b034a84c0971d80d2e2f78b2e576e40" +dependencies = [ + "compression-codecs", + "compression-core", + "pin-project-lite", + "tokio", +] + [[package]] name = "async-trait" version = "0.1.89" @@ -326,6 +344,7 @@ dependencies = [ "base64 0.22.1", "chrono", "clap", + "dotenvy", "once_cell", "portpicker", "rand 0.8.5", @@ -448,6 +467,23 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +[[package]] +name = "compression-codecs" +version = "0.4.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00828ba6fd27b45a448e57dbfe84f1029d4c9f26b368157e9a448a5f49a2ec2a" +dependencies = [ + "compression-core", + "flate2", + "memchr", +] + +[[package]] +name = "compression-core" +version = "0.4.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75984efb6ed102a0d42db99afb6c1948f0380d1d91808d5529916e6c08b49d8d" + [[package]] name = "const-oid" version = "0.9.6" @@ -513,6 +549,15 @@ version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "crossbeam-queue" version = "0.3.12" @@ -674,6 +719,16 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844" +[[package]] +name = "flate2" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b375d6465b98090a5f25b1c7703f3859783755aa9a80433b36e0379a3ec2f369" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "flume" version = "0.11.1" @@ -1373,6 +1428,16 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.1.0" @@ -1802,6 +1867,7 @@ version = "0.12.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d0946410b9f7b082a427e4ef5c8ff541a88b357bc6c637c40db3a68ac70a36f" dependencies = [ + "async-compression", "base64 0.22.1", "bytes", "cookie", @@ -1828,6 +1894,7 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-rustls", + "tokio-util", "tower 0.5.2", "tower-http", "tower-service", @@ -2127,6 +2194,12 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "simd-adler32" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" + [[package]] name = "slab" version = "0.4.11" diff --git a/Cargo.toml b/Cargo.toml index 6838de5..eb8f291 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,7 +17,8 @@ askama = "0.12" base64 = "0.22" chrono = { version = "0.4", features = ["serde", "clock"] } clap = { version = "4.5", features = ["derive", "env"] } -reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +dotenvy = "0.15" +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "gzip"] } rpassword = "7.3" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" diff --git a/src/application/routes/mod.rs b/src/application/routes/mod.rs index bec1482..ea4584e 100644 --- a/src/application/routes/mod.rs +++ b/src/application/routes/mod.rs @@ -75,6 +75,8 @@ pub fn app_router(state: AppState) -> axum::Router { .delete(cafes::delete_cafe), ) .route("/nearby-cafes", get(cafes::nearby_cafes)) + .route("/extract-roaster", post(roasters::extract_roaster)) + .route("/extract-roast", post(roasts::extract_roast_info)) .route("/cups", get(cups::list_cups).post(cups::create_cup)) .route( "/cups/:id", diff --git a/src/application/routes/roasters.rs b/src/application/routes/roasters.rs index 58c52c7..071e3de 100644 --- a/src/application/routes/roasters.rs +++ b/src/application/routes/roasters.rs @@ -14,6 +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::presentation::web::templates::{ RoasterDetailTemplate, RoasterListTemplate, RoastersTemplate, }; @@ -69,6 +70,7 @@ pub(crate) async fn roasters_page( let template = RoastersTemplate { nav_active: "roasters", is_authenticated, + has_ai_extract: state.has_ai_extract(), roasters, navigator, }; @@ -183,6 +185,24 @@ define_delete_handler!( render_roaster_list_fragment ); +#[tracing::instrument(skip(state, _auth_user))] +pub(crate) async fn extract_roaster( + 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_roaster(&state.http_client, api_key, &state.openrouter_model, &input) + .await + .map_err(ApiError::from)?; + + Ok(Json(result)) +} + define_list_fragment_renderer!( render_roaster_list_fragment, RoasterSortKey, diff --git a/src/application/routes/roasts.rs b/src/application/routes/roasts.rs index 7538812..2c49c31 100644 --- a/src/application/routes/roasts.rs +++ b/src/application/routes/roasts.rs @@ -18,6 +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::presentation::web::templates::{ RoastDetailTemplate, RoastListTemplate, RoastOptionsTemplate, RoastsTemplate, }; @@ -75,6 +76,7 @@ pub(crate) async fn roasts_page( let template = RoastsTemplate { nav_active: "roasts", is_authenticated, + has_ai_extract: state.has_ai_extract(), roasts, roaster_options, navigator, @@ -338,6 +340,24 @@ impl TastingNotesInput { } } +#[tracing::instrument(skip(state, _auth_user))] +pub(crate) async fn extract_roast_info( + 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_roast(&state.http_client, api_key, &state.openrouter_model, &input) + .await + .map_err(ApiError::from)?; + + Ok(Json(result)) +} + define_list_fragment_renderer!( render_roast_list_fragment, RoastSortKey, diff --git a/src/application/server.rs b/src/application/server.rs index 7f96ef1..8b1746f 100644 --- a/src/application/server.rs +++ b/src/application/server.rs @@ -32,6 +32,8 @@ pub struct ServerConfig { pub database_url: String, pub admin_password: Option, pub admin_username: Option, + pub openrouter_api_key: Option, + pub openrouter_model: String, } #[derive(Clone)] @@ -49,6 +51,8 @@ pub struct AppState { pub session_repo: Arc, pub http_client: reqwest::Client, pub nominatim_url: String, + pub openrouter_api_key: Option, + pub openrouter_model: String, } impl AppState { @@ -67,6 +71,8 @@ impl AppState { session_repo: Arc, http_client: reqwest::Client, nominatim_url: String, + openrouter_api_key: Option, + openrouter_model: String, ) -> Self { Self { roaster_repo, @@ -82,8 +88,14 @@ impl AppState { session_repo, http_client, nominatim_url, + openrouter_api_key, + openrouter_model, } } + + pub fn has_ai_extract(&self) -> bool { + self.openrouter_api_key.is_some() + } } pub async fn serve(config: ServerConfig) -> anyhow::Result<()> { @@ -124,6 +136,8 @@ pub async fn serve(config: ServerConfig) -> anyhow::Result<()> { session_repo, reqwest::Client::new(), crate::infrastructure::osm::NOMINATIM_SEARCH_URL.to_string(), + config.openrouter_api_key, + config.openrouter_model, ); let listener = TcpListener::bind(config.bind_address) diff --git a/src/infrastructure/ai.rs b/src/infrastructure/ai.rs new file mode 100644 index 0000000..2112f02 --- /dev/null +++ b/src/infrastructure/ai.rs @@ -0,0 +1,357 @@ +use std::time::Duration; + +use serde::{Deserialize, Serialize}; + +use crate::application::errors::AppError; + +const OPENROUTER_URL: &str = "https://openrouter.ai/api/v1/chat/completions"; +const USER_AGENT: &str = "Brewlog/1.0"; +const REQUEST_TIMEOUT: Duration = Duration::from_secs(90); + +const ROASTER_PROMPT: &str = r#"Extract coffee roaster 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, or background). Return a JSON object with these fields (only include fields you can identify with confidence): +- "name": the roaster's name +- "country": the country the roaster is based in +- "city": the city the roaster is based in +- "homepage": the roaster's website URL +- "notes": a single sentence describing the roaster (e.g. sourcing philosophy, speciality, or founding story) + +Return ONLY the JSON object, no other text."#; + +const ROAST_PROMPT: &str = r#"Extract coffee roast information from this input. Use web search to look up any details you cannot determine from the input alone (e.g. origin, region, producer, processing method, tasting notes). Return a JSON object with these fields (only include fields you can identify with confidence): +- "roaster_name": the name of the roaster +- "name": the name of this specific coffee/roast +- "origin": the country of origin of the coffee beans +- "region": the region within the origin country +- "producer": the farm, estate, or cooperative that produced the beans +- "process": the processing method (e.g. Washed, Natural, Honey, Anaerobic) +- "tasting_notes": an array of flavour/tasting notes (e.g. ["Blueberry", "Jasmine", "Dark Chocolate"]) + +Return ONLY the JSON object, no other text."#; + +// --- Public types --- + +#[derive(Debug, Deserialize)] +pub struct ExtractionInput { + pub image: Option, + pub prompt: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ExtractedRoaster { + pub name: Option, + pub country: Option, + pub city: Option, + pub homepage: Option, + pub notes: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ExtractedRoast { + pub roaster_name: Option, + pub name: Option, + pub origin: Option, + pub region: Option, + pub producer: Option, + pub process: Option, + pub tasting_notes: Option>, +} + +// --- Public functions --- + +pub async fn extract_roaster( + client: &reqwest::Client, + api_key: &str, + model: &str, + input: &ExtractionInput, +) -> Result { + let content = call_openrouter(client, api_key, model, ROASTER_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 roaster data: {e}")) + }) +} + +pub async fn extract_roast( + client: &reqwest::Client, + api_key: &str, + model: &str, + input: &ExtractionInput, +) -> Result { + let content = call_openrouter(client, api_key, model, ROAST_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 roast data: {e}")) + }) +} + +// --- Internal helpers --- + +async fn call_openrouter( + client: &reqwest::Client, + api_key: &str, + model: &str, + system_prompt: &str, + input: &ExtractionInput, +) -> Result { + let has_image = input.image.as_ref().is_some_and(|s| !s.trim().is_empty()); + let has_prompt = input.prompt.as_ref().is_some_and(|s| !s.trim().is_empty()); + + if !has_image && !has_prompt { + return Err(AppError::validation( + "Provide either an image or a text prompt", + )); + } + + let mut content_parts = vec![ContentPart::Text { + text: system_prompt.to_string(), + }]; + + if let Some(image) = &input.image + && !image.trim().is_empty() + { + content_parts.push(ContentPart::ImageUrl { + image_url: ImageUrlDetail { url: image.clone() }, + }); + } + + if let Some(prompt) = &input.prompt + && !prompt.trim().is_empty() + { + content_parts.push(ContentPart::Text { + text: prompt.clone(), + }); + } + + let request_body = ChatRequest { + model: model.to_string(), + messages: vec![Message { + role: "user".to_string(), + content: content_parts, + }], + }; + + let response = client + .post(OPENROUTER_URL) + .header("User-Agent", USER_AGENT) + .header("Authorization", format!("Bearer {api_key}")) + .timeout(REQUEST_TIMEOUT) + .json(&request_body) + .send() + .await + .map_err(|e| AppError::unexpected(format!("OpenRouter request failed: {e}")))?; + + if !response.status().is_success() { + let status = response.status(); + let body = response + .text() + .await + .unwrap_or_else(|_| "(unreadable body)".to_string()); + return Err(AppError::unexpected(format!( + "OpenRouter returned status {status}: {body}" + ))); + } + + let body = response.text().await.map_err(|e| { + AppError::unexpected(format!("Failed to read OpenRouter response body: {e}")) + })?; + + let chat_response: ChatResponse = serde_json::from_str(&body) + .map_err(|e| AppError::unexpected(format!("Failed to parse OpenRouter response: {e}")))?; + + let content = chat_response + .choices + .into_iter() + .next() + .map(|c| c.message.content) + .unwrap_or_default(); + + if content.trim().is_empty() { + return Err(AppError::unexpected( + "OpenRouter returned an empty response".to_string(), + )); + } + + Ok(content) +} + +/// Extract a JSON object from a model response that may contain markdown +/// fences (```json ... ```) or surrounding prose. +fn extract_json(raw: &str) -> &str { + let trimmed = raw.trim(); + + // Strip ```json ... ``` or ``` ... ``` fences + if let Some(after) = trimmed.strip_prefix("```json") + && let Some(inner) = after.strip_suffix("```") + { + return inner.trim(); + } + if let Some(after) = trimmed.strip_prefix("```") + && let Some(inner) = after.strip_suffix("```") + { + return inner.trim(); + } + + // Find the first '{' and last '}' to extract the JSON object + if let (Some(start), Some(end)) = (trimmed.find('{'), trimmed.rfind('}')) + && start < end + { + return &trimmed[start..=end]; + } + + trimmed +} + +// --- OpenRouter API types --- + +#[derive(Debug, Serialize)] +struct ChatRequest { + model: String, + messages: Vec, +} + +#[derive(Debug, Serialize)] +struct Message { + role: String, + content: Vec, +} + +#[derive(Debug, Serialize)] +#[serde(tag = "type")] +enum ContentPart { + #[serde(rename = "text")] + Text { text: String }, + #[serde(rename = "image_url")] + ImageUrl { image_url: ImageUrlDetail }, +} + +#[derive(Debug, Serialize)] +struct ImageUrlDetail { + url: String, +} + +#[derive(Debug, Deserialize)] +struct ChatResponse { + choices: Vec, +} + +#[derive(Debug, Deserialize)] +struct Choice { + message: ResponseMessage, +} + +#[derive(Debug, Deserialize)] +struct ResponseMessage { + content: String, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_chat_response() { + let json = r#"{ + "id": "gen-abc123", + "model": "openrouter/free", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "{\"name\": \"Square Mile\", \"country\": \"United Kingdom\", \"city\": \"London\"}" + }, + "finish_reason": "stop" + } + ] + }"#; + + let response: ChatResponse = serde_json::from_str(json).unwrap(); + assert_eq!(response.choices.len(), 1); + + let content = &response.choices[0].message.content; + let roaster: ExtractedRoaster = serde_json::from_str(content).unwrap(); + assert_eq!(roaster.name.as_deref(), Some("Square Mile")); + assert_eq!(roaster.country.as_deref(), Some("United Kingdom")); + assert_eq!(roaster.city.as_deref(), Some("London")); + assert!(roaster.homepage.is_none()); + } + + #[test] + fn parse_roast_extraction() { + let json = r#"{ + "roaster_name": "Square Mile", + "name": "Red Brick", + "origin": "Brazil", + "region": "Cerrado Mineiro", + "producer": "Fazenda Pinhal", + "process": "Natural", + "tasting_notes": ["Chocolate", "Hazelnut", "Caramel"] + }"#; + + let roast: ExtractedRoast = serde_json::from_str(json).unwrap(); + assert_eq!(roast.roaster_name.as_deref(), Some("Square Mile")); + assert_eq!(roast.name.as_deref(), Some("Red Brick")); + assert_eq!(roast.origin.as_deref(), Some("Brazil")); + assert_eq!( + roast.tasting_notes.as_deref(), + Some(&["Chocolate", "Hazelnut", "Caramel"].map(String::from)[..]) + ); + } + + #[test] + fn parse_partial_roast_extraction() { + let json = r#"{"name": "Ethiopia Yirgacheffe", "origin": "Ethiopia"}"#; + + let roast: ExtractedRoast = serde_json::from_str(json).unwrap(); + assert_eq!(roast.name.as_deref(), Some("Ethiopia Yirgacheffe")); + assert_eq!(roast.origin.as_deref(), Some("Ethiopia")); + assert!(roast.roaster_name.is_none()); + assert!(roast.region.is_none()); + assert!(roast.tasting_notes.is_none()); + } + + #[test] + fn serialize_chat_request_with_image() { + let request = ChatRequest { + model: "test-model".to_string(), + messages: vec![Message { + role: "user".to_string(), + content: vec![ + ContentPart::Text { + text: "Extract info".to_string(), + }, + ContentPart::ImageUrl { + image_url: ImageUrlDetail { + url: "data:image/jpeg;base64,/9j/4AAQ".to_string(), + }, + }, + ], + }], + }; + + let json = serde_json::to_value(&request).unwrap(); + assert_eq!(json["model"], "test-model"); + assert_eq!(json["messages"][0]["content"][0]["type"], "text"); + assert_eq!(json["messages"][0]["content"][1]["type"], "image_url"); + } + + #[test] + fn extract_json_from_plain_json() { + let raw = r#"{"name": "Square Mile"}"#; + assert_eq!(extract_json(raw), raw); + } + + #[test] + fn extract_json_from_markdown_fence() { + let raw = "```json\n{\"name\": \"Square Mile\"}\n```"; + assert_eq!(extract_json(raw), r#"{"name": "Square Mile"}"#); + } + + #[test] + fn extract_json_from_prose() { + let raw = "Here is the data:\n{\"name\": \"Square Mile\"}\nHope that helps!"; + assert_eq!(extract_json(raw), r#"{"name": "Square Mile"}"#); + } +} diff --git a/src/infrastructure/mod.rs b/src/infrastructure/mod.rs index 3e8d185..9acbc47 100644 --- a/src/infrastructure/mod.rs +++ b/src/infrastructure/mod.rs @@ -1,3 +1,4 @@ +pub mod ai; pub mod auth; pub mod backup; pub mod client; diff --git a/src/presentation/cli/mod.rs b/src/presentation/cli/mod.rs index f58ba70..1e2a514 100644 --- a/src/presentation/cli/mod.rs +++ b/src/presentation/cli/mod.rs @@ -114,6 +114,16 @@ pub struct ServeCommand { #[arg(long, env = "BREWLOG_ADMIN_USERNAME")] pub admin_username: Option, + + #[arg(long, env = "BREWLOG_OPENROUTER_API_KEY")] + pub openrouter_api_key: Option, + + #[arg( + long, + env = "BREWLOG_OPENROUTER_MODEL", + default_value = "openrouter/free" + )] + pub openrouter_model: String, } pub(crate) fn print_json(value: &T) -> anyhow::Result<()> diff --git a/src/presentation/web/templates.rs b/src/presentation/web/templates.rs index 2346490..f011b8d 100644 --- a/src/presentation/web/templates.rs +++ b/src/presentation/web/templates.rs @@ -19,6 +19,7 @@ use crate::domain::timeline::TimelineSortKey; pub struct RoastersTemplate { pub nav_active: &'static str, pub is_authenticated: bool, + pub has_ai_extract: bool, pub roasters: Paginated, pub navigator: ListNavigator, } @@ -45,6 +46,7 @@ pub struct RoasterDetailTemplate { pub struct RoastsTemplate { pub nav_active: &'static str, pub is_authenticated: bool, + pub has_ai_extract: bool, pub roasts: Paginated, pub roaster_options: Vec, pub navigator: ListNavigator, diff --git a/templates/roasters.html b/templates/roasters.html index 329b561..0d935b0 100644 --- a/templates/roasters.html +++ b/templates/roasters.html @@ -1,4 +1,77 @@ -{% extends "base.html" %} {% block title %}Brewlog · Roasters{% endblock %} {% block content %} +{% extends "base.html" %} {% block title %}Brewlog · Roasters{% endblock %} + +{% block head %} +{% if is_authenticated && has_ai_extract %} + +{% endif %} +{% endblock %} + +{% block content %}
@@ -31,6 +104,7 @@

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