This commit is contained in:
Andras Schmelczer 2026-03-12 22:11:00 +00:00
parent 14a3555cf1
commit 7e92bf112e
34 changed files with 1214437 additions and 224 deletions

View file

@ -20,13 +20,11 @@ pub const AI_FILTERS_TEMPERATURE: f32 = 0.0;
/// Timeout for outbound HTTP service calls (seconds).
pub const SERVICE_CALL_TIMEOUT: u64 = 120;
/// Inner London free zone bounds (south, west, north, east) — roughly zones 12.
/// Inner London free zone bounds (south, west, north, east) — roughly zone 1.
/// Users without a license can only query data within these bounds.
pub const FREE_ZONE_BOUNDS: (f64, f64, f64, f64) = (51.42, -0.34, 51.60, 0.14);
pub const FREE_ZONE_BOUNDS: (f64, f64, f64, f64) = (51.44, -0.31, 51.59, 0.05);
/// Homepage demo center (lat, lng) and tolerance for the license bypass.
/// Hexagon requests centered within this tolerance skip the license check,
/// so the ScrollStory animation works for anonymous visitors.
/// ~0.05° ≈ 5.5 km — covers central London only.
pub const DEMO_CENTER: (f64, f64) = (51.51, -0.12);
pub const DEMO_CENTER_TOLERANCE: f64 = 0.05;
/// Exact demo bounds (south, west, north, east) sent by the homepage ScrollStory.
/// Requests matching these exact values bypass the license check so the
/// animation works for anonymous visitors. Only this specific viewport is allowed.
pub const DEMO_BOUNDS: (f64, f64, f64, f64) = (46.0, -12.0, 56.5, 12.0);

View file

