Changes
This commit is contained in:
parent
3a3f899ea2
commit
128b3191e7
68 changed files with 28060 additions and 1152 deletions
|
|
@ -79,13 +79,18 @@ async fn validate_token(
|
|||
.header("Authorization", format!("Bearer {token}"))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| warn!("Token validation request failed: {err}"))
|
||||
.ok()?;
|
||||
|
||||
if !res.status().is_success() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let body: AuthRefreshResponse = res.json().await.ok()?;
|
||||
let body: AuthRefreshResponse = res
|
||||
.json()
|
||||
.await
|
||||
.map_err(|err| warn!("Failed to parse auth refresh response: {err}"))
|
||||
.ok()?;
|
||||
Some(body.record)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -18,6 +18,5 @@ pub const AREA_SUMMARY_SYSTEM_PROMPT: &str = "You are an experienced estate agen
|
|||
pub const AREA_SUMMARY_MAX_TOKENS: usize = 300;
|
||||
pub const AREA_SUMMARY_TEMPERATURE: f32 = 0.3;
|
||||
|
||||
pub const AI_FILTERS_SYSTEM_PROMPT: &str = "You are a property search assistant. The user will describe their ideal property or area in natural language. Your job is to translate their description into filter settings. ONLY set filters the user explicitly mentioned or clearly implied. Leave everything else out. Do not guess or add extra filters. If a request is ambiguous, prefer a wider range. Output valid JSON matching the provided schema.";
|
||||
pub const AI_FILTERS_MAX_TOKENS: usize = 2000;
|
||||
pub const AI_FILTERS_TEMPERATURE: f32 = 0.0;
|
||||
|
|
|
|||
|
|
@ -26,6 +26,8 @@ pub struct FeatureConfig {
|
|||
pub suffix: &'static str,
|
||||
/// If true, show full integer (no k/M abbreviation)
|
||||
pub raw: bool,
|
||||
/// If true, the slider uses absolute min/max/step instead of percentile scaling
|
||||
pub absolute: bool,
|
||||
}
|
||||
|
||||
/// Features whose histogram bins should be exactly 1 unit wide (one per integer).
|
||||
|
|
@ -85,6 +87,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
prefix: "£",
|
||||
suffix: "",
|
||||
raw: false,
|
||||
absolute: true,
|
||||
},
|
||||
FeatureConfig {
|
||||
name: "Estimated current price",
|
||||
|
|
@ -94,11 +97,12 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
},
|
||||
step: 10000.0,
|
||||
description: "Inflation-adjusted estimate of the current property value",
|
||||
detail: "Estimated by applying a repeat-sales price index to the last known sale price. The index tracks price changes within each postcode sector and property type. Properties sold recently will have estimates close to their sale price; older sales are adjusted more. Coverage depends on having enough repeat sales in the local area to build the index.",
|
||||
detail: "Estimated by applying a repeat-sales price index to the last known sale price, plus a renovation premium for properties with post-sale improvements detected from EPC records (extensions, renovations, remodeling). The index tracks price changes within each postcode sector and property type. Renovation premiums are estimated per area from observed repeat-sale pairs and decay over time. Properties sold recently will have estimates close to their sale price; older sales are adjusted more.",
|
||||
source: "price-paid",
|
||||
prefix: "£",
|
||||
suffix: "",
|
||||
raw: false,
|
||||
absolute: true,
|
||||
},
|
||||
FeatureConfig {
|
||||
name: "Price per sqm",
|
||||
|
|
@ -113,6 +117,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
prefix: "£",
|
||||
suffix: "",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
},
|
||||
FeatureConfig {
|
||||
name: "Est. price per sqm",
|
||||
|
|
@ -122,11 +127,12 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
},
|
||||
step: 100.0,
|
||||
description: "Estimated current price divided by total floor area",
|
||||
detail: "Calculated by dividing the inflation-adjusted estimated current price by the total floor area from the EPC certificate. Provides a more up-to-date price-per-area comparison than the historical sale price per sqm.",
|
||||
detail: "Calculated by dividing the inflation-adjusted estimated current price (including any renovation premium) by the total floor area from the EPC certificate. Provides a more up-to-date price-per-area comparison than the historical sale price per sqm.",
|
||||
source: "price-paid",
|
||||
prefix: "£",
|
||||
suffix: "",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
},
|
||||
FeatureConfig {
|
||||
name: "Total floor area (sqm)",
|
||||
|
|
@ -141,12 +147,28 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
prefix: "",
|
||||
suffix: " sqm",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
},
|
||||
FeatureConfig {
|
||||
name: "Interior height (m)",
|
||||
bounds: Bounds::Percentile {
|
||||
low: 2.0,
|
||||
high: 98.0,
|
||||
},
|
||||
step: 0.1,
|
||||
description: "Average storey height from the EPC survey",
|
||||
detail: "Average internal floor-to-ceiling height in metres as recorded during the Energy Performance Certificate assessment. Calculated by dividing the total internal volume by the total floor area.",
|
||||
source: "epc",
|
||||
prefix: "",
|
||||
suffix: " m",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
},
|
||||
FeatureConfig {
|
||||
name: "Number of bedrooms & living rooms",
|
||||
bounds: Bounds::Fixed {
|
||||
min: 1.0,
|
||||
max: 10.0,
|
||||
max: 12.0,
|
||||
},
|
||||
step: 1.0,
|
||||
description: "Count of habitable rooms from the EPC survey",
|
||||
|
|
@ -155,6 +177,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
prefix: "",
|
||||
suffix: " rooms",
|
||||
raw: false,
|
||||
absolute: true,
|
||||
},
|
||||
FeatureConfig {
|
||||
name: "Estimated monthly rent",
|
||||
|
|
@ -166,6 +189,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
prefix: "£",
|
||||
suffix: "/mo",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
},
|
||||
FeatureConfig {
|
||||
name: "Date of last transaction",
|
||||
|
|
@ -180,6 +204,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
prefix: "",
|
||||
suffix: "",
|
||||
raw: true,
|
||||
absolute: false,
|
||||
},
|
||||
FeatureConfig {
|
||||
name: "Construction age",
|
||||
|
|
@ -194,6 +219,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
prefix: "",
|
||||
suffix: "",
|
||||
raw: true,
|
||||
absolute: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
@ -213,6 +239,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
prefix: "",
|
||||
suffix: " mins",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
},
|
||||
FeatureConfig {
|
||||
name: "Public transport to Fitzrovia (mins)",
|
||||
|
|
@ -227,6 +254,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
prefix: "",
|
||||
suffix: " mins",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
},
|
||||
FeatureConfig {
|
||||
name: "Cycling to Bank (mins)",
|
||||
|
|
@ -241,6 +269,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
prefix: "",
|
||||
suffix: " mins",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
},
|
||||
FeatureConfig {
|
||||
name: "Cycling to Fitzrovia (mins)",
|
||||
|
|
@ -255,6 +284,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
prefix: "",
|
||||
suffix: " mins",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
},
|
||||
FeatureConfig {
|
||||
name: "Number of public transport stations within 2km",
|
||||
|
|
@ -269,6 +299,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
prefix: "",
|
||||
suffix: "",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
@ -288,6 +319,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
prefix: "",
|
||||
suffix: "",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
},
|
||||
FeatureConfig {
|
||||
name: "Good+ primary schools within 5km",
|
||||
|
|
@ -302,6 +334,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
prefix: "",
|
||||
suffix: "",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
},
|
||||
FeatureConfig {
|
||||
name: "Good+ secondary schools within 5km",
|
||||
|
|
@ -316,6 +349,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
prefix: "",
|
||||
suffix: "",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
@ -332,6 +366,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
prefix: "",
|
||||
suffix: "",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
},
|
||||
FeatureConfig {
|
||||
name: "Employment Score (rate)",
|
||||
|
|
@ -343,6 +378,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
prefix: "",
|
||||
suffix: "",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
},
|
||||
FeatureConfig {
|
||||
name: "Health Deprivation and Disability Score",
|
||||
|
|
@ -357,6 +393,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
prefix: "",
|
||||
suffix: "",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
},
|
||||
FeatureConfig {
|
||||
name: "Living Environment Score",
|
||||
|
|
@ -371,6 +408,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
prefix: "",
|
||||
suffix: "",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
},
|
||||
FeatureConfig {
|
||||
name: "Indoors Sub-domain Score",
|
||||
|
|
@ -385,6 +423,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
prefix: "",
|
||||
suffix: "",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
},
|
||||
FeatureConfig {
|
||||
name: "Outdoors Sub-domain Score",
|
||||
|
|
@ -399,6 +438,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
prefix: "",
|
||||
suffix: "",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
@ -418,6 +458,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
prefix: "",
|
||||
suffix: "/yr",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
},
|
||||
FeatureConfig {
|
||||
name: "Violence and sexual offences (avg/yr)",
|
||||
|
|
@ -432,6 +473,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
prefix: "",
|
||||
suffix: "/yr",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
},
|
||||
FeatureConfig {
|
||||
name: "Criminal damage and arson (avg/yr)",
|
||||
|
|
@ -446,6 +488,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
prefix: "",
|
||||
suffix: "/yr",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
},
|
||||
FeatureConfig {
|
||||
name: "Burglary (avg/yr)",
|
||||
|
|
@ -460,6 +503,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
prefix: "",
|
||||
suffix: "/yr",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
},
|
||||
FeatureConfig {
|
||||
name: "Vehicle crime (avg/yr)",
|
||||
|
|
@ -474,6 +518,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
prefix: "",
|
||||
suffix: "/yr",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
},
|
||||
FeatureConfig {
|
||||
name: "Robbery (avg/yr)",
|
||||
|
|
@ -488,6 +533,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
prefix: "",
|
||||
suffix: "/yr",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
},
|
||||
FeatureConfig {
|
||||
name: "Other theft (avg/yr)",
|
||||
|
|
@ -502,6 +548,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
prefix: "",
|
||||
suffix: "/yr",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
},
|
||||
FeatureConfig {
|
||||
name: "Shoplifting (avg/yr)",
|
||||
|
|
@ -516,6 +563,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
prefix: "",
|
||||
suffix: "/yr",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
},
|
||||
FeatureConfig {
|
||||
name: "Drugs (avg/yr)",
|
||||
|
|
@ -530,6 +578,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
prefix: "",
|
||||
suffix: "/yr",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
},
|
||||
FeatureConfig {
|
||||
name: "Possession of weapons (avg/yr)",
|
||||
|
|
@ -544,6 +593,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
prefix: "",
|
||||
suffix: "/yr",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
},
|
||||
FeatureConfig {
|
||||
name: "Public order (avg/yr)",
|
||||
|
|
@ -558,6 +608,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
prefix: "",
|
||||
suffix: "/yr",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
},
|
||||
FeatureConfig {
|
||||
name: "Bicycle theft (avg/yr)",
|
||||
|
|
@ -572,6 +623,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
prefix: "",
|
||||
suffix: "/yr",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
},
|
||||
FeatureConfig {
|
||||
name: "Theft from the person (avg/yr)",
|
||||
|
|
@ -586,6 +638,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
prefix: "",
|
||||
suffix: "/yr",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
},
|
||||
FeatureConfig {
|
||||
name: "Other crime (avg/yr)",
|
||||
|
|
@ -600,6 +653,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
prefix: "",
|
||||
suffix: "/yr",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
},
|
||||
FeatureConfig {
|
||||
name: "Serious crime (avg/yr)",
|
||||
|
|
@ -614,6 +668,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
prefix: "",
|
||||
suffix: "/yr",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
},
|
||||
FeatureConfig {
|
||||
name: "Minor crime (avg/yr)",
|
||||
|
|
@ -628,6 +683,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
prefix: "",
|
||||
suffix: "/yr",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
@ -647,6 +703,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
prefix: "",
|
||||
suffix: "%",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
},
|
||||
FeatureConfig {
|
||||
name: "% Asian",
|
||||
|
|
@ -661,6 +718,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
prefix: "",
|
||||
suffix: "%",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
},
|
||||
FeatureConfig {
|
||||
name: "% Black",
|
||||
|
|
@ -675,6 +733,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
prefix: "",
|
||||
suffix: "%",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
},
|
||||
FeatureConfig {
|
||||
name: "% Mixed",
|
||||
|
|
@ -689,6 +748,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
prefix: "",
|
||||
suffix: "%",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
},
|
||||
FeatureConfig {
|
||||
name: "% Other",
|
||||
|
|
@ -703,6 +763,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
prefix: "",
|
||||
suffix: "%",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
@ -722,6 +783,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
prefix: "",
|
||||
suffix: "",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
},
|
||||
FeatureConfig {
|
||||
name: "Number of grocery shops and supermarkets within 2km",
|
||||
|
|
@ -736,6 +798,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
prefix: "",
|
||||
suffix: "",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
},
|
||||
FeatureConfig {
|
||||
name: "Number of parks within 2km",
|
||||
|
|
@ -750,6 +813,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
prefix: "",
|
||||
suffix: "",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
@ -769,6 +833,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
prefix: "",
|
||||
suffix: " dB",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
},
|
||||
FeatureConfig {
|
||||
name: "Max available download speed (Mbps)",
|
||||
|
|
@ -783,6 +848,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
prefix: "",
|
||||
suffix: " Mbps",
|
||||
raw: true,
|
||||
absolute: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ mod features;
|
|||
mod metrics;
|
||||
mod og_middleware;
|
||||
pub mod parsing;
|
||||
mod pocketbase;
|
||||
mod routes;
|
||||
mod state;
|
||||
pub mod utils;
|
||||
|
|
@ -23,7 +24,7 @@ use tower_http::compression::CompressionLayer;
|
|||
use tower_http::cors::{Any, CorsLayer};
|
||||
use tower_http::services::{ServeDir, ServeFile};
|
||||
use tower_http::trace::TraceLayer;
|
||||
use tracing::{info, warn};
|
||||
use tracing::info;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
|
||||
use state::AppState;
|
||||
|
|
@ -39,6 +40,10 @@ struct Cli {
|
|||
#[arg(long)]
|
||||
pois: PathBuf,
|
||||
|
||||
/// Path to the places parquet file
|
||||
#[arg(long)]
|
||||
places: PathBuf,
|
||||
|
||||
/// Path to the postcode boundaries directory
|
||||
#[arg(long)]
|
||||
postcodes: PathBuf,
|
||||
|
|
@ -56,28 +61,36 @@ struct Cli {
|
|||
screenshot_url: String,
|
||||
|
||||
/// Public-facing URL for absolute og:image URLs
|
||||
#[arg(
|
||||
long,
|
||||
env = "PUBLIC_URL",
|
||||
default_value = "https://perfectpostcodes.schmelczer.dev"
|
||||
)]
|
||||
#[arg(long, env = "PUBLIC_URL")]
|
||||
public_url: String,
|
||||
|
||||
/// PocketBase server URL for authentication (e.g. http://localhost:8090)
|
||||
#[arg(long, env = "POCKETBASE_URL")]
|
||||
pocketbase_url: String,
|
||||
|
||||
/// PocketBase superuser email (for auto-creating collections at startup)
|
||||
#[arg(long, env = "POCKETBASE_ADMIN_EMAIL")]
|
||||
pocketbase_admin_email: Option<String>,
|
||||
|
||||
/// PocketBase superuser password (for auto-creating collections at startup)
|
||||
#[arg(long, env = "POCKETBASE_ADMIN_PASSWORD")]
|
||||
pocketbase_admin_password: Option<String>,
|
||||
|
||||
/// Ollama server URL for AI area summaries (e.g. http://ollama:11434)
|
||||
#[arg(long, env = "OLLAMA_URL")]
|
||||
ollama_url: String,
|
||||
|
||||
/// Ollama model name for area summaries
|
||||
#[arg(long, env = "OLLAMA_MODEL", default_value = "gemma3:12b")]
|
||||
#[arg(long, env = "OLLAMA_MODEL")]
|
||||
ollama_model: String,
|
||||
|
||||
/// R5 routing service URL for real-time travel times (e.g. http://r5:8003)
|
||||
#[arg(long, env = "R5_URL", default_value = "")]
|
||||
r5_url: String,
|
||||
/// R5 routing service URL for all travel times (e.g. http://r5:8003)
|
||||
#[arg(long, env = "R5_URL")]
|
||||
r5_url: Option<String>,
|
||||
|
||||
/// Google Maps API key for Street View metadata lookups
|
||||
#[arg(long, env = "GOOGLE_MAPS_API_KEY")]
|
||||
google_maps_api_key: String,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
|
|
@ -138,6 +151,15 @@ async fn main() -> anyhow::Result<()> {
|
|||
info!("Building POI spatial grid index");
|
||||
let poi_grid = utils::GridIndex::build(&poi_data.lat, &poi_data.lng, consts::GRID_CELL_SIZE);
|
||||
|
||||
// Load place data
|
||||
let places_path = &cli.places;
|
||||
if !places_path.exists() {
|
||||
bail!("Places parquet file not found: {}", places_path.display());
|
||||
}
|
||||
info!("Loading place data from {}", places_path.display());
|
||||
let place_data = data::PlaceData::load(places_path)?;
|
||||
info!(places = place_data.name.len(), "Place data loaded");
|
||||
|
||||
// Load postcode boundaries
|
||||
let postcodes_path = &cli.postcodes;
|
||||
if !postcodes_path.exists() {
|
||||
|
|
@ -191,26 +213,15 @@ async fn main() -> anyhow::Result<()> {
|
|||
let poi_category_groups = poi_data.category_groups()?;
|
||||
|
||||
// Read index.html at startup for crawler OG injection
|
||||
let frontend_dist = cli
|
||||
.dist
|
||||
.unwrap_or_else(|| PathBuf::from("frontend/dist"));
|
||||
|
||||
let index_html = {
|
||||
let index_path = frontend_dist.join("index.html");
|
||||
match std::fs::read_to_string(&index_path) {
|
||||
Ok(html) => {
|
||||
info!("Loaded index.html for OG injection");
|
||||
Some(html)
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(
|
||||
"Could not read {}: {} (OG injection disabled)",
|
||||
index_path.display(),
|
||||
err
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
let (frontend_dist, index_html) = if let Some(dist) = cli.dist {
|
||||
let index_path = dist.join("index.html");
|
||||
let html = std::fs::read_to_string(&index_path)
|
||||
.with_context(|| format!("Failed to read {}", index_path.display()))?;
|
||||
info!("Loaded index.html for OG injection");
|
||||
(Some(dist), Some(html))
|
||||
} else {
|
||||
info!("No --dist provided, static serving and OG injection disabled");
|
||||
(None, None)
|
||||
};
|
||||
|
||||
let http_client = reqwest::Client::new();
|
||||
|
|
@ -223,6 +234,10 @@ async fn main() -> anyhow::Result<()> {
|
|||
"Precomputed features response"
|
||||
);
|
||||
|
||||
let ai_filters_schema = routes::build_ollama_schema(&features_response);
|
||||
let ai_filters_system_prompt = routes::build_system_prompt(&features_response);
|
||||
info!("Precomputed AI filters schema and system prompt");
|
||||
|
||||
// Record data loading metrics
|
||||
metrics::record_data_stats(
|
||||
property_data.lat.len(),
|
||||
|
|
@ -231,12 +246,21 @@ async fn main() -> anyhow::Result<()> {
|
|||
);
|
||||
|
||||
info!("PocketBase configured: {}", cli.pocketbase_url);
|
||||
|
||||
if let (Some(ref email), Some(ref password)) =
|
||||
(&cli.pocketbase_admin_email, &cli.pocketbase_admin_password)
|
||||
{
|
||||
pocketbase::ensure_collections(&http_client, &cli.pocketbase_url, email, password).await?;
|
||||
} else {
|
||||
info!("PocketBase admin credentials not set — skipping collection auto-creation");
|
||||
}
|
||||
|
||||
info!(
|
||||
"Ollama configured: {} (model: {})",
|
||||
cli.ollama_url, cli.ollama_model
|
||||
);
|
||||
if !cli.r5_url.is_empty() {
|
||||
info!("R5 routing service configured: {}", cli.r5_url);
|
||||
if let Some(ref url) = cli.r5_url {
|
||||
info!("R5 routing service configured: {}", url);
|
||||
} else {
|
||||
info!("R5 routing service not configured (travel time queries disabled)");
|
||||
}
|
||||
|
|
@ -249,6 +273,7 @@ async fn main() -> anyhow::Result<()> {
|
|||
h3_cells,
|
||||
poi_data,
|
||||
poi_grid,
|
||||
place_data,
|
||||
postcode_data,
|
||||
feature_name_to_index,
|
||||
min_keys,
|
||||
|
|
@ -265,6 +290,9 @@ async fn main() -> anyhow::Result<()> {
|
|||
ollama_model: cli.ollama_model,
|
||||
r5_url: cli.r5_url,
|
||||
token_cache,
|
||||
ai_filters_schema,
|
||||
ai_filters_system_prompt,
|
||||
google_maps_api_key: cli.google_maps_api_key,
|
||||
});
|
||||
|
||||
let cors = CorsLayer::new()
|
||||
|
|
@ -286,8 +314,11 @@ async fn main() -> anyhow::Result<()> {
|
|||
let state_pb = state.clone();
|
||||
let state_postcode_stats = state.clone();
|
||||
let state_area_summary = state.clone();
|
||||
let state_places = state.clone();
|
||||
let state_shorten = state.clone();
|
||||
let state_short_url = state.clone();
|
||||
let state_ai_filters = state.clone();
|
||||
let state_streetview = state.clone();
|
||||
|
||||
let api = Router::new()
|
||||
.route(
|
||||
|
|
@ -314,6 +345,10 @@ async fn main() -> anyhow::Result<()> {
|
|||
"/api/poi-categories",
|
||||
get(move || routes::get_poi_categories(state_poi_categories.clone())),
|
||||
)
|
||||
.route(
|
||||
"/api/places",
|
||||
get(move |query| routes::get_places(state_places.clone(), query)),
|
||||
)
|
||||
.route(
|
||||
"/api/hexagon-properties",
|
||||
get(move |query| {
|
||||
|
|
@ -345,6 +380,14 @@ async fn main() -> anyhow::Result<()> {
|
|||
"/api/shorten",
|
||||
post(move |body| routes::post_shorten(state_shorten.clone(), body)),
|
||||
)
|
||||
.route(
|
||||
"/api/ai-filters",
|
||||
post(move |body| routes::post_ai_filters(state_ai_filters.clone(), body)),
|
||||
)
|
||||
.route(
|
||||
"/api/streetview",
|
||||
get(move |query| routes::get_streetview(state_streetview.clone(), query)),
|
||||
)
|
||||
.route(
|
||||
"/s/{code}",
|
||||
get(move |path| routes::get_short_url(state_short_url.clone(), path)),
|
||||
|
|
@ -364,6 +407,7 @@ async fn main() -> anyhow::Result<()> {
|
|||
routes::get_style(axum::extract::State(reader_style.clone()), headers, query)
|
||||
}),
|
||||
)
|
||||
.route("/health", get(|| async { "ok" }))
|
||||
.route(
|
||||
"/metrics",
|
||||
get(move || metrics::metrics_handler(metrics_handle.clone())),
|
||||
|
|
@ -373,10 +417,9 @@ async fn main() -> anyhow::Result<()> {
|
|||
any(move |req| routes::proxy_to_pocketbase(state_pb.clone(), req)),
|
||||
);
|
||||
|
||||
let app = if frontend_dist.exists() {
|
||||
let app = if let Some(ref dist) = frontend_dist {
|
||||
api.fallback_service(
|
||||
ServeDir::new(&frontend_dist)
|
||||
.not_found_service(ServeFile::new(frontend_dist.join("index.html"))),
|
||||
ServeDir::new(dist).not_found_service(ServeFile::new(dist.join("index.html"))),
|
||||
)
|
||||
} else {
|
||||
api
|
||||
|
|
|
|||
|
|
@ -38,11 +38,8 @@ pub fn cell_for_row(
|
|||
if !need_parent || max_cell == 0 {
|
||||
return max_cell;
|
||||
}
|
||||
h3o::CellIndex::try_from(max_cell)
|
||||
.ok()
|
||||
.and_then(|ci| ci.parent(h3_res))
|
||||
.map(u64::from)
|
||||
.unwrap_or(0)
|
||||
let cell = h3o::CellIndex::try_from(max_cell).expect("precomputed H3 cell must be valid");
|
||||
u64::from(cell.parent(h3_res).expect("parent resolution must be valid for precomputed cell"))
|
||||
}
|
||||
|
||||
/// Whether the given resolution requires computing a parent from precomputed cells.
|
||||
|
|
|
|||
235
server-rs/src/pocketbase.rs
Normal file
235
server-rs/src/pocketbase.rs
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::info;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct AuthResponse {
|
||||
token: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct CollectionList {
|
||||
items: Vec<CollectionItem>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct CollectionItem {
|
||||
name: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct CreateCollection {
|
||||
name: String,
|
||||
r#type: String,
|
||||
fields: Vec<Field>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct Field {
|
||||
name: String,
|
||||
r#type: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
required: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
max_select: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
collection_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
max_size: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
mime_types: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
impl Field {
|
||||
fn text(name: &str, required: bool) -> Self {
|
||||
Self {
|
||||
name: name.to_string(),
|
||||
r#type: "text".to_string(),
|
||||
required: Some(required),
|
||||
max_select: None,
|
||||
collection_id: None,
|
||||
max_size: None,
|
||||
mime_types: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn file(name: &str, mime_types: Vec<&str>) -> Self {
|
||||
Self {
|
||||
name: name.to_string(),
|
||||
r#type: "file".to_string(),
|
||||
required: Some(false),
|
||||
max_select: Some(1),
|
||||
collection_id: None,
|
||||
max_size: Some(10 * 1024 * 1024), // 10 MB
|
||||
mime_types: Some(mime_types.into_iter().map(String::from).collect()),
|
||||
}
|
||||
}
|
||||
|
||||
fn relation(name: &str, collection_id: &str) -> Self {
|
||||
Self {
|
||||
name: name.to_string(),
|
||||
r#type: "relation".to_string(),
|
||||
required: Some(true),
|
||||
max_select: Some(1),
|
||||
collection_id: Some(collection_id.to_string()),
|
||||
max_size: None,
|
||||
mime_types: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn auth_superuser(
|
||||
client: &Client,
|
||||
base_url: &str,
|
||||
email: &str,
|
||||
password: &str,
|
||||
) -> anyhow::Result<String> {
|
||||
let url = format!("{base_url}/api/collections/_superusers/auth-with-password");
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.json(&serde_json::json!({
|
||||
"identity": email,
|
||||
"password": password,
|
||||
}))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
anyhow::bail!("PocketBase superuser auth failed ({status}): {text}");
|
||||
}
|
||||
|
||||
let body: AuthResponse = resp.json().await?;
|
||||
Ok(body.token)
|
||||
}
|
||||
|
||||
async fn list_collections(
|
||||
client: &Client,
|
||||
base_url: &str,
|
||||
token: &str,
|
||||
) -> anyhow::Result<Vec<String>> {
|
||||
let url = format!("{base_url}/api/collections?perPage=200");
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.header("Authorization", format!("Bearer {token}"))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
anyhow::bail!("Failed to list PocketBase collections ({status}): {text}");
|
||||
}
|
||||
|
||||
let body: CollectionList = resp.json().await?;
|
||||
Ok(body.items.into_iter().map(|c| c.name).collect())
|
||||
}
|
||||
|
||||
async fn create_collection(
|
||||
client: &Client,
|
||||
base_url: &str,
|
||||
token: &str,
|
||||
collection: CreateCollection,
|
||||
) -> anyhow::Result<()> {
|
||||
let name = collection.name.clone();
|
||||
let resp = client
|
||||
.post(&format!("{base_url}/api/collections"))
|
||||
.header("Authorization", format!("Bearer {token}"))
|
||||
.json(&collection)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
anyhow::bail!("Failed to create collection '{name}' ({status}): {text}");
|
||||
}
|
||||
|
||||
info!("Created PocketBase collection: {name}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Look up the internal ID of the `users` auth collection.
|
||||
async fn find_users_collection_id(
|
||||
client: &Client,
|
||||
base_url: &str,
|
||||
token: &str,
|
||||
) -> anyhow::Result<String> {
|
||||
let url = format!("{base_url}/api/collections/users");
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.header("Authorization", format!("Bearer {token}"))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
anyhow::bail!("Failed to fetch users collection ({status}): {text}");
|
||||
}
|
||||
|
||||
let body: serde_json::Value = resp.json().await?;
|
||||
let id = body["id"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("users collection has no id field"))?;
|
||||
Ok(id.to_string())
|
||||
}
|
||||
|
||||
/// Ensure the `saved_searches` and `short_urls` collections exist in PocketBase.
|
||||
/// Authenticates as superuser, checks existing collections, and creates any that are missing.
|
||||
pub async fn ensure_collections(
|
||||
client: &Client,
|
||||
base_url: &str,
|
||||
admin_email: &str,
|
||||
admin_password: &str,
|
||||
) -> anyhow::Result<()> {
|
||||
let base_url = base_url.trim_end_matches('/');
|
||||
|
||||
let token = auth_superuser(client, base_url, admin_email, admin_password).await?;
|
||||
let existing = list_collections(client, base_url, &token).await?;
|
||||
|
||||
if !existing.iter().any(|n| n == "saved_searches") {
|
||||
let users_id = find_users_collection_id(client, base_url, &token).await?;
|
||||
create_collection(
|
||||
client,
|
||||
base_url,
|
||||
&token,
|
||||
CreateCollection {
|
||||
name: "saved_searches".to_string(),
|
||||
r#type: "base".to_string(),
|
||||
fields: vec![
|
||||
Field::relation("user", &users_id),
|
||||
Field::text("name", true),
|
||||
Field::text("params", true),
|
||||
Field::file("screenshot", vec!["image/png", "image/jpeg", "image/webp"]),
|
||||
],
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
} else {
|
||||
info!("PocketBase collection 'saved_searches' already exists");
|
||||
}
|
||||
|
||||
if !existing.iter().any(|n| n == "short_urls") {
|
||||
create_collection(
|
||||
client,
|
||||
base_url,
|
||||
&token,
|
||||
CreateCollection {
|
||||
name: "short_urls".to_string(),
|
||||
r#type: "base".to_string(),
|
||||
fields: vec![
|
||||
Field::text("code", true),
|
||||
Field::text("params", true),
|
||||
],
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
} else {
|
||||
info!("PocketBase collection 'short_urls' already exists");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -14,10 +14,11 @@ pub(crate) mod properties;
|
|||
mod screenshot;
|
||||
mod shorten;
|
||||
mod stats;
|
||||
mod streetview;
|
||||
mod tiles;
|
||||
pub(crate) mod travel_time;
|
||||
|
||||
pub use ai_filters::{build_feature_prompt, build_ollama_schema, post_ai_filters};
|
||||
pub use ai_filters::{build_ollama_schema, build_system_prompt, post_ai_filters};
|
||||
pub use area_summary::post_area_summary;
|
||||
pub use export::get_export;
|
||||
pub use features::{build_features_response, get_features, FeatureInfo, FeaturesResponse};
|
||||
|
|
@ -32,4 +33,5 @@ pub use postcodes::{get_postcode_lookup, get_postcodes};
|
|||
pub use properties::get_hexagon_properties;
|
||||
pub use screenshot::get_screenshot;
|
||||
pub use shorten::{get_short_url, post_shorten};
|
||||
pub use streetview::get_streetview;
|
||||
pub use tiles::{get_style, get_tile, init_tile_reader};
|
||||
|
|
|
|||
334
server-rs/src/routes/ai_filters.rs
Normal file
334
server-rs/src/routes/ai_filters.rs
Normal file
|
|
@ -0,0 +1,334 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::Json;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::consts::{AI_FILTERS_MAX_TOKENS, AI_FILTERS_TEMPERATURE};
|
||||
use crate::routes::{FeatureInfo, FeaturesResponse};
|
||||
use crate::state::AppState;
|
||||
use crate::utils::{extract_ollama_content, ollama_chat, strip_think_blocks};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct AiFiltersRequest {
|
||||
query: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct AiFiltersResponse {
|
||||
filters: Value,
|
||||
/// What the LLM couldn't map to existing filters (empty if everything matched)
|
||||
#[serde(skip_serializing_if = "String::is_empty")]
|
||||
notes: String,
|
||||
}
|
||||
|
||||
/// Build a JSON schema for Ollama structured output.
|
||||
///
|
||||
/// Uses two arrays (`numeric_filters` and `enum_filters`) instead of one property
|
||||
/// per feature, because Ollama converts JSON schema to GBNF grammar and a schema
|
||||
/// with 50+ optional keys causes a combinatorial explosion that crashes the parser.
|
||||
/// Array-based schema keeps the grammar small and constant-size.
|
||||
pub fn build_ollama_schema(_features: &FeaturesResponse) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"numeric_filters": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": { "type": "string" },
|
||||
"min": { "type": "number" },
|
||||
"max": { "type": "number" }
|
||||
},
|
||||
"required": ["name"]
|
||||
}
|
||||
},
|
||||
"enum_filters": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": { "type": "string" },
|
||||
"values": { "type": "array", "items": { "type": "string" } }
|
||||
},
|
||||
"required": ["name", "values"]
|
||||
}
|
||||
},
|
||||
"notes": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Build the complete system prompt for AI filters.
|
||||
///
|
||||
/// Contains: role instructions, feature catalogue, few-shot examples, output rules.
|
||||
/// Precomputed at startup and cached in AppState.
|
||||
pub fn build_system_prompt(features: &FeaturesResponse) -> String {
|
||||
let mut parts = Vec::new();
|
||||
|
||||
// Role and task description
|
||||
parts.push(
|
||||
"You are a UK property search assistant. \
|
||||
The user describes their ideal property or area in natural language. \
|
||||
Translate their description into filter settings using ONLY the features listed below.\n\
|
||||
\n\
|
||||
Rules:\n\
|
||||
- ONLY set filters the user explicitly mentioned or clearly implied.\n\
|
||||
- Leave out any filter the user did not mention. Empty arrays are fine.\n\
|
||||
- For numeric filters, omit \"min\" to leave the lower bound open, \
|
||||
omit \"max\" to leave the upper bound open.\n\
|
||||
- Use EXACT feature names from the list — spelling, capitalisation, and punctuation must match.\n\
|
||||
- \"cheap\" / \"affordable\" = lower price range. \"expensive\" = higher price range.\n\
|
||||
- \"low crime\" / \"safe\" = low values on crime features. \
|
||||
\"quiet\" = low Noise (dB). \"green\" / \"near parks\" = high Number of parks within 2km.\n\
|
||||
- When the user says a number like \"under 400k\", interpret it as 400000.\n\
|
||||
- When the user says \"3 bed\" or \"3 bedroom\", use Number of bedrooms & living rooms \
|
||||
(note: this counts bedrooms + living rooms combined, so 3 bed ~ min 4).\n\
|
||||
- If the user mentions something that has no matching filter, put it in \"notes\" \
|
||||
as a short phrase (e.g. \"No filter for: garden, sea view\"). \
|
||||
If everything was matched, set \"notes\" to an empty string."
|
||||
.to_string(),
|
||||
);
|
||||
|
||||
// Feature catalogue
|
||||
parts.push("\n--- AVAILABLE FEATURES ---\n".to_string());
|
||||
for group in &features.groups {
|
||||
parts.push(format!("## {}", group.name));
|
||||
for feature in &group.features {
|
||||
match feature {
|
||||
FeatureInfo::Numeric {
|
||||
name,
|
||||
min,
|
||||
max,
|
||||
description,
|
||||
prefix,
|
||||
suffix,
|
||||
..
|
||||
} => {
|
||||
parts.push(format!(
|
||||
"- \"{}\" (numeric, {}{:.0}{} to {}{:.0}{}): {}",
|
||||
name, prefix, min, suffix, prefix, max, suffix, description
|
||||
));
|
||||
}
|
||||
FeatureInfo::Enum {
|
||||
name,
|
||||
values,
|
||||
description,
|
||||
..
|
||||
} => {
|
||||
parts.push(format!(
|
||||
"- \"{}\" (enum, values: [{}]): {}",
|
||||
name,
|
||||
values
|
||||
.iter()
|
||||
.map(|val| format!("\"{}\"", val))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", "),
|
||||
description
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Few-shot examples
|
||||
parts.push("\n--- EXAMPLES ---\n".to_string());
|
||||
|
||||
parts.push(
|
||||
"User: \"cheap freehold house under 400k\"\n\
|
||||
Output: {\"numeric_filters\": [{\"name\": \"Last known price\", \"max\": 400000}], \
|
||||
\"enum_filters\": [{\"name\": \"Leashold/Freehold\", \"values\": [\"Freehold\"]}, \
|
||||
{\"name\": \"Property type\", \"values\": [\"Detached\", \"Semi-Detached\", \"Terraced\"]}], \
|
||||
\"notes\": \"\"}"
|
||||
.to_string(),
|
||||
);
|
||||
|
||||
parts.push(
|
||||
"\nUser: \"safe quiet area with good schools and parks\"\n\
|
||||
Output: {\"numeric_filters\": [\
|
||||
{\"name\": \"Violence and sexual offences (avg/yr)\", \"max\": 20}, \
|
||||
{\"name\": \"Burglary (avg/yr)\", \"max\": 10}, \
|
||||
{\"name\": \"Noise (dB)\", \"max\": 55}, \
|
||||
{\"name\": \"Good+ primary schools within 5km\", \"min\": 5}, \
|
||||
{\"name\": \"Good+ secondary schools within 5km\", \"min\": 2}, \
|
||||
{\"name\": \"Number of parks within 2km\", \"min\": 3}], \
|
||||
\"enum_filters\": [], \"notes\": \"\"}"
|
||||
.to_string(),
|
||||
);
|
||||
|
||||
parts.push(
|
||||
"\nUser: \"3 bed flat under 300k with fast broadband near the beach\"\n\
|
||||
Output: {\"numeric_filters\": [\
|
||||
{\"name\": \"Last known price\", \"max\": 300000}, \
|
||||
{\"name\": \"Number of bedrooms & living rooms\", \"min\": 4}, \
|
||||
{\"name\": \"Max available download speed (Mbps)\", \"min\": 100}], \
|
||||
\"enum_filters\": [{\"name\": \"Property type\", \"values\": [\"Flat\"]}], \
|
||||
\"notes\": \"No filter for: beach proximity\"}"
|
||||
.to_string(),
|
||||
);
|
||||
|
||||
parts.push(
|
||||
"\nUser: \"large family home with a garden near restaurants\"\n\
|
||||
Output: {\"numeric_filters\": [\
|
||||
{\"name\": \"Total floor area (sqm)\", \"min\": 100}, \
|
||||
{\"name\": \"Number of bedrooms & living rooms\", \"min\": 5}, \
|
||||
{\"name\": \"Number of restaurants within 2km\", \"min\": 10}], \
|
||||
\"enum_filters\": [{\"name\": \"Property type\", \
|
||||
\"values\": [\"Detached\", \"Semi-Detached\"]}], \
|
||||
\"notes\": \"No filter for: garden\"}"
|
||||
.to_string(),
|
||||
);
|
||||
|
||||
// Output format reminder
|
||||
parts.push(
|
||||
"\n--- OUTPUT FORMAT ---\n\
|
||||
{\"numeric_filters\": [...], \"enum_filters\": [...], \"notes\": \"...\"}\n\
|
||||
Respond with ONLY the JSON object. No explanation."
|
||||
.to_string(),
|
||||
);
|
||||
|
||||
parts.join("\n")
|
||||
}
|
||||
|
||||
pub async fn post_ai_filters(
|
||||
state: Arc<AppState>,
|
||||
Json(req): Json<AiFiltersRequest>,
|
||||
) -> Result<Json<AiFiltersResponse>, (StatusCode, String)> {
|
||||
info!(query = %req.query, "POST /api/ai-filters");
|
||||
|
||||
// Use Ollama native API with structured output
|
||||
let url = format!("{}/api/chat", state.ollama_url);
|
||||
let body = json!({
|
||||
"model": state.ollama_model,
|
||||
"messages": [
|
||||
{ "role": "system", "content": state.ai_filters_system_prompt },
|
||||
{ "role": "user", "content": req.query }
|
||||
],
|
||||
"stream": false,
|
||||
"format": state.ai_filters_schema,
|
||||
"options": {
|
||||
"temperature": AI_FILTERS_TEMPERATURE,
|
||||
"num_predict": AI_FILTERS_MAX_TOKENS,
|
||||
}
|
||||
});
|
||||
|
||||
let json_resp = ollama_chat(&state.http_client, &url, &body).await?;
|
||||
let content = extract_ollama_content(&json_resp)?;
|
||||
|
||||
let content = strip_think_blocks(content);
|
||||
let content = content.trim();
|
||||
|
||||
let raw: Value = serde_json::from_str(content).map_err(|err| {
|
||||
warn!(error = %err, content = %content, "Failed to parse LLM JSON output");
|
||||
(
|
||||
StatusCode::BAD_GATEWAY,
|
||||
format!("Failed to parse LLM output as JSON: {}", err),
|
||||
)
|
||||
})?;
|
||||
|
||||
// Validate and convert to FeatureFilters format
|
||||
let filters = validate_and_convert(&raw, &state.features_response);
|
||||
let notes = raw
|
||||
.get("notes")
|
||||
.and_then(|val| val.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
Ok(Json(AiFiltersResponse { filters, notes }))
|
||||
}
|
||||
|
||||
/// Validate LLM output against feature metadata and convert to FeatureFilters format.
|
||||
///
|
||||
/// Input format (array-based, grammar-friendly):
|
||||
/// ```json
|
||||
/// {
|
||||
/// "numeric_filters": [{"name": "Last known price", "min": 0, "max": 300000}],
|
||||
/// "enum_filters": [{"name": "Leashold/Freehold", "values": ["Freehold"]}]
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// Output format (FeatureFilters):
|
||||
/// ```json
|
||||
/// { "Last known price": [0, 300000], "Leashold/Freehold": ["Freehold"] }
|
||||
/// ```
|
||||
fn validate_and_convert(raw: &Value, features: &FeaturesResponse) -> Value {
|
||||
let mut result = serde_json::Map::new();
|
||||
|
||||
// Build lookup maps from feature metadata
|
||||
let mut numeric_features: rustc_hash::FxHashMap<&str, (f32, f32)> =
|
||||
rustc_hash::FxHashMap::default();
|
||||
let mut enum_features: rustc_hash::FxHashMap<&str, &[String]> =
|
||||
rustc_hash::FxHashMap::default();
|
||||
|
||||
for group in &features.groups {
|
||||
for feature in &group.features {
|
||||
match feature {
|
||||
FeatureInfo::Numeric { name, min, max, .. } => {
|
||||
numeric_features.insert(name, (*min, *max));
|
||||
}
|
||||
FeatureInfo::Enum { name, values, .. } => {
|
||||
enum_features.insert(name, values);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process numeric filters
|
||||
if let Some(arr) = raw.get("numeric_filters").and_then(|val| val.as_array()) {
|
||||
for item in arr {
|
||||
let name = match item.get("name").and_then(|val| val.as_str()) {
|
||||
Some(name) => name,
|
||||
None => continue,
|
||||
};
|
||||
let (feat_min, feat_max) = match numeric_features.get(name) {
|
||||
Some(range) => *range,
|
||||
None => continue,
|
||||
};
|
||||
let filter_min = item
|
||||
.get("min")
|
||||
.and_then(|val| val.as_f64())
|
||||
.map(|num| num.max(feat_min as f64).min(feat_max as f64) as f32)
|
||||
.unwrap_or(feat_min);
|
||||
let filter_max = item
|
||||
.get("max")
|
||||
.and_then(|val| val.as_f64())
|
||||
.map(|num| num.max(feat_min as f64).min(feat_max as f64) as f32)
|
||||
.unwrap_or(feat_max);
|
||||
// Only include if range is narrower than full range
|
||||
if filter_min > feat_min || filter_max < feat_max {
|
||||
result.insert(name.to_string(), json!([filter_min, filter_max]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process enum filters
|
||||
if let Some(arr) = raw.get("enum_filters").and_then(|val| val.as_array()) {
|
||||
for item in arr {
|
||||
let name = match item.get("name").and_then(|val| val.as_str()) {
|
||||
Some(name) => name,
|
||||
None => continue,
|
||||
};
|
||||
let valid_values = match enum_features.get(name) {
|
||||
Some(values) => *values,
|
||||
None => continue,
|
||||
};
|
||||
if let Some(selected) = item.get("values").and_then(|val| val.as_array()) {
|
||||
let valid: Vec<&str> = selected
|
||||
.iter()
|
||||
.filter_map(|item| item.as_str())
|
||||
.filter(|str_val| valid_values.iter().any(|known| known == str_val))
|
||||
.collect();
|
||||
if !valid.is_empty() && valid.len() < valid_values.len() {
|
||||
result.insert(name.to_string(), json!(valid));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Value::Object(result)
|
||||
}
|
||||
|
|
@ -3,12 +3,13 @@ use std::sync::Arc;
|
|||
use axum::http::StatusCode;
|
||||
use axum::response::Json;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::{info, warn};
|
||||
use tracing::info;
|
||||
|
||||
use crate::consts::{
|
||||
AREA_SUMMARY_MAX_TOKENS, AREA_SUMMARY_SYSTEM_PROMPT, AREA_SUMMARY_TEMPERATURE,
|
||||
};
|
||||
use crate::state::AppState;
|
||||
use crate::utils::{extract_openai_content, ollama_chat, strip_think_blocks};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct NumericStat {
|
||||
|
|
@ -89,22 +90,6 @@ fn build_prompt(req: &AreaSummaryRequest) -> String {
|
|||
result
|
||||
}
|
||||
|
||||
/// Strip `<think>...</think>` blocks from model output
|
||||
pub(crate) fn strip_think_blocks(text: &str) -> String {
|
||||
let mut result = String::new();
|
||||
let mut remaining = text;
|
||||
while let Some(start) = remaining.find("<think>") {
|
||||
result.push_str(&remaining[..start]);
|
||||
if let Some(end) = remaining[start..].find("</think>") {
|
||||
remaining = &remaining[start + end + 8..];
|
||||
} else {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
result.push_str(remaining);
|
||||
result
|
||||
}
|
||||
|
||||
pub async fn post_area_summary(
|
||||
state: Arc<AppState>,
|
||||
Json(req): Json<AreaSummaryRequest>,
|
||||
|
|
@ -124,45 +109,8 @@ pub async fn post_area_summary(
|
|||
"max_tokens": AREA_SUMMARY_MAX_TOKENS,
|
||||
});
|
||||
|
||||
let response = state
|
||||
.http_client
|
||||
.post(&url)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| {
|
||||
warn!(error = %err, "Failed to connect to Ollama");
|
||||
(
|
||||
StatusCode::BAD_GATEWAY,
|
||||
format!("Failed to connect to Ollama: {}", err),
|
||||
)
|
||||
})?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let body_text = response.text().await.unwrap_or_default();
|
||||
warn!(status = %status, body = %body_text, "Ollama returned error");
|
||||
return Err((
|
||||
StatusCode::BAD_GATEWAY,
|
||||
format!("Ollama error {}: {}", status, body_text),
|
||||
));
|
||||
}
|
||||
|
||||
let json: serde_json::Value = response.json().await.map_err(|err| {
|
||||
warn!(error = %err, "Failed to parse Ollama response");
|
||||
(
|
||||
StatusCode::BAD_GATEWAY,
|
||||
format!("Failed to parse Ollama response: {}", err),
|
||||
)
|
||||
})?;
|
||||
|
||||
let content = json
|
||||
.get("choices")
|
||||
.and_then(|ch| ch.get(0))
|
||||
.and_then(|ch| ch.get("message"))
|
||||
.and_then(|msg| msg.get("content"))
|
||||
.and_then(|ct| ct.as_str())
|
||||
.unwrap_or("");
|
||||
let json = ollama_chat(&state.http_client, &url, &body).await?;
|
||||
let content = extract_openai_content(&json)?;
|
||||
|
||||
let summary = strip_think_blocks(content).trim().to_string();
|
||||
|
||||
|
|
|
|||
|
|
@ -530,13 +530,19 @@ pub async fn get_export(
|
|||
}
|
||||
|
||||
// Column widths
|
||||
sheet.set_column_width(0, 12).ok();
|
||||
sheet.set_column_width(1, 12).ok();
|
||||
sheet
|
||||
.set_column_width(0, 12)
|
||||
.map_err(|e| format!("Failed to set column width: {e}"))?;
|
||||
sheet
|
||||
.set_column_width(1, 12)
|
||||
.map_err(|e| format!("Failed to set column width: {e}"))?;
|
||||
for col_offset in 0..feat_indices.len() {
|
||||
let col = (col_offset + 2) as u16;
|
||||
let feat_name = &feature_names[feat_indices[col_offset]];
|
||||
let width = (feat_name.len() as f64 * 1.1).clamp(10.0, 30.0);
|
||||
sheet.set_column_width(col, width).ok();
|
||||
sheet
|
||||
.set_column_width(col, width)
|
||||
.map_err(|e| format!("Failed to set column width: {e}"))?;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -35,6 +35,8 @@ pub enum FeatureInfo {
|
|||
suffix: &'static str,
|
||||
#[serde(skip_serializing_if = "is_false")]
|
||||
raw: bool,
|
||||
#[serde(skip_serializing_if = "is_false")]
|
||||
absolute: bool,
|
||||
},
|
||||
#[serde(rename = "enum")]
|
||||
Enum {
|
||||
|
|
@ -99,6 +101,7 @@ pub fn build_features_response(data: &PropertyData) -> FeaturesResponse {
|
|||
prefix: feature_config.prefix,
|
||||
suffix: feature_config.suffix,
|
||||
raw: feature_config.raw,
|
||||
absolute: feature_config.absolute,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ use axum::response::Json;
|
|||
use rustc_hash::FxHashMap;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value};
|
||||
use tracing::{info, warn};
|
||||
use tracing::info;
|
||||
|
||||
use crate::aggregation::Aggregator;
|
||||
use crate::consts::MAX_CELLS_PER_REQUEST;
|
||||
|
|
@ -33,10 +33,55 @@ pub struct HexagonParams {
|
|||
/// When present (even if empty), only listed features are aggregated and written.
|
||||
/// When absent, all features are included (backward compatible).
|
||||
fields: Option<String>,
|
||||
/// Destination point as "lat,lon" for real-time travel time calculation via R5.
|
||||
destination: Option<String>,
|
||||
/// Transport mode for travel time: "transit" (default), "car", or "bicycle".
|
||||
mode: Option<String>,
|
||||
/// Pipe-separated travel time entries: `lat,lon,mode|lat,lon,mode`
|
||||
/// Each entry requests travel time from hex centroids to that destination via the given mode.
|
||||
travel: Option<String>,
|
||||
}
|
||||
|
||||
struct TravelEntry {
|
||||
lat: f64,
|
||||
lon: f64,
|
||||
mode: String,
|
||||
}
|
||||
|
||||
const VALID_MODES: &[&str] = &["car", "bicycle", "walking", "transit"];
|
||||
|
||||
/// Parse `travel` param into a list of travel entries.
|
||||
/// Format: `lat,lon,mode|lat,lon,mode`
|
||||
fn parse_travel_entries(s: &str) -> Result<Vec<TravelEntry>, String> {
|
||||
let mut entries = Vec::new();
|
||||
let mut seen_modes = Vec::new();
|
||||
for segment in s.split('|') {
|
||||
let parts: Vec<&str> = segment.split(',').collect();
|
||||
if parts.len() != 3 {
|
||||
return Err(format!(
|
||||
"each travel entry must be 'lat,lon,mode', got '{}'",
|
||||
segment
|
||||
));
|
||||
}
|
||||
let lat: f64 = parts[0]
|
||||
.trim()
|
||||
.parse()
|
||||
.map_err(|_| format!("invalid travel latitude in '{}'", segment))?;
|
||||
let lon: f64 = parts[1]
|
||||
.trim()
|
||||
.parse()
|
||||
.map_err(|_| format!("invalid travel longitude in '{}'", segment))?;
|
||||
let mode = parts[2].trim().to_string();
|
||||
if !VALID_MODES.contains(&mode.as_str()) {
|
||||
return Err(format!(
|
||||
"invalid travel mode '{}', must be one of: {}",
|
||||
mode,
|
||||
VALID_MODES.join(", ")
|
||||
));
|
||||
}
|
||||
if seen_modes.contains(&mode) {
|
||||
return Err(format!("duplicate travel mode '{}'", mode));
|
||||
}
|
||||
seen_modes.push(mode.clone());
|
||||
entries.push(TravelEntry { lat, lon, mode });
|
||||
}
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
/// Build feature maps from aggregated cell data, filtering to only cells that intersect the query bounds.
|
||||
|
|
@ -104,23 +149,6 @@ fn build_feature_maps(
|
|||
features
|
||||
}
|
||||
|
||||
/// Parse "lat,lon" string into (lat, lon) tuple.
|
||||
fn parse_destination(s: &str) -> Result<[f64; 2], String> {
|
||||
let parts: Vec<&str> = s.split(',').collect();
|
||||
if parts.len() != 2 {
|
||||
return Err("destination must be 'lat,lon'".into());
|
||||
}
|
||||
let lat: f64 = parts[0]
|
||||
.trim()
|
||||
.parse()
|
||||
.map_err(|_| "invalid destination latitude")?;
|
||||
let lon: f64 = parts[1]
|
||||
.trim()
|
||||
.parse()
|
||||
.map_err(|_| "invalid destination longitude")?;
|
||||
Ok([lat, lon])
|
||||
}
|
||||
|
||||
pub async fn get_hexagons(
|
||||
state: Arc<AppState>,
|
||||
Query(params): Query<HexagonParams>,
|
||||
|
|
@ -141,16 +169,17 @@ pub async fn get_hexagons(
|
|||
|
||||
let field_indices = parse_field_indices(params.fields.as_deref(), &state.feature_name_to_index);
|
||||
|
||||
// Parse destination for travel time (before moving into blocking closure)
|
||||
let destination = params
|
||||
.destination
|
||||
// Parse travel entries
|
||||
let travel_entries = params
|
||||
.travel
|
||||
.as_deref()
|
||||
.map(parse_destination)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(parse_travel_entries)
|
||||
.transpose()
|
||||
.map_err(|e| (StatusCode::BAD_REQUEST, e))?;
|
||||
let mode = params.mode.clone().unwrap_or_else(|| "car".into());
|
||||
.map_err(|e| (StatusCode::BAD_REQUEST, e))?
|
||||
.unwrap_or_default();
|
||||
|
||||
// Capture what we need for the R5 call before moving state into spawn_blocking
|
||||
// Capture what we need for the R5 calls before moving state into spawn_blocking
|
||||
let r5_url = state.r5_url.clone();
|
||||
let http_client = state.http_client.clone();
|
||||
|
||||
|
|
@ -250,14 +279,12 @@ pub async fn get_hexagons(
|
|||
.map_err(|error| (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()))?
|
||||
.map_err(|error| (StatusCode::INTERNAL_SERVER_ERROR, error))?;
|
||||
|
||||
// If a destination was requested and R5 is configured, fetch travel times.
|
||||
if let Some(dest) = destination {
|
||||
if r5_url.is_empty() {
|
||||
return Err((
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"Travel time queries require routing service (R5_URL not configured)".into(),
|
||||
));
|
||||
}
|
||||
// If travel entries were requested and R5 is configured, fetch travel times concurrently.
|
||||
if !travel_entries.is_empty() {
|
||||
let url = r5_url.as_deref().ok_or((
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"Travel time queries require routing service (R5_URL not configured)".into(),
|
||||
))?;
|
||||
|
||||
// Collect hex centroids
|
||||
let origins: Vec<[f64; 2]> = response
|
||||
|
|
@ -267,39 +294,56 @@ pub async fn get_hexagons(
|
|||
let lat = f
|
||||
.get("lat")
|
||||
.and_then(|v| v.as_f64())
|
||||
.unwrap_or(0.0);
|
||||
.expect("lat must be present in feature map");
|
||||
let lon = f
|
||||
.get("lon")
|
||||
.and_then(|v| v.as_f64())
|
||||
.unwrap_or(0.0);
|
||||
.expect("lon must be present in feature map");
|
||||
[lat, lon]
|
||||
})
|
||||
.collect();
|
||||
|
||||
match fetch_travel_times(&http_client, &r5_url, origins, dest, &mode).await {
|
||||
Ok(travel_times) => {
|
||||
for (feature, tt) in response.features.iter_mut().zip(travel_times) {
|
||||
match tt {
|
||||
Some(minutes) => {
|
||||
if let Some(num) = serde_json::Number::from_f64(minutes) {
|
||||
feature.insert("travel_time".into(), Value::Number(num));
|
||||
}
|
||||
}
|
||||
None => {
|
||||
feature.insert("travel_time".into(), Value::Null);
|
||||
// Fire concurrent R5 calls for each travel entry
|
||||
let mut handles = Vec::with_capacity(travel_entries.len());
|
||||
for entry in &travel_entries {
|
||||
let client = http_client.clone();
|
||||
let url = url.to_string();
|
||||
let origins = origins.clone();
|
||||
let dest = [entry.lat, entry.lon];
|
||||
let mode = entry.mode.clone();
|
||||
handles.push(tokio::spawn(async move {
|
||||
fetch_travel_times(&client, &url, origins, dest, &mode).await
|
||||
}));
|
||||
}
|
||||
|
||||
let mut results = Vec::with_capacity(handles.len());
|
||||
for handle in handles {
|
||||
results.push(handle.await);
|
||||
}
|
||||
for (entry, result) in travel_entries.iter().zip(results) {
|
||||
let travel_times = result
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
.map_err(|err| (StatusCode::BAD_GATEWAY, err))?;
|
||||
|
||||
let field_name = format!("travel_time_{}", entry.mode);
|
||||
for (feature, tt) in response.features.iter_mut().zip(&travel_times) {
|
||||
match tt {
|
||||
Some(minutes) => {
|
||||
if let Some(num) = serde_json::Number::from_f64(*minutes) {
|
||||
feature.insert(field_name.clone(), Value::Number(num));
|
||||
}
|
||||
}
|
||||
None => {
|
||||
feature.insert(field_name.clone(), Value::Null);
|
||||
}
|
||||
}
|
||||
info!(
|
||||
hexagons = response.features.len(),
|
||||
destination = format_args!("{},{}", dest[0], dest[1]),
|
||||
mode = mode,
|
||||
"Travel times merged"
|
||||
);
|
||||
}
|
||||
Err(err) => {
|
||||
warn!("Travel time query failed, returning hexagons without travel_time: {}", err);
|
||||
}
|
||||
info!(
|
||||
hexagons = response.features.len(),
|
||||
destination = format_args!("{},{}", entry.lat, entry.lon),
|
||||
mode = entry.mode,
|
||||
"Travel times merged"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -53,11 +53,14 @@ pub async fn proxy_to_pocketbase(state: Arc<AppState>, req: Request) -> impl Int
|
|||
if name == "transfer-encoding" {
|
||||
continue;
|
||||
}
|
||||
response = response.header(
|
||||
HeaderName::from_bytes(name.as_ref())
|
||||
.unwrap_or(HeaderName::from_static("x-invalid")),
|
||||
value.clone(),
|
||||
);
|
||||
match HeaderName::from_bytes(name.as_ref()) {
|
||||
Ok(header_name) => {
|
||||
response = response.header(header_name, value.clone());
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(header = ?name, error = %err, "Skipping unparseable upstream header");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match upstream.bytes().await {
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ pub struct PlaceResult {
|
|||
place_type: String,
|
||||
lat: f32,
|
||||
lon: f32,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
city: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
|
|
@ -24,7 +26,7 @@ pub struct PlacesResponse {
|
|||
#[derive(Deserialize)]
|
||||
#[allow(clippy::min_ident_chars)]
|
||||
pub struct PlacesParams {
|
||||
q: Option<String>,
|
||||
q: String,
|
||||
limit: Option<usize>,
|
||||
}
|
||||
|
||||
|
|
@ -32,10 +34,11 @@ pub async fn get_places(
|
|||
state: Arc<AppState>,
|
||||
Query(params): Query<PlacesParams>,
|
||||
) -> Result<Json<PlacesResponse>, (StatusCode, String)> {
|
||||
let query = params
|
||||
.q
|
||||
.filter(|val| !val.is_empty())
|
||||
.ok_or((StatusCode::BAD_REQUEST, "Missing 'q' parameter".to_string()))?;
|
||||
let query = if params.q.is_empty() {
|
||||
return Err((StatusCode::BAD_REQUEST, "'q' must not be empty".into()));
|
||||
} else {
|
||||
params.q
|
||||
};
|
||||
|
||||
let limit = params.limit.unwrap_or(7).min(20);
|
||||
|
||||
|
|
@ -45,26 +48,37 @@ pub async fn get_places(
|
|||
let pd = &state.place_data;
|
||||
|
||||
// Linear scan — ~50-100k rows, <1ms
|
||||
let mut matches: Vec<(usize, bool, u8, usize)> = pd
|
||||
// Tuple: (row_idx, is_exact, is_prefix, type_rank, population, name_len)
|
||||
let mut matches: Vec<(usize, bool, bool, u8, u32, usize)> = pd
|
||||
.name_lower
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(idx, name)| {
|
||||
if name.contains(&query_lower) {
|
||||
let is_exact = name.len() == query_lower.len();
|
||||
let is_prefix = name.starts_with(&query_lower);
|
||||
Some((idx, is_prefix, pd.type_rank[idx], pd.name[idx].len()))
|
||||
Some((
|
||||
idx,
|
||||
is_exact,
|
||||
is_prefix,
|
||||
pd.type_rank[idx],
|
||||
pd.population[idx],
|
||||
pd.name[idx].len(),
|
||||
))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Sort: prefix first, then by type rank (cities before hamlets), then shorter names first
|
||||
// Sort: exact first, then prefix, then type rank asc, then population desc, then name length asc
|
||||
matches.sort_unstable_by(|lhs, rhs| {
|
||||
rhs.1
|
||||
.cmp(&lhs.1)
|
||||
.then(lhs.2.cmp(&rhs.2))
|
||||
.then(rhs.2.cmp(&lhs.2))
|
||||
.then(lhs.3.cmp(&rhs.3))
|
||||
.then(rhs.4.cmp(&lhs.4))
|
||||
.then(lhs.5.cmp(&rhs.5))
|
||||
});
|
||||
|
||||
matches.truncate(limit);
|
||||
|
|
@ -76,6 +90,7 @@ pub async fn get_places(
|
|||
place_type: pd.place_type.get(idx).to_string(),
|
||||
lat: pd.lat[idx],
|
||||
lon: pd.lon[idx],
|
||||
city: pd.city[idx].clone(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
|
|
|
|||
|
|
@ -146,6 +146,9 @@ pub async fn get_hexagon_properties(
|
|||
}
|
||||
});
|
||||
|
||||
// Sort so properties with addresses come first, unknown addresses last
|
||||
matching_rows.sort_unstable_by_key(|&row| state.data.address(row).trim().is_empty());
|
||||
|
||||
let total = matching_rows.len();
|
||||
let limit = params
|
||||
.limit
|
||||
|
|
|
|||
84
server-rs/src/routes/streetview.rs
Normal file
84
server-rs/src/routes/streetview.rs
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Json};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::warn;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct StreetViewQuery {
|
||||
lat: f64,
|
||||
lon: f64,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct GoogleMetadataResponse {
|
||||
status: String,
|
||||
#[serde(default)]
|
||||
pano_id: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct StreetViewResponse {
|
||||
status: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pano_id: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn get_streetview(
|
||||
state: Arc<AppState>,
|
||||
query: axum::extract::Query<StreetViewQuery>,
|
||||
) -> impl IntoResponse {
|
||||
let url = format!(
|
||||
"https://maps.googleapis.com/maps/api/streetview/metadata?location={},{}&radius=1000&source=outdoor&key={}",
|
||||
query.lat, query.lon, state.google_maps_api_key
|
||||
);
|
||||
|
||||
let resp = match state.http_client.get(&url).send().await {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
warn!("Street View metadata request failed: {e}");
|
||||
return (
|
||||
StatusCode::BAD_GATEWAY,
|
||||
Json(StreetViewResponse {
|
||||
status: "ERROR".to_string(),
|
||||
pano_id: None,
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
let meta: GoogleMetadataResponse = match resp.json().await {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
warn!("Failed to parse Street View metadata: {e}");
|
||||
return (
|
||||
StatusCode::BAD_GATEWAY,
|
||||
Json(StreetViewResponse {
|
||||
status: "ERROR".to_string(),
|
||||
pano_id: None,
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
if meta.status == "OK" {
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(StreetViewResponse {
|
||||
status: "OK".to_string(),
|
||||
pano_id: Some(meta.pano_id),
|
||||
}),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(StreetViewResponse {
|
||||
status: meta.status,
|
||||
pano_id: None,
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -35,7 +35,6 @@ pub async fn get_tile(
|
|||
|
||||
#[derive(Deserialize)]
|
||||
pub struct StyleParams {
|
||||
#[serde(default)]
|
||||
theme: Option<String>,
|
||||
}
|
||||
|
||||
|
|
@ -43,26 +42,26 @@ pub async fn get_style(
|
|||
State(reader): State<Arc<TileReader>>,
|
||||
headers: HeaderMap,
|
||||
Query(params): Query<StyleParams>,
|
||||
) -> Response {
|
||||
) -> Result<Response, (StatusCode, String)> {
|
||||
let is_dark = params.theme.as_deref() == Some("dark");
|
||||
|
||||
// Metadata is returned as a JSON string
|
||||
let metadata_str = match reader.get_metadata().await {
|
||||
Ok(meta) => meta,
|
||||
Err(err) => {
|
||||
warn!(error = %err, "Failed to get PMTiles metadata");
|
||||
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
|
||||
}
|
||||
};
|
||||
let metadata_str = reader.get_metadata().await.map_err(|err| {
|
||||
warn!(error = %err, "Failed to get PMTiles metadata");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to get PMTiles metadata: {err}"),
|
||||
)
|
||||
})?;
|
||||
|
||||
// Parse the JSON string
|
||||
let metadata: serde_json::Value = match serde_json::from_str(&metadata_str) {
|
||||
Ok(val) => val,
|
||||
Err(err) => {
|
||||
warn!(error = %err, "Failed to parse PMTiles metadata JSON");
|
||||
serde_json::Value::Object(serde_json::Map::new())
|
||||
}
|
||||
};
|
||||
let metadata: serde_json::Value = serde_json::from_str(&metadata_str).map_err(|err| {
|
||||
warn!(error = %err, "Failed to parse PMTiles metadata JSON");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to parse PMTiles metadata: {err}"),
|
||||
)
|
||||
})?;
|
||||
|
||||
// Extract tilestats for layer info if available
|
||||
let layers: Vec<serde_json::Value> = metadata
|
||||
|
|
@ -75,16 +74,19 @@ pub async fn get_style(
|
|||
let host = headers
|
||||
.get(header::HOST)
|
||||
.and_then(|hv| hv.to_str().ok())
|
||||
.unwrap_or("localhost:8001");
|
||||
.ok_or((
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Missing Host header".into(),
|
||||
))?;
|
||||
let tile_url = format!("http://{}/api/tiles/{{z}}/{{x}}/{{y}}", host);
|
||||
let style = build_style(is_dark, &layers, &tile_url);
|
||||
|
||||
(
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
[(header::CONTENT_TYPE, "application/json")],
|
||||
serde_json::to_string(&style).unwrap(),
|
||||
)
|
||||
.into_response()
|
||||
.into_response())
|
||||
}
|
||||
|
||||
fn build_style(is_dark: bool, layers: &[serde_json::Value], tile_url: &str) -> serde_json::Value {
|
||||
|
|
|
|||
|
|
@ -44,12 +44,14 @@ pub struct AppState {
|
|||
pub ollama_url: String,
|
||||
/// Ollama model name for area summaries (e.g. gemma3:12b)
|
||||
pub ollama_model: String,
|
||||
/// R5 routing service URL for all travel times (empty = disabled)
|
||||
pub r5_url: String,
|
||||
/// R5 routing service URL for all travel times (None = disabled)
|
||||
pub r5_url: Option<String>,
|
||||
/// Token validation cache (60s TTL)
|
||||
pub token_cache: Arc<TokenCache>,
|
||||
/// JSON schema for Ollama structured output in AI filters
|
||||
pub ai_filters_schema: serde_json::Value,
|
||||
/// Feature listing portion of the AI filters prompt
|
||||
pub ai_filters_feature_prompt: String,
|
||||
/// Complete system prompt for AI filters (features + examples + instructions)
|
||||
pub ai_filters_system_prompt: String,
|
||||
/// Google Maps API key for Street View metadata lookups
|
||||
pub google_maps_api_key: String,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,4 +6,4 @@ mod llm;
|
|||
pub use grid_index::GridIndex;
|
||||
pub use hash::{generate_priorities, splitmix64_hash};
|
||||
pub use interned_column::InternedColumn;
|
||||
pub use llm::strip_think_blocks;
|
||||
pub use llm::{extract_ollama_content, extract_openai_content, ollama_chat, strip_think_blocks};
|
||||
|
|
|
|||
|
|
@ -1,3 +1,75 @@
|
|||
use axum::http::StatusCode;
|
||||
use serde_json::Value;
|
||||
use tracing::warn;
|
||||
|
||||
pub type LlmError = (StatusCode, String);
|
||||
|
||||
/// Send a chat request to Ollama and return the parsed JSON response.
|
||||
///
|
||||
/// Handles connection errors, non-success status codes, and JSON parse failures
|
||||
/// uniformly as `BAD_GATEWAY` errors.
|
||||
pub async fn ollama_chat(
|
||||
client: &reqwest::Client,
|
||||
url: &str,
|
||||
body: &Value,
|
||||
) -> Result<Value, LlmError> {
|
||||
let response = client.post(url).json(body).send().await.map_err(|err| {
|
||||
warn!(error = %err, "Failed to connect to Ollama");
|
||||
(
|
||||
StatusCode::BAD_GATEWAY,
|
||||
format!("Failed to connect to Ollama: {}", err),
|
||||
)
|
||||
})?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let body_text = response.text().await.unwrap_or_default();
|
||||
warn!(status = %status, body = %body_text, "Ollama returned error");
|
||||
return Err((
|
||||
StatusCode::BAD_GATEWAY,
|
||||
format!("Ollama error {}: {}", status, body_text),
|
||||
));
|
||||
}
|
||||
|
||||
response.json().await.map_err(|err| {
|
||||
warn!(error = %err, "Failed to parse Ollama response");
|
||||
(
|
||||
StatusCode::BAD_GATEWAY,
|
||||
format!("Failed to parse Ollama response: {}", err),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Extract content from OpenAI-compatible response (`choices[0].message.content`)
|
||||
pub fn extract_openai_content(json: &Value) -> Result<&str, LlmError> {
|
||||
json.get("choices")
|
||||
.and_then(|ch| ch.get(0))
|
||||
.and_then(|ch| ch.get("message"))
|
||||
.and_then(|msg| msg.get("content"))
|
||||
.and_then(|ct| ct.as_str())
|
||||
.ok_or_else(|| {
|
||||
warn!("Malformed OpenAI response: missing choices[0].message.content");
|
||||
(
|
||||
StatusCode::BAD_GATEWAY,
|
||||
"Malformed LLM response: missing choices[0].message.content".into(),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Extract content from Ollama native response (`message.content`)
|
||||
pub fn extract_ollama_content(json: &Value) -> Result<&str, LlmError> {
|
||||
json.get("message")
|
||||
.and_then(|msg| msg.get("content"))
|
||||
.and_then(|ct| ct.as_str())
|
||||
.ok_or_else(|| {
|
||||
warn!("Malformed Ollama response: missing message.content");
|
||||
(
|
||||
StatusCode::BAD_GATEWAY,
|
||||
"Malformed LLM response: missing message.content".into(),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Strip `<think>...</think>` blocks from model output
|
||||
pub fn strip_think_blocks(text: &str) -> String {
|
||||
let mut result = String::new();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue