Last night

This commit is contained in:
Andras Schmelczer 2026-02-08 10:21:37 +00:00
parent 2906b01734
commit 42ee2d4c51
47 changed files with 848 additions and 478 deletions

View file

@ -45,12 +45,12 @@ fn build_prompt(req: &AreaSummaryRequest) -> String {
let area_type = if req.is_postcode { "postcode" } else { "area" };
parts.push(format!(
"Summarise this {} of England ({}) which contain {} properties matching the filters.",
area_type, req.location, req.count
"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: {}.", req.filters.join(", ")));
parts.push(format!("Active filters: {}.\n", req.filters.join(", ")));
}
if !req.numeric_stats.is_empty() {
@ -59,7 +59,11 @@ fn build_prompt(req: &AreaSummaryRequest) -> String {
.iter()
.map(|stat| format!("{}: {:.1}", stat.name, stat.mean))
.collect();
parts.push(format!("Average values: {}.", stats.join(", ")));
parts.push(format!(
"Average values of the {}: {}.",
if req.is_postcode { "postcode" } else { "area" },
stats.join(", ")
));
}
for es in &req.enum_stats {

View file

@ -93,13 +93,13 @@ fn extract_filter_feature_names(filters_str: Option<&str>) -> Vec<String> {
names
}
/// Fetch the OG screenshot image from the sidecar service.
async fn fetch_og_image(
/// Fetch a screenshot image from the screenshot service for Excel export.
async fn fetch_screenshot(
state: &AppState,
view_param: &str,
filters_str: Option<&str>,
) -> Option<Vec<u8>> {
let sidecar_url = state.og_sidecar_url.as_deref()?;
let screenshot_base = &state.screenshot_url;
let mut params = vec![format!("v={}", urlencoding::encode(view_param))];
if let Some(fs) = filters_str {
@ -107,25 +107,25 @@ async fn fetch_og_image(
params.push(format!("f={}", urlencoding::encode(fs)));
}
}
let url = format!("{}/screenshot?{}", sidecar_url, params.join("&"));
let url = format!("{}/screenshot?{}", screenshot_base, params.join("&"));
match state.http_client.get(&url).send().await {
Ok(resp) if resp.status().is_success() => match resp.bytes().await {
Ok(bytes) => {
info!(bytes = bytes.len(), "Fetched OG image for export");
info!(bytes = bytes.len(), "Fetched screenshot for export");
Some(bytes.to_vec())
}
Err(err) => {
warn!("Failed to read OG sidecar response for export: {err}");
warn!("Failed to read screenshot response for export: {err}");
None
}
},
Ok(resp) => {
warn!(status = %resp.status(), "OG sidecar returned error for export");
warn!(status = %resp.status(), "Screenshot service returned error for export");
None
}
Err(err) => {
warn!("Failed to reach OG sidecar for export: {err}");
warn!("Failed to reach screenshot service for export: {err}");
None
}
}
@ -163,8 +163,8 @@ pub async fn get_export(
};
let view_param = format!("{:.4},{:.4},{:.1}", center_lat, center_lon, zoom);
// Fetch OG image from sidecar (async, before spawn_blocking)
let og_image_bytes = fetch_og_image(&state, &view_param, filters_str.as_deref()).await;
// Fetch screenshot (async, before spawn_blocking)
let og_image_bytes = fetch_screenshot(&state, &view_param, filters_str.as_deref()).await;
// Build feature name → description map from the precomputed features response
let feature_descriptions: FxHashMap<String, String> = state

View file

@ -38,6 +38,8 @@ struct CellAgg {
count: u32,
mins: Box<[f32]>,
maxs: Box<[f32]>,
sums: Box<[f64]>,
feat_counts: Box<[u32]>,
}
impl CellAgg {
@ -46,6 +48,8 @@ impl CellAgg {
count: 0,
mins: vec![f32::INFINITY; num_features].into_boxed_slice(),
maxs: vec![f32::NEG_INFINITY; num_features].into_boxed_slice(),
sums: vec![0.0f64; num_features].into_boxed_slice(),
feat_counts: vec![0u32; num_features].into_boxed_slice(),
}
}
@ -65,6 +69,8 @@ impl CellAgg {
if value > self.maxs[feat_index] {
self.maxs[feat_index] = value;
}
self.sums[feat_index] += value as f64;
self.feat_counts[feat_index] += 1;
}
}
}
@ -89,6 +95,8 @@ impl CellAgg {
if value > self.maxs[feat_index] {
self.maxs[feat_index] = value;
}
self.sums[feat_index] += value as f64;
self.feat_counts[feat_index] += 1;
}
}
}
@ -99,6 +107,7 @@ fn build_feature_maps(
groups: &FxHashMap<u64, CellAgg>,
min_keys: &[String],
max_keys: &[String],
avg_keys: &[String],
num_features: usize,
indices: Option<&[usize]>,
query_bounds: (f64, f64, f64, f64), // (south, west, north, east)
@ -122,6 +131,14 @@ fn build_feature_maps(
let mut map = Map::new();
map.insert("h3".into(), Value::String(cell.to_string()));
map.insert("count".into(), Value::Number(aggregation.count.into()));
let center: h3o::LatLng = cell.into();
if let (Some(lat), Some(lon)) = (
serde_json::Number::from_f64(center.lat()),
serde_json::Number::from_f64(center.lng()),
) {
map.insert("lat".into(), Value::Number(lat));
map.insert("lon".into(), Value::Number(lon));
}
let iter: Box<dyn Iterator<Item = usize>> = if let Some(idx) = indices {
Box::new(idx.iter().copied())
@ -130,14 +147,16 @@ fn build_feature_maps(
};
for feat_index in iter {
if aggregation.mins[feat_index].is_finite() && aggregation.maxs[feat_index].is_finite()
{
if let (Some(min_num), Some(max_num)) = (
if aggregation.feat_counts[feat_index] > 0 {
let avg = aggregation.sums[feat_index] / aggregation.feat_counts[feat_index] as f64;
if let (Some(min_num), Some(max_num), Some(avg_num)) = (
serde_json::Number::from_f64(aggregation.mins[feat_index] as f64),
serde_json::Number::from_f64(aggregation.maxs[feat_index] as f64),
serde_json::Number::from_f64(avg),
) {
map.insert(min_keys[feat_index].clone(), Value::Number(min_num));
map.insert(max_keys[feat_index].clone(), Value::Number(max_num));
map.insert(avg_keys[feat_index].clone(), Value::Number(avg_num));
}
}
}
@ -208,6 +227,7 @@ pub async fn get_hexagons(
let feature_data = &state.data.feature_data;
let min_keys = &state.min_keys;
let max_keys = &state.max_keys;
let avg_keys = &state.avg_keys;
let h3_res = h3o::Resolution::try_from(resolution)
.map_err(|error| format!("Invalid H3 resolution {}: {}", resolution, error))?;
@ -277,6 +297,7 @@ pub async fn get_hexagons(
&groups,
min_keys,
max_keys,
avg_keys,
num_features,
field_indices.as_deref(),
(south, west, north, east),

View file

@ -15,20 +15,20 @@ pub struct OgImageQuery {
filters: Option<String>,
poi: Option<String>,
tab: Option<String>,
/// When "1", renders the OG heading overlay on the screenshot
og: Option<String>,
}
pub async fn get_og_image(
state: Arc<AppState>,
Query(query): Query<OgImageQuery>,
) -> impl IntoResponse {
let sidecar_url = match &state.og_sidecar_url {
Some(url) => url,
None => {
return (StatusCode::SERVICE_UNAVAILABLE, "OG sidecar not configured").into_response();
}
};
let screenshot_base = &state.screenshot_url;
let mut params = Vec::new();
if query.og.as_deref() == Some("1") {
params.push("og=1".to_string());
}
if let Some(ref val) = query.view {
params.push(format!("v={}", urlencoding::encode(val)));
}
@ -47,9 +47,8 @@ pub async fn get_og_image(
} else {
format!("?{}", params.join("&"))
};
let url = format!("{}/screenshot{}", sidecar_url, qs);
info!("Proxying OG screenshot request to: {}", url);
let url = format!("{}/screenshot{}", screenshot_base, qs);
info!("Proxying screenshot request to: {}", url);
match state.http_client.get(&url).send().await {
Ok(resp) if resp.status().is_success() => match resp.bytes().await {
@ -63,19 +62,19 @@ pub async fn get_og_image(
)
.into_response(),
Err(err) => {
warn!("Failed to read sidecar response: {}", err);
warn!("Failed to read screenshot response: {}", err);
(StatusCode::BAD_GATEWAY, "Failed to read screenshot").into_response()
}
},
Ok(resp) => {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
warn!("Sidecar returned status {}: {}", status, body);
(StatusCode::BAD_GATEWAY, "Screenshot sidecar error").into_response()
warn!("Screenshot service returned status {}: {}", status, body);
(StatusCode::BAD_GATEWAY, "Screenshot service error").into_response()
}
Err(err) => {
warn!("Failed to reach sidecar: {}", err);
(StatusCode::BAD_GATEWAY, "Screenshot sidecar unavailable").into_response()
warn!("Failed to reach screenshot service: {}", err);
(StatusCode::BAD_GATEWAY, "Screenshot service unavailable").into_response()
}
}
}

View file

@ -31,6 +31,8 @@ struct PostcodeAgg {
count: u32,
mins: Box<[f32]>,
maxs: Box<[f32]>,
sums: Box<[f64]>,
feat_counts: Box<[u32]>,
}
impl PostcodeAgg {
@ -39,6 +41,8 @@ impl PostcodeAgg {
count: 0,
mins: vec![f32::INFINITY; num_features].into_boxed_slice(),
maxs: vec![f32::NEG_INFINITY; num_features].into_boxed_slice(),
sums: vec![0.0f64; num_features].into_boxed_slice(),
feat_counts: vec![0u32; num_features].into_boxed_slice(),
}
}
@ -55,6 +59,8 @@ impl PostcodeAgg {
if value > self.maxs[feat_index] {
self.maxs[feat_index] = value;
}
self.sums[feat_index] += value as f64;
self.feat_counts[feat_index] += 1;
}
}
}
@ -78,6 +84,8 @@ impl PostcodeAgg {
if value > self.maxs[feat_index] {
self.maxs[feat_index] = value;
}
self.sums[feat_index] += value as f64;
self.feat_counts[feat_index] += 1;
}
}
}
@ -127,6 +135,7 @@ pub async fn get_postcodes(
let feature_data = &state.data.feature_data;
let min_keys = &state.min_keys;
let max_keys = &state.max_keys;
let avg_keys = &state.avg_keys;
let has_selective = field_indices.is_some();
let sel_indices = field_indices.as_deref().unwrap_or(&[]);
@ -272,15 +281,16 @@ pub async fn get_postcodes(
};
for feat_index in iter {
if aggregation.mins[feat_index].is_finite()
&& aggregation.maxs[feat_index].is_finite()
{
if let (Some(min_num), Some(max_num)) = (
if aggregation.feat_counts[feat_index] > 0 {
let avg = aggregation.sums[feat_index] / aggregation.feat_counts[feat_index] as f64;
if let (Some(min_num), Some(max_num), Some(avg_num)) = (
serde_json::Number::from_f64(aggregation.mins[feat_index] as f64),
serde_json::Number::from_f64(aggregation.maxs[feat_index] as f64),
serde_json::Number::from_f64(avg),
) {
props.insert(min_keys[feat_index].clone(), Value::Number(min_num));
props.insert(max_keys[feat_index].clone(), Value::Number(max_num));
props.insert(avg_keys[feat_index].clone(), Value::Number(avg_num));
}
}
}