@ -1058,9 +1058,9 @@ pub static ENUM_FEATURE_GROUPS: &[EnumFeatureGroup] = &[
},
EnumFeatureConfig {
name: "Property type",
order: Some(&["Detached", "Semi-Detached", "Terraced", "Flats/Maisonettes"]),
description: "Type of property: detached, semi-detached, terraced, or flat/maisonette",
detail: "From HM Land Registry Price Paid data. The broad property type classification: Detached, Semi-Detached, Terraced, or Flats/Maisonettes.",
order: Some(&["Detached", "Semi-Detached", "Terraced", "Flats/Maisonettes", "Other"]),
description: "Type of property: detached, semi-detached, terraced, flat/maisonette, or other",
detail: "From HM Land Registry Price Paid data and EPC certificates. Detached, Semi-Detached, Terraced (includes all terrace sub-types), Flats/Maisonettes, or Other (bungalows, park homes, etc.).",
source: "price-paid",
},
EnumFeatureConfig {

View file

@ -205,7 +205,6 @@ pub async fn post_ai_filters(
) -> 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,
@ -221,29 +220,65 @@ pub async fn post_ai_filters(
}
});
let json_resp = ollama_chat(&state.http_client, &url, &body).await?;
// Try up to 2 attempts — LLMs occasionally return empty content (e.g. only
// <think> blocks with no JSON output), which is transient and usually
// succeeds on retry.
let mut last_err = None;
for attempt in 0..2 {
let raw = call_ollama_and_parse(&state.http_client, &url, &body).await;
match raw {
Ok(raw) => {
let filters = validate_and_convert(&raw, &state.features_response);
let notes = raw
.get("notes")
.and_then(|val| val.as_str())
.unwrap_or("")
.to_string();
return Ok(Json(AiFiltersResponse { filters, notes }));
}
Err(err) => {
if attempt == 0 {
warn!("LLM attempt 1 failed, retrying: {}", err.1);
}
last_err = Some(err);
}
}
}
Err(last_err.unwrap())
}
/// Call Ollama and parse the response content as JSON.
///
/// Returns an error if: the HTTP call fails, the response is malformed,
/// the content is empty after stripping think blocks, or the content is
/// not valid JSON.
async fn call_ollama_and_parse(
client: &reqwest::Client,
url: &str,
body: &Value,
) -> Result<Value, (StatusCode, String)> {
let json_resp = ollama_chat(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| {
if content.is_empty() {
warn!("LLM returned empty content after stripping think blocks");
return Err((
StatusCode::BAD_GATEWAY,
"LLM returned empty content (no JSON output)".into(),
));
}
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.

View file

@ -11,7 +11,7 @@ use tracing::info;
use crate::aggregation::Aggregator;
use crate::auth::OptionalUser;
use crate::consts::{DEMO_CENTER, DEMO_CENTER_TOLERANCE, MAX_CELLS_PER_REQUEST};
use crate::consts::{DEMO_BOUNDS, MAX_CELLS_PER_REQUEST};
use crate::data::travel_time::TravelData;
use crate::licensing::check_license_bounds;
use crate::parsing::{
@ -139,11 +139,7 @@ pub async fn get_hexagons(
let (south, west, north, east) =
require_bounds(params.bounds).map_err(IntoResponse::into_response)?;
let center_lat = (south + north) / 2.0;
let center_lng = (west + east) / 2.0;
let is_demo_view = (center_lat - DEMO_CENTER.0).abs() <= DEMO_CENTER_TOLERANCE
&& (center_lng - DEMO_CENTER.1).abs() <= DEMO_CENTER_TOLERANCE;
let is_demo_view = (south, west, north, east) == DEMO_BOUNDS;
if !is_demo_view {
check_license_bounds(&user.0, (south, west, north, east))
.map_err(|(_, resp)| resp)?;

View file

@ -159,13 +159,26 @@ pub async fn get_invite(
}
let pb_url = state.pocketbase_url.trim_end_matches('/');
let token = match auth_superuser(&state.http_client, pb_url, &state.pocketbase_admin_email, &state.pocketbase_admin_password).await
{
Ok(t) => t,
Err(err) => {
warn!("Failed to auth as PocketBase superuser: {err}");
return StatusCode::BAD_GATEWAY.into_response();
}
};
let filter = format!("code=\"{}\"", code);
let url = format!(
"{pb_url}/api/collections/invites/records?filter={}&perPage=1",
urlencoding::encode(&filter)
);
let res = match state.http_client.get(&url).send().await {
let res = match state.http_client.get(&url)
.header("Authorization", format!("Bearer {token}"))
.send().await
{
Ok(r) => r,
Err(err) => {
warn!("Failed to look up invite: {err}");

View file

@ -12,6 +12,7 @@ use crate::consts::{DEFAULT_PROPERTIES_LIMIT, MAX_PROPERTIES_LIMIT, POSTCODE_SEA
use crate::licensing::check_license_point;
use crate::parsing::{parse_filters, row_passes_filters};
use crate::state::AppState;
use crate::utils::normalize_postcode;
use super::properties::{HexagonPropertiesResponse, Property};
@ -28,12 +29,7 @@ pub async fn get_postcode_properties(
Extension(user): Extension<OptionalUser>,
Query(params): Query<PostcodePropertiesParams>,
) -> Result<Json<HexagonPropertiesResponse>, axum::response::Response> {
let normalized = params
.postcode
.to_uppercase()
.split_whitespace()
.collect::<Vec<_>>()
.join(" ");
let normalized = normalize_postcode(&params.postcode);
let pc_idx = match state.postcode_data.postcode_to_idx.get(&normalized) {
Some(&idx) => idx,

View file

@ -12,6 +12,7 @@ use crate::consts::POSTCODE_SEARCH_OFFSET;
use crate::licensing::check_license_point;
use crate::parsing::{parse_field_set, parse_filters, row_passes_filters};
use crate::state::AppState;
use crate::utils::normalize_postcode;
use super::hexagon_stats::HexagonStatsResponse;
use super::stats;
@ -30,13 +31,7 @@ pub async fn get_postcode_stats(
Extension(user): Extension<OptionalUser>,
Query(params): Query<PostcodeStatsParams>,
) -> Result<Json<HexagonStatsResponse>, axum::response::Response> {
// Normalize postcode: uppercase, collapse whitespace
let normalized = params
.postcode
.to_uppercase()
.split_whitespace()
.collect::<Vec<_>>()
.join(" ");
let normalized = normalize_postcode(&params.postcode);
// Look up postcode centroid for spatial search
let pc_idx = match state.postcode_data.postcode_to_idx.get(&normalized) {

View file

@ -19,6 +19,7 @@ use crate::parsing::{
};
use crate::routes::travel_time::{parse_travel_entries, TravelTimeAgg};
use crate::state::AppState;
use crate::utils::normalize_postcode;
#[derive(Serialize)]
pub struct PostcodesResponse {
@ -361,12 +362,7 @@ pub async fn get_postcode_lookup(
state: Arc<AppState>,
Path(postcode): Path<String>,
) -> Result<Json<Value>, StatusCode> {
// Normalize the postcode: uppercase, remove extra spaces, ensure single space
let normalized = postcode
.to_uppercase()
.split_whitespace()
.collect::<Vec<_>>()
.join(" ");
let normalized = normalize_postcode(&postcode);
let postcode_data = &state.postcode_data;

View file

@ -3,6 +3,7 @@ use std::sync::Arc;
use axum::extract::Query;
use axum::http::StatusCode;
use axum::response::Json;
use rustc_hash::FxHashSet;
use serde::{Deserialize, Serialize};
use tracing::info;
@ -62,6 +63,11 @@ pub async fn get_travel_destinations(
// Sort: type rank asc, population desc, name length asc
matches.sort_unstable_by(|a, b| a.2.cmp(&b.2).then(b.3.cmp(&a.3)).then(a.4.cmp(&b.4)));
// Deduplicate by slug — multiple places can share a name/slug
// (e.g. "Richmond" as city + suburb), keep the best-ranked one
let mut seen_slugs = FxHashSet::default();
matches.retain(|(_, slug, ..)| seen_slugs.insert(slug.clone()));
let results: Vec<DestinationResult> = matches
.into_iter()
.map(|(idx, slug, ..)| DestinationResult {

View file

@ -7,3 +7,16 @@ pub use grid_index::GridIndex;
pub use hash::{generate_priorities, splitmix64_hash};
pub use interned_column::InternedColumn;
pub use llm::{extract_ollama_content, ollama_chat, strip_think_blocks};
/// Normalize a UK postcode: uppercase, strip spaces, insert canonical space before inward code.
/// e.g. "e142dg" → "E14 2DG", "E14 2DG" → "E14 2DG", "EC1A1BB" → "EC1A 1BB"
pub fn normalize_postcode(raw: &str) -> String {
let stripped: String = raw.chars().filter(|c| !c.is_whitespace()).collect();
let upper = stripped.to_uppercase();
if upper.len() >= 5 {
let (outward, inward) = upper.split_at(upper.len() - 3);
format!("{} {}", outward, inward)
} else {
upper
}
}