Changes
This commit is contained in:
parent
3a3f899ea2
commit
128b3191e7
68 changed files with 28060 additions and 1152 deletions
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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue