More fixes
This commit is contained in:
parent
15fa09430b
commit
6b12e21d50
54 changed files with 1665 additions and 630 deletions
|
|
@ -23,7 +23,7 @@ pub struct POIData {
|
|||
/// Byte offset into `id_buffer` where each row's ID starts.
|
||||
id_offsets: Vec<u32>,
|
||||
/// Length in bytes of each row's ID.
|
||||
id_lengths: Vec<u8>,
|
||||
id_lengths: Vec<u16>,
|
||||
pub group: InternedColumn,
|
||||
pub category: InternedColumn,
|
||||
pub name: Vec<String>,
|
||||
|
|
@ -101,7 +101,7 @@ impl POIData {
|
|||
let mut id_lengths = Vec::with_capacity(row_count);
|
||||
for s in &id_raw {
|
||||
let offset = id_buffer.len() as u32;
|
||||
let length = s.len().min(u8::MAX as usize) as u8;
|
||||
let length = s.len().min(u16::MAX as usize) as u16;
|
||||
id_offsets.push(offset);
|
||||
id_lengths.push(length);
|
||||
id_buffer.push_str(&s[..length as usize]);
|
||||
|
|
|
|||
|
|
@ -128,6 +128,7 @@ impl PostcodeData {
|
|||
// Compute centroid across all vertices from all rings
|
||||
let total_vertices: usize = rings.iter().map(|ring| ring.len()).sum();
|
||||
let centroid = if total_vertices == 0 {
|
||||
tracing::warn!(postcode = %postcode, "Postcode polygon has zero vertices, defaulting centroid to (0,0)");
|
||||
(0.0, 0.0)
|
||||
} else {
|
||||
let mut sum_lat: f32 = 0.0;
|
||||
|
|
|
|||
|
|
@ -68,9 +68,9 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
features: &[
|
||||
FeatureConfig {
|
||||
name: "Last known price",
|
||||
bounds: Bounds::Fixed {
|
||||
min: 0.0,
|
||||
max: 2_000_000.0,
|
||||
bounds: Bounds::Percentile {
|
||||
low: 0.0,
|
||||
high: 98.0,
|
||||
},
|
||||
step: 10000.0,
|
||||
description: "Most recent sale price from the Land Registry",
|
||||
|
|
@ -79,15 +79,15 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
prefix: "£",
|
||||
suffix: "",
|
||||
raw: false,
|
||||
absolute: true,
|
||||
absolute: false,
|
||||
modes: &["historical"],
|
||||
linked: "",
|
||||
},
|
||||
FeatureConfig {
|
||||
name: "Estimated current price",
|
||||
bounds: Bounds::Fixed {
|
||||
min: 0.0,
|
||||
max: 2_000_000.0,
|
||||
bounds: Bounds::Percentile {
|
||||
low: 0.0,
|
||||
high: 98.0,
|
||||
},
|
||||
step: 10000.0,
|
||||
description: "Inflation-adjusted estimate of the current property value",
|
||||
|
|
@ -96,7 +96,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
prefix: "£",
|
||||
suffix: "",
|
||||
raw: false,
|
||||
absolute: true,
|
||||
absolute: false,
|
||||
modes: &["historical"],
|
||||
linked: "Asking price",
|
||||
},
|
||||
|
|
@ -252,9 +252,9 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
},
|
||||
FeatureConfig {
|
||||
name: "Asking price",
|
||||
bounds: Bounds::Fixed {
|
||||
min: 0.0,
|
||||
max: 2_000_000.0,
|
||||
bounds: Bounds::Percentile {
|
||||
low: 0.0,
|
||||
high: 98.0,
|
||||
},
|
||||
step: 10000.0,
|
||||
description: "Listed asking price for properties currently for sale",
|
||||
|
|
@ -263,15 +263,15 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
prefix: "£",
|
||||
suffix: "",
|
||||
raw: false,
|
||||
absolute: true,
|
||||
absolute: false,
|
||||
modes: &["buy"],
|
||||
linked: "Estimated current price",
|
||||
},
|
||||
FeatureConfig {
|
||||
name: "Asking rent (monthly)",
|
||||
bounds: Bounds::Fixed {
|
||||
min: 0.0,
|
||||
max: 10_000.0,
|
||||
bounds: Bounds::Percentile {
|
||||
low: 0.0,
|
||||
high: 98.0,
|
||||
},
|
||||
step: 50.0,
|
||||
description: "Listed monthly rent for properties currently for rent",
|
||||
|
|
@ -280,7 +280,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
prefix: "£",
|
||||
suffix: "/mo",
|
||||
raw: false,
|
||||
absolute: true,
|
||||
absolute: false,
|
||||
modes: &["rent"],
|
||||
linked: "Estimated monthly rent",
|
||||
},
|
||||
|
|
@ -870,7 +870,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
min: 0.0,
|
||||
max: 100.0,
|
||||
},
|
||||
step: 1.0,
|
||||
step: 0.1,
|
||||
description: "Percentage of population identifying as South Asian",
|
||||
detail: "From the 2021 Census. Percentage of the local authority population identifying as Indian, Pakistani, Bangladeshi, or any other Asian background.",
|
||||
source: "ethnicity",
|
||||
|
|
@ -887,7 +887,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
min: 0.0,
|
||||
max: 100.0,
|
||||
},
|
||||
step: 1.0,
|
||||
step: 0.1,
|
||||
description: "Percentage of population identifying as East Asian",
|
||||
detail: "From the 2021 Census. Percentage of the local authority population identifying as Chinese.",
|
||||
source: "ethnicity",
|
||||
|
|
@ -904,7 +904,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
min: 0.0,
|
||||
max: 100.0,
|
||||
},
|
||||
step: 1.0,
|
||||
step: 0.1,
|
||||
description: "Percentage of population identifying as Black",
|
||||
detail: "From the 2021 Census. Percentage of the local authority population identifying as Black, Black British, Caribbean, or African.",
|
||||
source: "ethnicity",
|
||||
|
|
@ -921,7 +921,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
min: 0.0,
|
||||
max: 100.0,
|
||||
},
|
||||
step: 1.0,
|
||||
step: 0.1,
|
||||
description: "Percentage of population identifying as Mixed or Multiple ethnic groups",
|
||||
detail: "From the 2021 Census. Percentage of the local authority population identifying as Mixed or Multiple ethnic groups (White and Black Caribbean, White and Black African, White and Asian, or any other Mixed or Multiple background).",
|
||||
source: "ethnicity",
|
||||
|
|
@ -938,7 +938,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
min: 0.0,
|
||||
max: 100.0,
|
||||
},
|
||||
step: 1.0,
|
||||
step: 0.1,
|
||||
description: "Percentage of population identifying as Other ethnic group",
|
||||
detail: "From the 2021 Census. Percentage of the local authority population identifying as Other ethnic group (Arab or any other ethnic group not covered by the main categories).",
|
||||
source: "ethnicity",
|
||||
|
|
|
|||
|
|
@ -365,6 +365,7 @@ async fn main() -> anyhow::Result<()> {
|
|||
info!("Precomputed AI filters system prompt");
|
||||
|
||||
let token_cache = Arc::new(auth::TokenCache::new());
|
||||
let superuser_token_cache = Arc::new(pocketbase::SuperuserTokenCache::new());
|
||||
|
||||
let app_state = AppState {
|
||||
data: property_data,
|
||||
|
|
@ -392,6 +393,7 @@ async fn main() -> anyhow::Result<()> {
|
|||
gemini_model: cli.gemini_model,
|
||||
travel_time_store,
|
||||
token_cache,
|
||||
superuser_token_cache,
|
||||
ai_filters_system_prompt,
|
||||
google_maps_api_key: cli.google_maps_api_key,
|
||||
stripe_secret_key: cli.stripe_secret_key,
|
||||
|
|
|
|||
|
|
@ -65,6 +65,14 @@ pub async fn og_middleware(request: Request, next: Next) -> Response {
|
|||
format!("{}/api/screenshot?og=1&{}", state.public_url, query_string)
|
||||
};
|
||||
|
||||
let og_url = if query_string.is_empty() {
|
||||
format!("{}{}", state.public_url, path)
|
||||
} else {
|
||||
format!("{}{}?{}", state.public_url, path, query_string)
|
||||
};
|
||||
|
||||
let og_logo = format!("{}/favicon.svg", state.public_url);
|
||||
|
||||
let (og_title, og_description) = if is_invite {
|
||||
(
|
||||
"You\u{2019}re invited to Perfect Postcode",
|
||||
|
|
@ -81,6 +89,8 @@ pub async fn og_middleware(request: Request, next: Next) -> Response {
|
|||
r#"<meta property="og:title" content="{og_title}" />
|
||||
<meta property="og:description" content="{og_description}" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content="{og_url}" />
|
||||
<meta property="og:logo" content="{og_logo}" />
|
||||
<meta property="og:image" content="{og_image_url}" />
|
||||
<meta property="og:image:width" content="1200" />
|
||||
<meta property="og:image:height" content="630" />
|
||||
|
|
|
|||
|
|
@ -54,16 +54,21 @@ pub fn parse_filters(
|
|||
// Check if this is an enum feature
|
||||
if let Some(values) = enum_values.get(&feat_idx) {
|
||||
// Enum filter: convert string values to u16 indices
|
||||
let allowed: FxHashSet<u16> = rest
|
||||
.split('|')
|
||||
.filter_map(|value| {
|
||||
let value = value.trim();
|
||||
values
|
||||
.iter()
|
||||
.position(|existing| existing == value)
|
||||
.map(|position| position as u16)
|
||||
})
|
||||
.collect();
|
||||
let mut allowed: FxHashSet<u16> = FxHashSet::default();
|
||||
for value in rest.split('|') {
|
||||
let value = value.trim();
|
||||
match values.iter().position(|existing| existing == value) {
|
||||
Some(position) => {
|
||||
allowed.insert(position as u16);
|
||||
}
|
||||
None => {
|
||||
return Err(format!(
|
||||
"Unknown value '{}' for enum feature '{}'. Valid values: {:?}",
|
||||
value, name, values
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
enums.push(ParsedEnumFilter { feat_idx, allowed });
|
||||
} else {
|
||||
// Numeric filter: parse min:max and encode to u16
|
||||
|
|
@ -369,20 +374,16 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn parse_enum_with_unknown_value() {
|
||||
fn parse_enum_with_unknown_value_errors() {
|
||||
let tq = test_quant(4, 2);
|
||||
let (_numeric, enums) = parse_filters(
|
||||
let result = parse_filters(
|
||||
Some("Type:Detached|Unknown|Flats/Maisonettes"),
|
||||
&extended_feature_map(),
|
||||
&extended_enum_values(),
|
||||
&tq.as_ref(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(enums.len(), 1);
|
||||
assert!(enums[0].allowed.contains(&0)); // Detached
|
||||
assert!(enums[0].allowed.contains(&3)); // Flats/Maisonettes
|
||||
assert_eq!(enums[0].allowed.len(), 2);
|
||||
);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("Unknown value 'Unknown'"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -1,13 +1,62 @@
|
|||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use metrics::gauge;
|
||||
use parking_lot::RwLock;
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
/// Cache TTL for the superuser token. PocketBase superuser JWTs are valid for
|
||||
/// ~14 days by default, so 10 minutes is very conservative while eliminating
|
||||
/// nearly all redundant auth requests (metrics poller, newsletter, invites, etc.).
|
||||
const SUPERUSER_TOKEN_TTL_SECS: u64 = 600;
|
||||
|
||||
pub struct SuperuserTokenCache {
|
||||
token: RwLock<Option<(String, Instant)>>,
|
||||
}
|
||||
|
||||
impl SuperuserTokenCache {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
token: RwLock::new(None),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get a cached superuser token, or authenticate fresh if expired/missing.
|
||||
pub async fn get_superuser_token(state: &AppState) -> anyhow::Result<String> {
|
||||
// Check cache first (read lock — cheap, non-blocking for other readers)
|
||||
{
|
||||
let cached = state.superuser_token_cache.token.read();
|
||||
if let Some((token, created)) = cached.as_ref() {
|
||||
if created.elapsed().as_secs() < SUPERUSER_TOKEN_TTL_SECS {
|
||||
return Ok(token.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cache miss or expired — fetch a fresh token
|
||||
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?;
|
||||
|
||||
// Store in cache
|
||||
{
|
||||
let mut cached = state.superuser_token_cache.token.write();
|
||||
*cached = Some((token.clone(), Instant::now()));
|
||||
}
|
||||
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct AuthResponse {
|
||||
token: String,
|
||||
|
|
@ -775,21 +824,14 @@ pub fn start_metrics_poller(shared: Arc<crate::state::SharedState>) {
|
|||
}
|
||||
|
||||
async fn poll_pocketbase_counts(state: &AppState) {
|
||||
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!("PocketBase metrics poll auth failed: {err}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let pb_url = state.pocketbase_url.trim_end_matches('/');
|
||||
|
||||
// Simple collection counts
|
||||
for (collection, metric_name) in [
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ use crate::auth::TokenCache;
|
|||
use crate::data::{
|
||||
POICategoryGroup, POIData, PlaceData, PostcodeData, PropertyData, TravelTimeStore,
|
||||
};
|
||||
use crate::pocketbase::SuperuserTokenCache;
|
||||
use crate::routes::FeaturesResponse;
|
||||
use crate::utils::GridIndex;
|
||||
|
||||
|
|
@ -44,6 +45,8 @@ pub struct AppState {
|
|||
pub travel_time_store: Arc<TravelTimeStore>,
|
||||
/// Token validation cache (60s TTL)
|
||||
pub token_cache: Arc<TokenCache>,
|
||||
/// Cached PocketBase superuser token (10min TTL) to avoid rate-limiting
|
||||
pub superuser_token_cache: Arc<SuperuserTokenCache>,
|
||||
|
||||
// --- Config (cheap to clone) ---
|
||||
/// URL of the screenshot service (e.g. http://screenshot:8002)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue