More fixes

This commit is contained in:
Andras Schmelczer 2026-03-18 22:46:08 +00:00
parent 15fa09430b
commit 6b12e21d50
54 changed files with 1665 additions and 630 deletions

View file

@ -12,7 +12,7 @@ use tracing::{info, warn};
use crate::auth::OptionalUser;
use crate::consts::{AI_FILTERS_MAX_TOKENS, AI_FILTERS_TEMPERATURE, AI_FILTERS_WEEKLY_TOKEN_LIMIT};
use crate::data::slugify;
use crate::pocketbase::auth_superuser;
use crate::pocketbase::get_superuser_token;
use crate::routes::{FeatureInfo, FeaturesResponse};
use crate::state::{AppState, SharedState};
use crate::utils::gemini_chat;
@ -37,6 +37,8 @@ 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)]
@ -58,6 +60,8 @@ 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,
}
/// Strip markdown code fences (```json ... ``` or ``` ... ```) from LLM output.
@ -268,6 +272,37 @@ pub fn build_system_prompt(
modes_list,
));
// Listing modes section
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\
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\
\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)"
.to_string(),
);
// Feature catalogue
parts.push("\n--- AVAILABLE FEATURES ---\n".to_string());
for group in &features.groups {
@ -285,11 +320,17 @@ pub fn build_system_prompt(
description,
prefix,
suffix,
modes,
..
} => {
let mode_str = if modes.is_empty() {
String::new()
} else {
format!(" [{}]", modes.join("/"))
};
parts.push(format!(
"- \"{}\" (numeric, {}{:.0}{} to {}{:.0}{}): {}",
name, prefix, min, suffix, prefix, max, suffix, description
"- \"{}\"{} (numeric, {}{:.0}{} to {}{:.0}{}): {}",
name, mode_str, prefix, min, suffix, prefix, max, suffix, description
));
}
FeatureInfo::Enum {
@ -298,6 +339,10 @@ pub fn build_system_prompt(
description,
..
} => {
// Skip Listing status — handled via listing_type field
if name == "Listing status" {
continue;
}
parts.push(format!(
"- \"{}\" (enum, values: [{}]): {}",
name,
@ -381,10 +426,37 @@ pub fn build_system_prompt(
.to_string(),
);
// Examples showing listing mode switching
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}], \
\"enum_filters\": [{\"name\": \"Property type\", \"values\": [\"Flats/Maisonettes\"]}], \
\"travel_time_filters\": [], \
\"notes\": \"\"}"
.to_string(),
);
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}, \
{\"name\": \"Good+ primary schools within 5km\", \"bound\": \"min\", \"value\": 5}], \
\"enum_filters\": [{\"name\": \"Property type\", \
\"values\": [\"Detached\", \"Semi-Detached\", \"Terraced\"]}], \
\"travel_time_filters\": [], \
\"notes\": \"\"}"
.to_string(),
);
// Output format reminder
parts.push(
"\n--- OUTPUT FORMAT ---\n\
{\"numeric_filters\": [...], \"enum_filters\": [...], \"travel_time_filters\": [{\"mode\": \"...\", \"slug\": \"...\", \"label\": \"...\", \"bound\": \"min\"|\"max\", \"value\": N}, ...], \"notes\": \"...\"}\n\
{\"listing_type\": \"buy\"|\"rent\" (OPTIONAL — only when switching mode), \
\"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(),
@ -409,19 +481,12 @@ async fn fetch_ai_usage(
state: &AppState,
user_id: &str,
) -> Result<(u64, u64), (StatusCode, String)> {
let pb_url = state.pocketbase_url.trim_end_matches('/');
let token = auth_superuser(
&state.http_client,
pb_url,
&state.pocketbase_admin_email,
&state.pocketbase_admin_password,
)
.await
.map_err(|err| {
let token = get_superuser_token(state).await.map_err(|err| {
warn!("Failed to auth superuser for AI usage check: {err}");
(StatusCode::BAD_GATEWAY, "Internal error".into())
})?;
let pb_url = state.pocketbase_url.trim_end_matches('/');
let url = format!("{pb_url}/api/collections/users/records/{user_id}");
let resp = state
.http_client
@ -460,15 +525,7 @@ async fn fetch_ai_usage(
/// Update the user's AI token usage in PocketBase.
/// Best-effort — logs warnings on failure but does not propagate errors.
async fn update_ai_usage(state: &AppState, user_id: &str, tokens_used: u64, week: u64) {
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
{
let token = match get_superuser_token(state).await {
Ok(tk) => tk,
Err(err) => {
warn!("Failed to auth superuser for AI usage update: {err}");
@ -476,6 +533,7 @@ async fn update_ai_usage(state: &AppState, user_id: &str, tokens_used: u64, week
}
};
let pb_url = state.pocketbase_url.trim_end_matches('/');
let url = format!("{pb_url}/api/collections/users/records/{user_id}");
let res = state
.http_client
@ -533,9 +591,17 @@ pub async fn post_ai_filters(
let tools = build_tool_declarations(&state);
// Build user message with optional context for conversational refinement
// 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
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() {
@ -553,7 +619,10 @@ pub async fn post_ai_filters(
msg.push_str(&format!("\nUser request: {}", req.query));
msg
} else {
req.query.clone()
format!(
"Current listing mode: {}\nUser request: {}",
current_mode, req.query
)
};
let mut contents = vec![json!({
@ -679,7 +748,17 @@ pub async fn post_ai_filters(
}
};
let filters = validate_and_convert(&raw, &state.features_response);
// 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);
let travel_time_filters = validate_travel_time_filters(&raw, &state);
let notes = raw
.get("notes")
@ -687,6 +766,16 @@ 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",
};
if let Value::Object(ref mut map) = filters {
map.insert("Listing status".to_string(), json!([listing_value]));
}
// Update usage with total accumulated tokens
let new_total = tokens_used + total_tokens_accumulated;
update_ai_usage(&state, &user.id, new_total, current_week).await;
@ -698,6 +787,7 @@ pub async fn post_ai_filters(
filters,
travel_time_filters,
notes,
listing_type: listing_type.to_string(),
}));
}
@ -787,10 +877,10 @@ fn validate_travel_time_filters(raw: &Value, state: &AppState) -> Vec<TravelTime
/// ```json
/// { "Last known price": [0, 300000], "Leasehold/Freehold": ["Freehold"] }
/// ```
fn validate_and_convert(raw: &Value, features: &FeaturesResponse) -> Value {
fn validate_and_convert(raw: &Value, features: &FeaturesResponse, listing_type: &str) -> Value {
let mut result = serde_json::Map::new();
// Build lookup maps from feature metadata
// Build lookup maps from feature metadata, filtering by listing mode
let mut numeric_features: rustc_hash::FxHashMap<&str, (f32, f32)> =
rustc_hash::FxHashMap::default();
let mut enum_features: rustc_hash::FxHashMap<&str, &[String]> =
@ -799,11 +889,23 @@ fn validate_and_convert(raw: &Value, features: &FeaturesResponse) -> Value {
for group in &features.groups {
for feature in &group.features {
match feature {
FeatureInfo::Numeric { name, min, max, .. } => {
numeric_features.insert(name, (*min, *max));
FeatureInfo::Numeric {
name,
min,
max,
modes,
..
} => {
// Only include features valid for the chosen listing mode
if modes.is_empty() || modes.contains(&listing_type) {
numeric_features.insert(name, (*min, *max));
}
}
FeatureInfo::Enum { name, values, .. } => {
enum_features.insert(name, values);
// Skip Listing status — handled via auto-injection
if name != "Listing status" {
enum_features.insert(name, values);
}
}
}
}

View file

@ -8,7 +8,7 @@ use serde::{Deserialize, Serialize};
use tracing::{info, warn};
use crate::auth::OptionalUser;
use crate::pocketbase::auth_superuser;
use crate::pocketbase::get_superuser_token;
use crate::state::{AppState, SharedState};
use super::pricing::{count_licensed_users, price_for_count};
@ -88,6 +88,8 @@ pub async fn post_checkout(
state.stripe_referral_coupon_id.clone(),
));
info!(code = %code, "Applying referral coupon to checkout");
} else {
warn!(code = %code, "Referral code validation failed, proceeding without discount");
}
}
@ -131,15 +133,9 @@ pub async fn post_checkout(
/// Grant a license by updating the user's subscription to "licensed" in PocketBase.
async fn grant_license(state: &AppState, user_id: &str) -> anyhow::Result<()> {
let pb_url = state.pocketbase_url.trim_end_matches('/');
let token = auth_superuser(
&state.http_client,
pb_url,
&state.pocketbase_admin_email,
&state.pocketbase_admin_password,
)
.await?;
let token = get_superuser_token(state).await?;
let pb_url = state.pocketbase_url.trim_end_matches('/');
let url = format!("{pb_url}/api/collections/users/records/{user_id}");
let resp = state
.http_client

View file

@ -8,7 +8,7 @@ use serde::{Deserialize, Serialize};
use tracing::{info, warn};
use crate::auth::OptionalUser;
use crate::pocketbase::auth_superuser;
use crate::pocketbase::get_superuser_token;
use crate::state::SharedState;
#[derive(Serialize)]
@ -118,14 +118,7 @@ pub async fn post_invites(
let code = generate_invite_code();
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
{
let token = match get_superuser_token(&state).await {
Ok(t) => t,
Err(err) => {
warn!("Failed to auth as PocketBase superuser: {err}");
@ -202,14 +195,7 @@ 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
{
let token = match get_superuser_token(&state).await {
Ok(t) => t,
Err(err) => {
warn!("Failed to auth as PocketBase superuser: {err}");
@ -325,14 +311,7 @@ pub async fn post_redeem_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
{
let token = match get_superuser_token(&state).await {
Ok(t) => t,
Err(err) => {
warn!("Failed to auth as PocketBase superuser: {err}");
@ -500,14 +479,7 @@ pub async fn get_invites(
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
{
let token = match get_superuser_token(&state).await {
Ok(t) => t,
Err(err) => {
warn!("Failed to auth as PocketBase superuser: {err}");

View file

@ -8,7 +8,7 @@ use serde::Deserialize;
use tracing::warn;
use crate::auth::OptionalUser;
use crate::pocketbase::auth_superuser;
use crate::pocketbase::get_superuser_token;
use crate::state::SharedState;
#[derive(Deserialize)]
@ -27,16 +27,7 @@ pub async fn patch_newsletter(
None => return StatusCode::UNAUTHORIZED.into_response(),
};
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
{
let token = match get_superuser_token(&state).await {
Ok(t) => t,
Err(err) => {
warn!("Failed to authenticate as PocketBase superuser: {err}");
@ -44,6 +35,7 @@ pub async fn patch_newsletter(
}
};
let pb_url = state.pocketbase_url.trim_end_matches('/');
let url = format!("{pb_url}/api/collections/users/records/{}", user.id);
let res = state
.http_client

View file

@ -281,7 +281,7 @@ pub async fn get_postcodes(
histogram!("postcodes_response_count").record(features.len() as f64);
let truncated = features.len() > MAX_CELLS_PER_REQUEST;
let truncated = features.len() >= MAX_CELLS_PER_REQUEST;
let t_total = t0.elapsed();
info!(
postcodes_before_filter,

View file

@ -7,7 +7,7 @@ use axum::Json;
use serde::Serialize;
use tracing::warn;
use crate::pocketbase::auth_superuser;
use crate::pocketbase::get_superuser_token;
use crate::state::{AppState, SharedState};
/// Pricing tiers: (cumulative user cap, price in pence).
@ -45,15 +45,9 @@ pub fn price_for_count(count: u64) -> u64 {
/// Count users with subscription="licensed" in PocketBase.
pub async fn count_licensed_users(state: &AppState) -> anyhow::Result<u64> {
let pb_url = state.pocketbase_url.trim_end_matches('/');
let token = auth_superuser(
&state.http_client,
pb_url,
&state.pocketbase_admin_email,
&state.pocketbase_admin_password,
)
.await?;
let token = get_superuser_token(state).await?;
let pb_url = state.pocketbase_url.trim_end_matches('/');
let filter = "subscription=\"licensed\"";
let url = format!(
"{pb_url}/api/collections/users/records?filter={}&perPage=1",

View file

@ -147,6 +147,7 @@ fn rebuild_data(shared: &SharedState, start: Instant) -> anyhow::Result<(usize,
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),
superuser_token_cache: Arc::clone(&old.superuser_token_cache),
// Config (cheap clone)
screenshot_url: old.screenshot_url.clone(),

View file

@ -8,7 +8,7 @@ use rand::Rng;
use serde::{Deserialize, Serialize};
use tracing::warn;
use crate::pocketbase::auth_superuser;
use crate::pocketbase::get_superuser_token;
use crate::state::SharedState;
const CODE_LEN: usize = 8;
@ -42,14 +42,7 @@ pub async fn post_shorten(State(shared): State<Arc<SharedState>>, Json(req): Jso
let state = shared.load_state();
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
{
let token = match get_superuser_token(&state).await {
Ok(t) => t,
Err(err) => {
warn!("PocketBase superuser auth failed: {err}");
@ -102,14 +95,7 @@ pub async fn get_short_url(State(shared): State<Arc<SharedState>>, Path(code): P
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
{
let token = match get_superuser_token(&state).await {
Ok(t) => t,
Err(err) => {
warn!("PocketBase superuser auth failed: {err}");

View file

@ -8,7 +8,7 @@ use hmac::{Hmac, Mac};
use sha2::Sha256;
use tracing::{info, warn};
use crate::pocketbase::auth_superuser;
use crate::pocketbase::get_superuser_token;
use crate::state::SharedState;
type HmacSha256 = Hmac<Sha256>;
@ -31,6 +31,19 @@ fn verify_signature(payload: &[u8], sig_header: &str, secret: &str) -> bool {
_ => return false,
};
// Reject webhooks older than 5 minutes to prevent replay attacks
if let Ok(ts_secs) = ts.parse::<i64>() {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64;
if (now - ts_secs).abs() > 300 {
return false;
}
} else {
return false;
}
// Compute expected signature: HMAC-SHA256(secret, "TIMESTAMP.PAYLOAD")
let signed_payload = format!("{ts}.{}", String::from_utf8_lossy(payload));
let mut mac = match HmacSha256::new_from_slice(secret.as_bytes()) {
@ -94,15 +107,7 @@ pub async fn post_stripe_webhook(
}
// Update user subscription to "licensed" via PocketBase superuser auth
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
{
let token = match get_superuser_token(&state).await {
Ok(t) => t,
Err(err) => {
warn!("Failed to auth as PocketBase superuser in webhook: {err}");
@ -110,6 +115,7 @@ pub async fn post_stripe_webhook(
}
};
let pb_url = state.pocketbase_url.trim_end_matches('/');
let url = format!("{pb_url}/api/collections/users/records/{user_id}");
let res = state
.http_client