feat(ai): add AI-powered extraction for roaster and roast forms
Users can upload a photo of a coffee bag or type a text prompt to auto-fill roaster/roast form fields via OpenRouter's chat completions API. The feature is opt-in: hidden when BREWLOG_OPENROUTER_API_KEY is not set. - Add src/infrastructure/ai.rs with OpenRouter client, prompt templates, JSON extraction from model responses, and unit tests - Add extract-roaster and extract-roast API endpoints behind auth - Add extraction UI (photo button + text input) to both form templates - Add openrouter_api_key and openrouter_model to CLI args and AppState - Enable reqwest gzip feature for compressed API responses - Default model set to openrouter/free (auto-selects available models) - Prompts instruct model to use web search for missing details
This commit is contained in:
parent
a4da0598f6
commit
b668d368e7
13 changed files with 744 additions and 3 deletions
73
Cargo.lock
generated
73
Cargo.lock
generated
|
|
@ -2,6 +2,12 @@
|
||||||
# It is not intended for manual editing.
|
# It is not intended for manual editing.
|
||||||
version = 4
|
version = 4
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "adler2"
|
||||||
|
version = "2.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ahash"
|
name = "ahash"
|
||||||
version = "0.8.12"
|
version = "0.8.12"
|
||||||
|
|
@ -161,6 +167,18 @@ dependencies = [
|
||||||
"serde_json",
|
"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]]
|
[[package]]
|
||||||
name = "async-trait"
|
name = "async-trait"
|
||||||
version = "0.1.89"
|
version = "0.1.89"
|
||||||
|
|
@ -326,6 +344,7 @@ dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"chrono",
|
"chrono",
|
||||||
"clap",
|
"clap",
|
||||||
|
"dotenvy",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"portpicker",
|
"portpicker",
|
||||||
"rand 0.8.5",
|
"rand 0.8.5",
|
||||||
|
|
@ -448,6 +467,23 @@ version = "1.0.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
|
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]]
|
[[package]]
|
||||||
name = "const-oid"
|
name = "const-oid"
|
||||||
version = "0.9.6"
|
version = "0.9.6"
|
||||||
|
|
@ -513,6 +549,15 @@ version = "2.4.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5"
|
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]]
|
[[package]]
|
||||||
name = "crossbeam-queue"
|
name = "crossbeam-queue"
|
||||||
version = "0.3.12"
|
version = "0.3.12"
|
||||||
|
|
@ -674,6 +719,16 @@ version = "0.1.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844"
|
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]]
|
[[package]]
|
||||||
name = "flume"
|
name = "flume"
|
||||||
version = "0.11.1"
|
version = "0.11.1"
|
||||||
|
|
@ -1373,6 +1428,16 @@ version = "0.2.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
|
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]]
|
[[package]]
|
||||||
name = "mio"
|
name = "mio"
|
||||||
version = "1.1.0"
|
version = "1.1.0"
|
||||||
|
|
@ -1802,6 +1867,7 @@ version = "0.12.24"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9d0946410b9f7b082a427e4ef5c8ff541a88b357bc6c637c40db3a68ac70a36f"
|
checksum = "9d0946410b9f7b082a427e4ef5c8ff541a88b357bc6c637c40db3a68ac70a36f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"async-compression",
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"bytes",
|
"bytes",
|
||||||
"cookie",
|
"cookie",
|
||||||
|
|
@ -1828,6 +1894,7 @@ dependencies = [
|
||||||
"sync_wrapper",
|
"sync_wrapper",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-rustls",
|
"tokio-rustls",
|
||||||
|
"tokio-util",
|
||||||
"tower 0.5.2",
|
"tower 0.5.2",
|
||||||
"tower-http",
|
"tower-http",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
|
|
@ -2127,6 +2194,12 @@ dependencies = [
|
||||||
"rand_core 0.6.4",
|
"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]]
|
[[package]]
|
||||||
name = "slab"
|
name = "slab"
|
||||||
version = "0.4.11"
|
version = "0.4.11"
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,8 @@ askama = "0.12"
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
chrono = { version = "0.4", features = ["serde", "clock"] }
|
chrono = { version = "0.4", features = ["serde", "clock"] }
|
||||||
clap = { version = "4.5", features = ["derive", "env"] }
|
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"
|
rpassword = "7.3"
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,8 @@ pub fn app_router(state: AppState) -> axum::Router {
|
||||||
.delete(cafes::delete_cafe),
|
.delete(cafes::delete_cafe),
|
||||||
)
|
)
|
||||||
.route("/nearby-cafes", get(cafes::nearby_cafes))
|
.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", get(cups::list_cups).post(cups::create_cup))
|
||||||
.route(
|
.route(
|
||||||
"/cups/:id",
|
"/cups/:id",
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ use crate::application::server::AppState;
|
||||||
use crate::domain::ids::RoasterId;
|
use crate::domain::ids::RoasterId;
|
||||||
use crate::domain::listing::{ListRequest, SortDirection};
|
use crate::domain::listing::{ListRequest, SortDirection};
|
||||||
use crate::domain::roasters::{NewRoaster, Roaster, RoasterSortKey, UpdateRoaster};
|
use crate::domain::roasters::{NewRoaster, Roaster, RoasterSortKey, UpdateRoaster};
|
||||||
|
use crate::infrastructure::ai::{self, ExtractedRoaster, ExtractionInput};
|
||||||
use crate::presentation::web::templates::{
|
use crate::presentation::web::templates::{
|
||||||
RoasterDetailTemplate, RoasterListTemplate, RoastersTemplate,
|
RoasterDetailTemplate, RoasterListTemplate, RoastersTemplate,
|
||||||
};
|
};
|
||||||
|
|
@ -69,6 +70,7 @@ pub(crate) async fn roasters_page(
|
||||||
let template = RoastersTemplate {
|
let template = RoastersTemplate {
|
||||||
nav_active: "roasters",
|
nav_active: "roasters",
|
||||||
is_authenticated,
|
is_authenticated,
|
||||||
|
has_ai_extract: state.has_ai_extract(),
|
||||||
roasters,
|
roasters,
|
||||||
navigator,
|
navigator,
|
||||||
};
|
};
|
||||||
|
|
@ -183,6 +185,24 @@ define_delete_handler!(
|
||||||
render_roaster_list_fragment
|
render_roaster_list_fragment
|
||||||
);
|
);
|
||||||
|
|
||||||
|
#[tracing::instrument(skip(state, _auth_user))]
|
||||||
|
pub(crate) async fn extract_roaster(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
_auth_user: AuthenticatedUser,
|
||||||
|
Json(input): Json<ExtractionInput>,
|
||||||
|
) -> Result<Json<ExtractedRoaster>, 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!(
|
define_list_fragment_renderer!(
|
||||||
render_roaster_list_fragment,
|
render_roaster_list_fragment,
|
||||||
RoasterSortKey,
|
RoasterSortKey,
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ use crate::domain::bags::{BagFilter, BagSortKey};
|
||||||
use crate::domain::ids::{RoastId, RoasterId};
|
use crate::domain::ids::{RoastId, RoasterId};
|
||||||
use crate::domain::listing::{ListRequest, SortDirection};
|
use crate::domain::listing::{ListRequest, SortDirection};
|
||||||
use crate::domain::roasts::{NewRoast, RoastSortKey, RoastWithRoaster, UpdateRoast};
|
use crate::domain::roasts::{NewRoast, RoastSortKey, RoastWithRoaster, UpdateRoast};
|
||||||
|
use crate::infrastructure::ai::{self, ExtractedRoast, ExtractionInput};
|
||||||
use crate::presentation::web::templates::{
|
use crate::presentation::web::templates::{
|
||||||
RoastDetailTemplate, RoastListTemplate, RoastOptionsTemplate, RoastsTemplate,
|
RoastDetailTemplate, RoastListTemplate, RoastOptionsTemplate, RoastsTemplate,
|
||||||
};
|
};
|
||||||
|
|
@ -75,6 +76,7 @@ pub(crate) async fn roasts_page(
|
||||||
let template = RoastsTemplate {
|
let template = RoastsTemplate {
|
||||||
nav_active: "roasts",
|
nav_active: "roasts",
|
||||||
is_authenticated,
|
is_authenticated,
|
||||||
|
has_ai_extract: state.has_ai_extract(),
|
||||||
roasts,
|
roasts,
|
||||||
roaster_options,
|
roaster_options,
|
||||||
navigator,
|
navigator,
|
||||||
|
|
@ -338,6 +340,24 @@ impl TastingNotesInput {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument(skip(state, _auth_user))]
|
||||||
|
pub(crate) async fn extract_roast_info(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
_auth_user: AuthenticatedUser,
|
||||||
|
Json(input): Json<ExtractionInput>,
|
||||||
|
) -> Result<Json<ExtractedRoast>, 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!(
|
define_list_fragment_renderer!(
|
||||||
render_roast_list_fragment,
|
render_roast_list_fragment,
|
||||||
RoastSortKey,
|
RoastSortKey,
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,8 @@ pub struct ServerConfig {
|
||||||
pub database_url: String,
|
pub database_url: String,
|
||||||
pub admin_password: Option<String>,
|
pub admin_password: Option<String>,
|
||||||
pub admin_username: Option<String>,
|
pub admin_username: Option<String>,
|
||||||
|
pub openrouter_api_key: Option<String>,
|
||||||
|
pub openrouter_model: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
|
|
@ -49,6 +51,8 @@ pub struct AppState {
|
||||||
pub session_repo: Arc<dyn SessionRepository>,
|
pub session_repo: Arc<dyn SessionRepository>,
|
||||||
pub http_client: reqwest::Client,
|
pub http_client: reqwest::Client,
|
||||||
pub nominatim_url: String,
|
pub nominatim_url: String,
|
||||||
|
pub openrouter_api_key: Option<String>,
|
||||||
|
pub openrouter_model: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppState {
|
impl AppState {
|
||||||
|
|
@ -67,6 +71,8 @@ impl AppState {
|
||||||
session_repo: Arc<dyn SessionRepository>,
|
session_repo: Arc<dyn SessionRepository>,
|
||||||
http_client: reqwest::Client,
|
http_client: reqwest::Client,
|
||||||
nominatim_url: String,
|
nominatim_url: String,
|
||||||
|
openrouter_api_key: Option<String>,
|
||||||
|
openrouter_model: String,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
roaster_repo,
|
roaster_repo,
|
||||||
|
|
@ -82,8 +88,14 @@ impl AppState {
|
||||||
session_repo,
|
session_repo,
|
||||||
http_client,
|
http_client,
|
||||||
nominatim_url,
|
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<()> {
|
pub async fn serve(config: ServerConfig) -> anyhow::Result<()> {
|
||||||
|
|
@ -124,6 +136,8 @@ pub async fn serve(config: ServerConfig) -> anyhow::Result<()> {
|
||||||
session_repo,
|
session_repo,
|
||||||
reqwest::Client::new(),
|
reqwest::Client::new(),
|
||||||
crate::infrastructure::osm::NOMINATIM_SEARCH_URL.to_string(),
|
crate::infrastructure::osm::NOMINATIM_SEARCH_URL.to_string(),
|
||||||
|
config.openrouter_api_key,
|
||||||
|
config.openrouter_model,
|
||||||
);
|
);
|
||||||
|
|
||||||
let listener = TcpListener::bind(config.bind_address)
|
let listener = TcpListener::bind(config.bind_address)
|
||||||
|
|
|
||||||
357
src/infrastructure/ai.rs
Normal file
357
src/infrastructure/ai.rs
Normal file
|
|
@ -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<String>,
|
||||||
|
pub prompt: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ExtractedRoaster {
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub country: Option<String>,
|
||||||
|
pub city: Option<String>,
|
||||||
|
pub homepage: Option<String>,
|
||||||
|
pub notes: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ExtractedRoast {
|
||||||
|
pub roaster_name: Option<String>,
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub origin: Option<String>,
|
||||||
|
pub region: Option<String>,
|
||||||
|
pub producer: Option<String>,
|
||||||
|
pub process: Option<String>,
|
||||||
|
pub tasting_notes: Option<Vec<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Public functions ---
|
||||||
|
|
||||||
|
pub async fn extract_roaster(
|
||||||
|
client: &reqwest::Client,
|
||||||
|
api_key: &str,
|
||||||
|
model: &str,
|
||||||
|
input: &ExtractionInput,
|
||||||
|
) -> Result<ExtractedRoaster, AppError> {
|
||||||
|
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<ExtractedRoast, AppError> {
|
||||||
|
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<String, AppError> {
|
||||||
|
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<Message>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct Message {
|
||||||
|
role: String,
|
||||||
|
content: Vec<ContentPart>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<Choice>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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"}"#);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
pub mod ai;
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod backup;
|
pub mod backup;
|
||||||
pub mod client;
|
pub mod client;
|
||||||
|
|
|
||||||
|
|
@ -114,6 +114,16 @@ pub struct ServeCommand {
|
||||||
|
|
||||||
#[arg(long, env = "BREWLOG_ADMIN_USERNAME")]
|
#[arg(long, env = "BREWLOG_ADMIN_USERNAME")]
|
||||||
pub admin_username: Option<String>,
|
pub admin_username: Option<String>,
|
||||||
|
|
||||||
|
#[arg(long, env = "BREWLOG_OPENROUTER_API_KEY")]
|
||||||
|
pub openrouter_api_key: Option<String>,
|
||||||
|
|
||||||
|
#[arg(
|
||||||
|
long,
|
||||||
|
env = "BREWLOG_OPENROUTER_MODEL",
|
||||||
|
default_value = "openrouter/free"
|
||||||
|
)]
|
||||||
|
pub openrouter_model: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn print_json<T>(value: &T) -> anyhow::Result<()>
|
pub(crate) fn print_json<T>(value: &T) -> anyhow::Result<()>
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ use crate::domain::timeline::TimelineSortKey;
|
||||||
pub struct RoastersTemplate {
|
pub struct RoastersTemplate {
|
||||||
pub nav_active: &'static str,
|
pub nav_active: &'static str,
|
||||||
pub is_authenticated: bool,
|
pub is_authenticated: bool,
|
||||||
|
pub has_ai_extract: bool,
|
||||||
pub roasters: Paginated<RoasterView>,
|
pub roasters: Paginated<RoasterView>,
|
||||||
pub navigator: ListNavigator<RoasterSortKey>,
|
pub navigator: ListNavigator<RoasterSortKey>,
|
||||||
}
|
}
|
||||||
|
|
@ -45,6 +46,7 @@ pub struct RoasterDetailTemplate {
|
||||||
pub struct RoastsTemplate {
|
pub struct RoastsTemplate {
|
||||||
pub nav_active: &'static str,
|
pub nav_active: &'static str,
|
||||||
pub is_authenticated: bool,
|
pub is_authenticated: bool,
|
||||||
|
pub has_ai_extract: bool,
|
||||||
pub roasts: Paginated<RoastView>,
|
pub roasts: Paginated<RoastView>,
|
||||||
pub roaster_options: Vec<RoasterOptionView>,
|
pub roaster_options: Vec<RoasterOptionView>,
|
||||||
pub navigator: ListNavigator<RoastSortKey>,
|
pub navigator: ListNavigator<RoastSortKey>,
|
||||||
|
|
|
||||||
|
|
@ -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 %}
|
||||||
|
<script>
|
||||||
|
var _extracting = false;
|
||||||
|
|
||||||
|
function triggerPhotoExtract(formId, endpoint) {
|
||||||
|
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 () {
|
||||||
|
doExtract(formId, endpoint, { image: reader.result });
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(input.files[0]);
|
||||||
|
};
|
||||||
|
input.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractFromText(formId, endpoint) {
|
||||||
|
var input = document.getElementById(formId + '-extract-text');
|
||||||
|
var prompt = input.value.trim();
|
||||||
|
if (prompt.length < 3) return;
|
||||||
|
doExtract(formId, endpoint, { prompt: prompt });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doExtract(formId, endpoint, body) {
|
||||||
|
if (_extracting) return;
|
||||||
|
_extracting = true;
|
||||||
|
var errorEl = document.getElementById(formId + '-extract-error');
|
||||||
|
var spinnerEl = document.getElementById(formId + '-extract-spinner');
|
||||||
|
errorEl.classList.add('hidden');
|
||||||
|
spinnerEl.classList.remove('hidden');
|
||||||
|
|
||||||
|
try {
|
||||||
|
var resp = await fetch(endpoint, {
|
||||||
|
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();
|
||||||
|
fillRoasterForm(formId, data);
|
||||||
|
} catch (e) {
|
||||||
|
errorEl.textContent = 'Extraction failed: ' + e.message;
|
||||||
|
errorEl.classList.remove('hidden');
|
||||||
|
} finally {
|
||||||
|
spinnerEl.classList.add('hidden');
|
||||||
|
_extracting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillRoasterForm(formId, data) {
|
||||||
|
var form = document.getElementById(formId);
|
||||||
|
if (!form) return;
|
||||||
|
if (data.name) form.querySelector('[name="name"]').value = data.name;
|
||||||
|
if (data.country) form.querySelector('[name="country"]').value = data.country;
|
||||||
|
if (data.city) form.querySelector('[name="city"]').value = data.city;
|
||||||
|
if (data.homepage) form.querySelector('[name="homepage"]').value = data.homepage;
|
||||||
|
if (data.notes) form.querySelector('[name="notes"]').value = data.notes;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
<section data-signals:_show-form="false">
|
<section data-signals:_show-form="false">
|
||||||
<header class="flex flex-wrap items-start justify-between gap-4">
|
<header class="flex flex-wrap items-start justify-between gap-4">
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
|
|
@ -31,6 +104,7 @@
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<form
|
<form
|
||||||
|
id="roaster-form"
|
||||||
method="post"
|
method="post"
|
||||||
action="/api/v1/roasters"
|
action="/api/v1/roasters"
|
||||||
class="mt-4 flex flex-col gap-4"
|
class="mt-4 flex flex-col gap-4"
|
||||||
|
|
@ -38,6 +112,44 @@
|
||||||
data-ref="_form"
|
data-ref="_form"
|
||||||
data-on:datastar-fetch="evt.detail.type === 'finished' && ($_showForm = false, $_form && $_form.reset())"
|
data-on:datastar-fetch="evt.detail.type === 'finished' && ($_showForm = false, $_form && $_form.reset())"
|
||||||
>
|
>
|
||||||
|
{% if has_ai_extract %}
|
||||||
|
<div class="relative border-b border-amber-200 pb-4">
|
||||||
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick="triggerPhotoExtract('roaster-form', '/api/v1/extract-roaster')"
|
||||||
|
class="inline-flex items-center gap-2 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"
|
||||||
|
>
|
||||||
|
<svg class="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>
|
||||||
|
Extract from 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="roaster-form-extract-text"
|
||||||
|
class="input-field w-full text-sm"
|
||||||
|
placeholder="Describe the roaster…"
|
||||||
|
onkeydown="if(event.key==='Enter'){event.preventDefault();extractFromText('roaster-form','/api/v1/extract-roaster')}"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick="extractFromText('roaster-form', '/api/v1/extract-roaster')"
|
||||||
|
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="roaster-form-extract-spinner" class="hidden h-4 w-4 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="roaster-form-extract-error" class="hidden mt-2 text-sm text-red-600"></p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
<div class="grid gap-4 sm:grid-cols-2">
|
<div class="grid gap-4 sm:grid-cols-2">
|
||||||
<label class="flex flex-col gap-1 text-sm">
|
<label class="flex flex-col gap-1 text-sm">
|
||||||
<span class="text-stone-700">Name *</span>
|
<span class="text-stone-700">Name *</span>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,92 @@
|
||||||
{% extends "base.html" %} {% block title %}Brewlog · Roasts{% endblock %} {% block content %}
|
{% extends "base.html" %} {% block title %}Brewlog · Roasts{% endblock %}
|
||||||
|
|
||||||
|
{% block head %}
|
||||||
|
{% if is_authenticated && has_ai_extract %}
|
||||||
|
<script>
|
||||||
|
var _extracting = false;
|
||||||
|
|
||||||
|
function triggerPhotoExtract(formId, endpoint) {
|
||||||
|
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 () {
|
||||||
|
doExtract(formId, endpoint, { image: reader.result });
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(input.files[0]);
|
||||||
|
};
|
||||||
|
input.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractFromText(formId, endpoint) {
|
||||||
|
var input = document.getElementById(formId + '-extract-text');
|
||||||
|
var prompt = input.value.trim();
|
||||||
|
if (prompt.length < 3) return;
|
||||||
|
doExtract(formId, endpoint, { prompt: prompt });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doExtract(formId, endpoint, body) {
|
||||||
|
if (_extracting) return;
|
||||||
|
_extracting = true;
|
||||||
|
var errorEl = document.getElementById(formId + '-extract-error');
|
||||||
|
var spinnerEl = document.getElementById(formId + '-extract-spinner');
|
||||||
|
errorEl.classList.add('hidden');
|
||||||
|
spinnerEl.classList.remove('hidden');
|
||||||
|
|
||||||
|
try {
|
||||||
|
var resp = await fetch(endpoint, {
|
||||||
|
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();
|
||||||
|
fillRoastForm(formId, data);
|
||||||
|
} catch (e) {
|
||||||
|
errorEl.textContent = 'Extraction failed: ' + e.message;
|
||||||
|
errorEl.classList.remove('hidden');
|
||||||
|
} finally {
|
||||||
|
spinnerEl.classList.add('hidden');
|
||||||
|
_extracting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillRoastForm(formId, data) {
|
||||||
|
var form = document.getElementById(formId);
|
||||||
|
if (!form) return;
|
||||||
|
|
||||||
|
if (data.roaster_name) {
|
||||||
|
var select = form.querySelector('[name="roaster_id"]');
|
||||||
|
var lowerName = data.roaster_name.toLowerCase();
|
||||||
|
for (var i = 0; i < select.options.length; i++) {
|
||||||
|
if (select.options[i].text.toLowerCase().includes(lowerName)) {
|
||||||
|
select.selectedIndex = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.name) form.querySelector('[name="name"]').value = data.name;
|
||||||
|
if (data.origin) form.querySelector('[name="origin"]').value = data.origin;
|
||||||
|
if (data.region) form.querySelector('[name="region"]').value = data.region;
|
||||||
|
if (data.producer) form.querySelector('[name="producer"]').value = data.producer;
|
||||||
|
if (data.process) form.querySelector('[name="process"]').value = data.process;
|
||||||
|
if (data.tasting_notes && data.tasting_notes.length > 0) {
|
||||||
|
form.querySelector('[name="tasting_notes"]').value = data.tasting_notes.join(', ');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
<section data-signals:_show-form="false">
|
<section data-signals:_show-form="false">
|
||||||
<header class="flex flex-wrap items-start justify-between gap-4">
|
<header class="flex flex-wrap items-start justify-between gap-4">
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
|
|
@ -45,6 +133,7 @@
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<form
|
<form
|
||||||
|
id="roast-form"
|
||||||
method="post"
|
method="post"
|
||||||
action="/api/v1/roasts"
|
action="/api/v1/roasts"
|
||||||
class="mt-4 flex flex-col gap-4"
|
class="mt-4 flex flex-col gap-4"
|
||||||
|
|
@ -52,6 +141,44 @@
|
||||||
data-ref="_form"
|
data-ref="_form"
|
||||||
data-on:datastar-fetch="evt.detail.type === 'finished' && ($_showForm = false, $_form && $_form.reset())"
|
data-on:datastar-fetch="evt.detail.type === 'finished' && ($_showForm = false, $_form && $_form.reset())"
|
||||||
>
|
>
|
||||||
|
{% if has_ai_extract %}
|
||||||
|
<div class="relative border-b border-amber-200 pb-4">
|
||||||
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick="triggerPhotoExtract('roast-form', '/api/v1/extract-roast')"
|
||||||
|
class="inline-flex items-center gap-2 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"
|
||||||
|
>
|
||||||
|
<svg class="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>
|
||||||
|
Extract from 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="roast-form-extract-text"
|
||||||
|
class="input-field w-full text-sm"
|
||||||
|
placeholder="Describe the coffee…"
|
||||||
|
onkeydown="if(event.key==='Enter'){event.preventDefault();extractFromText('roast-form','/api/v1/extract-roast')}"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick="extractFromText('roast-form', '/api/v1/extract-roast')"
|
||||||
|
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="roast-form-extract-spinner" class="hidden h-4 w-4 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="roast-form-extract-error" class="hidden mt-2 text-sm text-red-600"></p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
<div class="grid gap-4 sm:grid-cols-2">
|
<div class="grid gap-4 sm:grid-cols-2">
|
||||||
<label class="flex flex-col gap-1 text-sm">
|
<label class="flex flex-col gap-1 text-sm">
|
||||||
<span class="text-stone-700">Roaster *</span>
|
<span class="text-stone-700">Roaster *</span>
|
||||||
|
|
|
||||||
|
|
@ -127,6 +127,8 @@ async fn spawn_app_inner(
|
||||||
session_repo,
|
session_repo,
|
||||||
reqwest::Client::new(),
|
reqwest::Client::new(),
|
||||||
nominatim_url,
|
nominatim_url,
|
||||||
|
None,
|
||||||
|
"openrouter/free".to_string(),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Create router
|
// Create router
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue