This commit is contained in:
Andras Schmelczer 2026-02-18 21:22:15 +00:00
parent 524580eb25
commit ffe080adef
82 changed files with 2652 additions and 2956 deletions

View file

@ -1,118 +0,0 @@
use std::sync::Arc;
use axum::http::StatusCode;
use axum::response::Json;
use serde::{Deserialize, Serialize};
use tracing::info;
use crate::consts::{
AREA_SUMMARY_MAX_TOKENS, AREA_SUMMARY_SYSTEM_PROMPT, AREA_SUMMARY_TEMPERATURE,
};
use crate::state::AppState;
use crate::utils::{extract_openai_content, ollama_chat, strip_think_blocks};
#[derive(Deserialize)]
pub struct NumericStat {
name: String,
mean: f64,
}
#[derive(Deserialize)]
pub struct EnumStat {
name: String,
counts: std::collections::HashMap<String, u64>,
}
#[derive(Deserialize)]
pub struct AreaSummaryRequest {
count: usize,
location: String,
is_postcode: bool,
#[serde(default)]
filters: Vec<String>,
#[serde(default)]
numeric_stats: Vec<NumericStat>,
#[serde(default)]
enum_stats: Vec<EnumStat>,
}
#[derive(Serialize)]
pub struct AreaSummaryResponse {
summary: String,
}
fn build_prompt(req: &AreaSummaryRequest) -> String {
let mut parts = Vec::new();
let area_type = if req.is_postcode { "postcode" } else { "area" };
parts.push(format!(
"Summarise this {} of England which contains {} properties matching my requirements.\n",
area_type, req.count
));
if !req.filters.is_empty() {
parts.push(format!("Active filters: {}.\n", req.filters.join(", ")));
}
if !req.numeric_stats.is_empty() {
let stats: Vec<String> = req
.numeric_stats
.iter()
.map(|stat| format!("{}: {:.1}", stat.name, stat.mean))
.collect();
parts.push(format!(
"Average values of the {}: {}.",
if req.is_postcode { "postcode" } else { "area" },
stats.join(", ")
));
}
for es in &req.enum_stats {
let total: u64 = es.counts.values().sum();
if total == 0 {
continue;
}
let mut sorted: Vec<_> = es.counts.iter().collect();
sorted.sort_by(|lhs, rhs| rhs.1.cmp(lhs.1));
let top: Vec<String> = sorted
.iter()
.take(3)
.map(|(val, count)| {
let pct = **count as f64 / total as f64 * 100.0;
format!("{} ({:.0}%)", val, pct)
})
.collect();
parts.push(format!("{}: {}.", es.name, top.join(", ")));
}
let result = parts.join(" ");
info!(prompt = %result, "Built prompt for area summary");
result
}
pub async fn post_area_summary(
state: Arc<AppState>,
Json(req): Json<AreaSummaryRequest>,
) -> Result<Json<AreaSummaryResponse>, (StatusCode, String)> {
let prompt = build_prompt(&req);
info!(location = %req.location, count = req.count, "POST /api/area-summary");
let url = format!("{}/v1/chat/completions", state.ollama_url);
let body = serde_json::json!({
"model": state.ollama_model,
"messages": [
{ "role": "system", "content": AREA_SUMMARY_SYSTEM_PROMPT },
{ "role": "user", "content": prompt }
],
"stream": false,
"temperature": AREA_SUMMARY_TEMPERATURE,
"max_tokens": AREA_SUMMARY_MAX_TOKENS,
});
let json = ollama_chat(&state.http_client, &url, &body).await?;
let content = extract_openai_content(&json)?;
let summary = strip_think_blocks(content).trim().to_string();
Ok(Json(AreaSummaryResponse { summary }))
}

View file

