Working
This commit is contained in:
parent
14a3555cf1
commit
7e92bf112e
34 changed files with 1214437 additions and 224 deletions
|
|
@ -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 1–2.
|
||||
/// 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);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)?;
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
|
|
|
|||
|
|
@ -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(¶ms.postcode);
|
||||
|
||||
let pc_idx = match state.postcode_data.postcode_to_idx.get(&normalized) {
|
||||
Some(&idx) => idx,
|
||||
|
|
|
|||
|
|
@ -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(¶ms.postcode);
|
||||
|
||||
// Look up postcode centroid for spatial search
|
||||
let pc_idx = match state.postcode_data.postcode_to_idx.get(&normalized) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue