Lots of improvements
This commit is contained in:
parent
3853b5dce7
commit
b94cf17d75
33 changed files with 2587 additions and 1866 deletions
|
|
@ -39,8 +39,6 @@ pub struct AiFiltersRequest {
|
|||
query: String,
|
||||
/// Current filters for conversational refinement (e.g. "make it cheaper")
|
||||
context: Option<AiFiltersContext>,
|
||||
/// Current listing mode (historical/buy/rent). Defaults to "historical".
|
||||
listing_type: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
|
|
@ -62,8 +60,6 @@ pub struct AiFiltersResponse {
|
|||
/// What the LLM couldn't map to existing filters (empty if everything matched)
|
||||
#[serde(skip_serializing_if = "String::is_empty")]
|
||||
notes: String,
|
||||
/// The listing mode used for this response (historical/buy/rent)
|
||||
listing_type: String,
|
||||
/// Number of properties matching the proposed filters (excludes travel time)
|
||||
match_count: usize,
|
||||
}
|
||||
|
|
@ -345,34 +341,19 @@ pub fn build_system_prompt(
|
|||
modes_list,
|
||||
));
|
||||
|
||||
// Listing modes section
|
||||
// Feature guidance — only historical features are available
|
||||
parts.push(
|
||||
"\n--- LISTING MODES ---\n\
|
||||
There are three listing modes that control which property data is shown:\n\
|
||||
- \"historical\": Historical sales from Land Registry (default). Uses features like \
|
||||
\"Last known price\", \"Estimated current price\", \"Price per sqm\".\n\
|
||||
- \"buy\": Properties currently listed for sale. Uses features like \"Asking price\", \
|
||||
\"Asking price per sqm\".\n\
|
||||
- \"rent\": Properties currently listed for rent. Uses features like \
|
||||
\"Asking rent (monthly)\".\n\
|
||||
"\n--- DATA SOURCE ---\n\
|
||||
The data is historical property sales from the Land Registry.\n\
|
||||
\n\
|
||||
When the user mentions buying, purchasing, for-sale properties, or asking prices, \
|
||||
set listing_type to \"buy\".\n\
|
||||
When the user mentions renting, letting, rental properties, or monthly rent, \
|
||||
set listing_type to \"rent\".\n\
|
||||
When the user doesn't specify or mentions historical prices/past sales, \
|
||||
omit listing_type to keep the current mode.\n\
|
||||
Use these features for price queries:\n\
|
||||
- For purchase price: use \"Estimated current price\" or \"Last known price\"\n\
|
||||
- For price per sqm: use \"Est. price per sqm\"\n\
|
||||
- For rent: use \"Estimated monthly rent\"\n\
|
||||
\n\
|
||||
Features marked with [mode] below are only available in that mode. \
|
||||
Features without a mode annotation work in all modes. \
|
||||
ONLY use features valid for the chosen listing_type.\n\
|
||||
If the user mentions price and the mode is \"buy\", use \"Asking price\" (not \"Last known price\").\n\
|
||||
If the user mentions rent/price and the mode is \"rent\", use \"Asking rent (monthly)\".\n\
|
||||
\n\
|
||||
Feature equivalences across modes:\n\
|
||||
- \"Estimated current price\" (historical) ↔ \"Asking price\" (buy)\n\
|
||||
- \"Est. price per sqm\" (historical) ↔ \"Asking price per sqm\" (buy)\n\
|
||||
- \"Estimated monthly rent\" (historical) ↔ \"Asking rent (monthly)\" (rent)"
|
||||
Features marked with [historical] below are available. \
|
||||
Features marked with [buy] or [rent] are NOT available — do not use them.\n\
|
||||
ONLY use features marked [historical] or unmarked."
|
||||
.to_string(),
|
||||
);
|
||||
|
||||
|
|
@ -412,7 +393,7 @@ pub fn build_system_prompt(
|
|||
description,
|
||||
..
|
||||
} => {
|
||||
// Skip Listing status — handled via listing_type field
|
||||
// Skip Listing status — auto-injected as "Historical sale"
|
||||
if name == "Listing status" {
|
||||
continue;
|
||||
}
|
||||
|
|
@ -499,11 +480,11 @@ pub fn build_system_prompt(
|
|||
.to_string(),
|
||||
);
|
||||
|
||||
// Examples showing listing mode switching
|
||||
// Examples showing rent and price features
|
||||
parts.push(
|
||||
"\nUser: \"2 bed flat to rent under £1500/month\"\n\
|
||||
Output: {\"listing_type\": \"rent\", \
|
||||
\"numeric_filters\": [{\"name\": \"Asking rent (monthly)\", \"bound\": \"max\", \"value\": 1500}], \
|
||||
"\nUser: \"2 bed flat with rent under £1500/month\"\n\
|
||||
Output: {\
|
||||
\"numeric_filters\": [{\"name\": \"Estimated monthly rent\", \"bound\": \"max\", \"value\": 1500}], \
|
||||
\"enum_filters\": [{\"name\": \"Property type\", \"values\": [\"Flats/Maisonettes\"]}], \
|
||||
\"travel_time_filters\": [], \
|
||||
\"notes\": \"\"}"
|
||||
|
|
@ -511,9 +492,9 @@ pub fn build_system_prompt(
|
|||
);
|
||||
|
||||
parts.push(
|
||||
"\nUser: \"3 bed house to buy under 500k with good schools\"\n\
|
||||
Output: {\"listing_type\": \"buy\", \
|
||||
\"numeric_filters\": [{\"name\": \"Asking price\", \"bound\": \"max\", \"value\": 500000}, \
|
||||
"\nUser: \"3 bed house under 500k with good schools\"\n\
|
||||
Output: {\
|
||||
\"numeric_filters\": [{\"name\": \"Estimated current price\", \"bound\": \"max\", \"value\": 500000}, \
|
||||
{\"name\": \"Good+ primary schools within 2km\", \"bound\": \"min\", \"value\": 2}], \
|
||||
\"enum_filters\": [{\"name\": \"Property type\", \
|
||||
\"values\": [\"Detached\", \"Semi-Detached\", \"Terraced\"]}], \
|
||||
|
|
@ -525,11 +506,9 @@ pub fn build_system_prompt(
|
|||
// Output format reminder
|
||||
parts.push(
|
||||
"\n--- OUTPUT FORMAT ---\n\
|
||||
{\"listing_type\": \"buy\"|\"rent\" (OPTIONAL — only when switching mode), \
|
||||
\"numeric_filters\": [...], \"enum_filters\": [...], \
|
||||
{\"numeric_filters\": [...], \"enum_filters\": [...], \
|
||||
\"travel_time_filters\": [{\"mode\": \"...\", \"slug\": \"...\", \"label\": \"...\", \
|
||||
\"bound\": \"min\"|\"max\", \"value\": N}, ...], \"notes\": \"...\"}\n\
|
||||
- listing_type: include only when the user explicitly wants to buy or rent. Omit to keep current mode.\n\
|
||||
- travel_time_filters: use ONLY slugs returned by search_destinations. If a place isn't found, mention it in notes.\n\
|
||||
Respond with ONLY the JSON object. No explanation."
|
||||
.to_string(),
|
||||
|
|
@ -779,17 +758,9 @@ pub async fn post_ai_filters(
|
|||
|
||||
let tools = build_tool_declarations(&state);
|
||||
|
||||
// Resolve current listing mode from request
|
||||
let current_mode = req.listing_type.as_deref().unwrap_or("historical");
|
||||
let current_mode = match current_mode {
|
||||
"historical" | "buy" | "rent" => current_mode,
|
||||
_ => "historical",
|
||||
};
|
||||
|
||||
// Build user message with listing mode and optional context for conversational refinement
|
||||
// Build user message with optional context for conversational refinement
|
||||
let user_text = if let Some(ref ctx) = req.context {
|
||||
let mut msg = String::new();
|
||||
msg.push_str(&format!("Current listing mode: {}\n", current_mode));
|
||||
msg.push_str("Currently active filters:\n");
|
||||
msg.push_str(&serde_json::to_string(&ctx.filters).unwrap_or_default());
|
||||
if !ctx.travel_time.is_empty() {
|
||||
|
|
@ -807,10 +778,7 @@ pub async fn post_ai_filters(
|
|||
msg.push_str(&format!("\nUser request: {}", req.query));
|
||||
msg
|
||||
} else {
|
||||
format!(
|
||||
"Current listing mode: {}\nUser request: {}",
|
||||
current_mode, req.query
|
||||
)
|
||||
req.query.clone()
|
||||
};
|
||||
|
||||
let mut contents = vec![json!({
|
||||
|
|
@ -967,17 +935,8 @@ pub async fn post_ai_filters(
|
|||
}
|
||||
};
|
||||
|
||||
// Resolve listing_type: LLM output > request > "historical"
|
||||
let listing_type = raw
|
||||
.get("listing_type")
|
||||
.and_then(|val| val.as_str())
|
||||
.unwrap_or(current_mode);
|
||||
let listing_type = match listing_type {
|
||||
"historical" | "buy" | "rent" => listing_type,
|
||||
_ => current_mode,
|
||||
};
|
||||
|
||||
let mut filters = validate_and_convert(&raw, &state.features_response, listing_type);
|
||||
// Only historical mode is supported — validate features accordingly
|
||||
let mut filters = validate_and_convert(&raw, &state.features_response, "historical");
|
||||
let travel_time_filters = validate_travel_time_filters(&raw, &state);
|
||||
let notes = raw
|
||||
.get("notes")
|
||||
|
|
@ -985,14 +944,12 @@ pub async fn post_ai_filters(
|
|||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
// Auto-inject Listing status filter for the chosen mode
|
||||
let listing_value = match listing_type {
|
||||
"buy" => "For sale",
|
||||
"rent" => "For rent",
|
||||
_ => "Historical sale",
|
||||
};
|
||||
// Auto-inject Listing status filter for historical mode
|
||||
if let Value::Object(ref mut map) = filters {
|
||||
map.insert("Listing status".to_string(), json!([listing_value]));
|
||||
map.insert(
|
||||
"Listing status".to_string(),
|
||||
json!(["Historical sale"]),
|
||||
);
|
||||
}
|
||||
|
||||
// Count matching properties and refine if too restrictive
|
||||
|
|
@ -1031,7 +988,6 @@ pub async fn post_ai_filters(
|
|||
filters,
|
||||
travel_time_filters,
|
||||
notes,
|
||||
listing_type: listing_type.to_string(),
|
||||
match_count: 0,
|
||||
}));
|
||||
}
|
||||
|
|
@ -1073,7 +1029,7 @@ pub async fn post_ai_filters(
|
|||
let log_state = state.clone();
|
||||
let log_user_id = user.id.clone();
|
||||
let log_query = req.query.clone();
|
||||
let log_listing_type = listing_type.to_string();
|
||||
let log_listing_type = "historical".to_string();
|
||||
let log_notes = notes.clone();
|
||||
let log_rounds = (round + 1) as u64;
|
||||
tokio::spawn(async move {
|
||||
|
|
@ -1094,7 +1050,6 @@ pub async fn post_ai_filters(
|
|||
filters,
|
||||
travel_time_filters,
|
||||
notes,
|
||||
listing_type: listing_type.to_string(),
|
||||
match_count,
|
||||
}));
|
||||
}
|
||||
|
|
|
|||
203
server-rs/src/routes/filter_counts.rs
Normal file
203
server-rs/src/routes/filter_counts.rs
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::{Query, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Json};
|
||||
use rustc_hash::FxHashMap;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::info;
|
||||
|
||||
use crate::consts::NAN_U16;
|
||||
use crate::data::travel_time::TravelData;
|
||||
use crate::parsing::{parse_filters, require_bounds};
|
||||
use crate::routes::travel_time::parse_optional_travel;
|
||||
use crate::state::SharedState;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct FilterCountsParams {
|
||||
bounds: Option<String>,
|
||||
filters: Option<String>,
|
||||
travel: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct FilterCountsResponse {
|
||||
total: u32,
|
||||
impacts: FxHashMap<String, u32>,
|
||||
}
|
||||
|
||||
pub async fn get_filter_counts(
|
||||
State(shared): State<Arc<SharedState>>,
|
||||
Query(params): Query<FilterCountsParams>,
|
||||
) -> Result<Json<FilterCountsResponse>, axum::response::Response> {
|
||||
let state = shared.load_state();
|
||||
|
||||
let (south, west, north, east) =
|
||||
require_bounds(params.bounds).map_err(IntoResponse::into_response)?;
|
||||
|
||||
let quant = state.data.quant_ref();
|
||||
let (parsed_filters, parsed_enum_filters) = parse_filters(
|
||||
params.filters.as_deref(),
|
||||
&state.feature_name_to_index,
|
||||
&state.data.enum_values,
|
||||
&quant,
|
||||
)
|
||||
.map_err(|err| (StatusCode::BAD_REQUEST, err).into_response())?;
|
||||
|
||||
let travel_entries = parse_optional_travel(params.travel.as_deref())
|
||||
.map_err(|err| (StatusCode::BAD_REQUEST, err).into_response())?;
|
||||
|
||||
let num_regular = parsed_filters.len() + parsed_enum_filters.len();
|
||||
// Only travel entries with a filter range count as filters for impact tracking
|
||||
let travel_filter_indices: Vec<usize> = travel_entries
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, e)| e.filter_min.is_some())
|
||||
.map(|(i, _)| i)
|
||||
.collect();
|
||||
let num_total_filters = num_regular + travel_filter_indices.len();
|
||||
|
||||
if num_total_filters == 0 {
|
||||
return Ok(Json(FilterCountsResponse {
|
||||
total: 0,
|
||||
impacts: FxHashMap::default(),
|
||||
}));
|
||||
}
|
||||
|
||||
let filters_str = params.filters;
|
||||
|
||||
let response = tokio::task::spawn_blocking(move || -> Result<FilterCountsResponse, String> {
|
||||
let t0 = std::time::Instant::now();
|
||||
let num_features = state.data.num_features;
|
||||
let feature_data = &state.data.feature_data;
|
||||
|
||||
// Load travel time data
|
||||
let travel_data: Vec<TravelData> = travel_entries
|
||||
.iter()
|
||||
.map(|entry| {
|
||||
state
|
||||
.travel_time_store
|
||||
.get(&entry.mode, &entry.slug)
|
||||
.map_err(|err| format!("Failed to load travel data: {}", err))
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
let has_travel = !travel_entries.is_empty();
|
||||
let (pc_interner, pc_keys) = state.data.postcode_parts();
|
||||
|
||||
let rows = state.grid.query(south, west, north, east);
|
||||
let row_count = rows.len();
|
||||
|
||||
let mut total_passing: u32 = 0;
|
||||
let mut impacts = vec![0u32; num_total_filters];
|
||||
|
||||
for row_idx in rows {
|
||||
let row = row_idx as usize;
|
||||
let base = row * num_features;
|
||||
let mut fail_count: u32 = 0;
|
||||
let mut fail_index: usize = 0;
|
||||
|
||||
// Test numeric filters
|
||||
for (i, f) in parsed_filters.iter().enumerate() {
|
||||
let raw = feature_data[base + f.feat_idx];
|
||||
if raw == NAN_U16 || raw < f.min_u16 || raw > f.max_u16 {
|
||||
fail_count += 1;
|
||||
fail_index = i;
|
||||
if fail_count > 1 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Test enum filters
|
||||
if fail_count <= 1 {
|
||||
for (i, f) in parsed_enum_filters.iter().enumerate() {
|
||||
let raw = feature_data[base + f.feat_idx];
|
||||
if raw == NAN_U16 || !f.allowed.contains(&raw) {
|
||||
fail_count += 1;
|
||||
fail_index = parsed_filters.len() + i;
|
||||
if fail_count > 1 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Test travel time filters
|
||||
if fail_count <= 1 && has_travel {
|
||||
let postcode = pc_interner.resolve(&pc_keys[row]);
|
||||
for (slot, &ti) in travel_filter_indices.iter().enumerate() {
|
||||
let entry = &travel_entries[ti];
|
||||
let minutes = travel_data[ti].get(postcode).map(|r| {
|
||||
if entry.use_best {
|
||||
r.best_minutes.unwrap_or(r.minutes)
|
||||
} else {
|
||||
r.minutes
|
||||
}
|
||||
});
|
||||
let passes = match (minutes, entry.filter_min, entry.filter_max) {
|
||||
(Some(mins), Some(fmin), Some(fmax)) => {
|
||||
(mins as f32) >= fmin && (mins as f32) <= fmax
|
||||
}
|
||||
(None, Some(_), Some(_)) => false,
|
||||
_ => true,
|
||||
};
|
||||
if !passes {
|
||||
fail_count += 1;
|
||||
fail_index = num_regular + slot;
|
||||
if fail_count > 1 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match fail_count {
|
||||
0 => total_passing += 1,
|
||||
1 => impacts[fail_index] += 1,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Map filter indices back to feature/travel names
|
||||
let mut impact_map: FxHashMap<String, u32> = FxHashMap::default();
|
||||
for (i, &count) in impacts.iter().enumerate() {
|
||||
if count == 0 {
|
||||
continue;
|
||||
}
|
||||
let name = if i < parsed_filters.len() {
|
||||
state.data.feature_names[parsed_filters[i].feat_idx].clone()
|
||||
} else if i < num_regular {
|
||||
let ei = i - parsed_filters.len();
|
||||
state.data.feature_names[parsed_enum_filters[ei].feat_idx].clone()
|
||||
} else {
|
||||
let slot = i - num_regular;
|
||||
let ti = travel_filter_indices[slot];
|
||||
let e = &travel_entries[ti];
|
||||
format!("tt_{}_{}", e.mode, e.slug)
|
||||
};
|
||||
impact_map.insert(name, count);
|
||||
}
|
||||
|
||||
let elapsed = t0.elapsed();
|
||||
info!(
|
||||
rows = row_count,
|
||||
filters = num_total_filters,
|
||||
travel = travel_filter_indices.len(),
|
||||
total = total_passing,
|
||||
filters_raw = filters_str.as_deref().unwrap_or("-"),
|
||||
ms = format_args!("{:.1}", elapsed.as_secs_f64() * 1000.0),
|
||||
"GET /api/filter-counts"
|
||||
);
|
||||
|
||||
Ok(FilterCountsResponse {
|
||||
total: total_passing,
|
||||
impacts: impact_map,
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|err| (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response())?
|
||||
.map_err(|err| (StatusCode::INTERNAL_SERVER_ERROR, err).into_response())?;
|
||||
|
||||
Ok(Json(response))
|
||||
}
|
||||
|
|
@ -144,6 +144,7 @@ fn rebuild_data(shared: &SharedState, start: Instant) -> anyhow::Result<(usize,
|
|||
poi_grid: Arc::clone(&old.poi_grid),
|
||||
place_data: Arc::clone(&old.place_data),
|
||||
postcode_data: Arc::clone(&old.postcode_data),
|
||||
outcode_data: Arc::clone(&old.outcode_data),
|
||||
poi_category_groups: Arc::clone(&old.poi_category_groups),
|
||||
travel_time_store: Arc::clone(&old.travel_time_store),
|
||||
token_cache: Arc::clone(&old.token_cache),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue