This commit is contained in:
Andras Schmelczer 2026-02-14 12:53:29 +00:00
parent 3a3f899ea2
commit 128b3191e7
68 changed files with 28060 additions and 1152 deletions

View 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)
}

View file

@ -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();

View file

@ -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}"))?;
}
}

View file

@ -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,
});
}
}

View file

@ -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"
);
}
}

View file

@ -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 {

View file

@ -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();

View file

@ -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

View 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,
}),
)
}
}

View file

@ -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 {