@ -11,7 +11,7 @@ use tracing::info;
use crate::aggregation::Aggregator;
use crate::auth::OptionalUser;
use crate::consts::MAX_CELLS_PER_REQUEST;
use crate::consts::{DEMO_CENTER, DEMO_CENTER_TOLERANCE, MAX_CELLS_PER_REQUEST};
use crate::data::travel_time::TravelData;
use crate::licensing::check_license_bounds;
use crate::parsing::{
@ -190,9 +190,14 @@ pub async fn get_hexagons(
let (south, west, north, east) =
require_bounds(params.bounds).map_err(IntoResponse::into_response)?;
// Skip license check at low resolutions (≤5) — data is too aggregated to be
// commercially useful, and the homepage demo needs country-wide access.
if resolution > 5 {
// Allow the homepage demo: check if the center of the requested bounds
// is near the demo view center (52.2, -1.9).
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;
if !is_demo_view {
check_license_bounds(&user.0, (south, west, north, east))
.map_err(|(_, resp)| resp)?;
}

View file

@ -38,6 +38,10 @@ pub struct Property {
pub duration: Option<String>,
pub current_energy_rating: Option<String>,
pub potential_energy_rating: Option<String>,
pub listing_status: Option<String>,
pub listing_url: Option<String>,
pub property_sub_type: Option<String>,
pub price_qualifier: Option<String>,
// Numeric fields
pub lat: f32,
@ -48,6 +52,9 @@ pub struct Property {
#[serde(skip_serializing_if = "Vec::is_empty")]
pub renovation_history: Vec<RenovationEvent>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub listing_features: Vec<String>,
#[serde(flatten)]
pub features: FxHashMap<String, f32>,
}
@ -231,6 +238,18 @@ pub async fn get_hexagon_properties(
lat: state.data.lat[row],
lon: state.data.lon[row],
renovation_history: state.data.renovation_history(row).to_vec(),
listing_features: state.data.listing_features(row).to_vec(),
listing_status: lookup_enum_value(
feature_name_to_index,
feature_data,
num_features,
enum_values,
row,
"Listing status",
),
listing_url: state.data.listing_url(row).map(String::from),
property_sub_type: state.data.property_sub_type(row).map(String::from),
price_qualifier: state.data.price_qualifier(row).map(String::from),
features,
}
})

View file

@ -1,12 +1,16 @@
use std::sync::Arc;
use axum::http::{header, StatusCode, Uri};
use axum::http::{header, HeaderMap, StatusCode, Uri};
use axum::response::IntoResponse;
use tracing::{info, warn};
use crate::state::AppState;
pub async fn get_screenshot(state: Arc<AppState>, uri: Uri) -> impl IntoResponse {
pub async fn get_screenshot(
state: Arc<AppState>,
headers: HeaderMap,
uri: Uri,
) -> impl IntoResponse {
let screenshot_base = &state.screenshot_url;
let qs = uri
@ -16,7 +20,12 @@ pub async fn get_screenshot(state: Arc<AppState>, uri: Uri) -> impl IntoResponse
let url = format!("{screenshot_base}/screenshot{qs}");
info!("Proxying screenshot request to: {}", url);
match state.http_client.get(&url).send().await {
let mut req = state.http_client.get(&url);
if let Some(auth) = headers.get(header::AUTHORIZATION) {
req = req.header(header::AUTHORIZATION, auth);
}
match req.send().await {
Ok(resp) if resp.status().is_success() => match resp.bytes().await {
Ok(bytes) => (
StatusCode::OK,

View file

@ -8,6 +8,7 @@ use rand::Rng;
use serde::{Deserialize, Serialize};
use tracing::warn;
use crate::pocketbase::auth_superuser;
use crate::state::AppState;
const CODE_LEN: usize = 8;
@ -39,6 +40,22 @@ struct PbRecord {
pub async fn post_shorten(state: Arc<AppState>, Json(req): Json<ShortenRequest>) -> 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
{
Ok(t) => t,
Err(err) => {
warn!("PocketBase superuser auth failed: {err}");
return StatusCode::BAD_GATEWAY.into_response();
}
};
let code = generate_code();
let record = PbRecord {
@ -51,6 +68,7 @@ pub async fn post_shorten(state: Arc<AppState>, Json(req): Json<ShortenRequest>)
.post(format!(
"{pb_url}/api/collections/short_urls/records"
))
.header("Authorization", format!("Bearer {token}"))
.json(&record)
.send()
.await;
@ -79,13 +97,33 @@ pub async fn post_shorten(state: Arc<AppState>, Json(req): Json<ShortenRequest>)
pub async fn get_short_url(state: Arc<AppState>, Path(code): Path<String>) -> 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
{
Ok(t) => t,
Err(err) => {
warn!("PocketBase superuser auth failed: {err}");
return StatusCode::BAD_GATEWAY.into_response();
}
};
let filter = format!("code=\"{code}\"");
let url = format!(
"{pb_url}/api/collections/short_urls/records?filter={}&perPage=1",
urlencoding::encode(&filter)
);
let res = state.http_client.get(&url).send().await;
let res = state
.http_client
.get(&url)
.header("Authorization", format!("Bearer {token}"))
.send()
.await;
match res {
Ok(resp) if resp.status().is_success() => {