From 9e71ed77df4dd4187d1eeb5840323ec3d5c06ec9 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sat, 7 Feb 2026 21:32:28 +0000 Subject: [PATCH] Add LLM summary --- frontend/src/hooks/useAreaSummary.ts | 108 +++++++++++++++++ server-rs/src/routes/area_summary.rs | 166 +++++++++++++++++++++++++++ server-rs/src/routes/pb_proxy.rs | 10 +- 3 files changed, 275 insertions(+), 9 deletions(-) create mode 100644 frontend/src/hooks/useAreaSummary.ts create mode 100644 server-rs/src/routes/area_summary.rs diff --git a/frontend/src/hooks/useAreaSummary.ts b/frontend/src/hooks/useAreaSummary.ts new file mode 100644 index 0000000..795ba30 --- /dev/null +++ b/frontend/src/hooks/useAreaSummary.ts @@ -0,0 +1,108 @@ +import { useState, useEffect, useCallback, useRef } from 'react'; +import type { FeatureMeta, FeatureFilters, HexagonStatsResponse } from '../types'; +import { apiUrl, authHeaders, logNonAbortError } from '../lib/api'; + +interface UseAreaSummaryOptions { + stats: HexagonStatsResponse | null; + hexagonId: string | null; + isPostcode: boolean; + filters: FeatureFilters; + features: FeatureMeta[]; +} + +interface UseAreaSummaryResult { + summary: string; + loading: boolean; + error: string | null; + retry: () => void; +} + +const FORBIDDEN_FEATURES = ['% White', '% Black', '% Asian', '% Mixed', '% Other']; + +export function useAreaSummary({ + stats, + hexagonId, + isPostcode, + filters, + features, +}: UseAreaSummaryOptions): UseAreaSummaryResult { + const [summary, setSummary] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const abortRef = useRef(null); + + const fetchSummary = useCallback(async () => { + if (!stats || !hexagonId) return; + + abortRef.current?.abort(); + const controller = new AbortController(); + abortRef.current = controller; + + setSummary(''); + setLoading(true); + setError(null); + + try { + const filterDescriptions: string[] = []; + for (const [name, value] of Object.entries(filters)) { + const meta = features.find((f) => f.name === name); + if (meta?.type === 'enum') { + filterDescriptions.push(`${name}: ${(value as string[]).join(', ')}`); + } else { + const [min, max] = value as [number, number]; + filterDescriptions.push(`${name}: ${min}–${max}`); + } + } + + const body = { + count: stats.count, + location: hexagonId, + is_postcode: isPostcode, + filters: filterDescriptions, + numeric_stats: stats.numeric_features.filter(f => !FORBIDDEN_FEATURES.includes(f.name)).map((f) => ({ + name: f.name, + mean: f.mean, + })), + enum_stats: stats.enum_features.map((f) => ({ + name: f.name, + counts: f.counts, + })), + }; + + const url = apiUrl('area-summary'); + const response = await fetch(url, authHeaders({ + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + signal: controller.signal, + })); + + if (!response.ok) { + const text = await response.text(); + throw new Error(text || `HTTP ${response.status}`); + } + + const json = await response.json(); + setSummary(json.summary || ''); + setLoading(false); + } catch (err) { + if (controller.signal.aborted) return; + logNonAbortError('area-summary', err); + setError(err instanceof Error ? err.message : 'Failed to generate summary'); + setLoading(false); + } + }, [stats, hexagonId, isPostcode, filters, features]); + + useEffect(() => { + fetchSummary(); + return () => { + abortRef.current?.abort(); + }; + }, [stats, hexagonId]); // eslint-disable-line react-hooks/exhaustive-deps + + const retry = useCallback(() => { + fetchSummary(); + }, [fetchSummary]); + + return { summary, loading, error, retry }; +} diff --git a/server-rs/src/routes/area_summary.rs b/server-rs/src/routes/area_summary.rs new file mode 100644 index 0000000..267ce38 --- /dev/null +++ b/server-rs/src/routes/area_summary.rs @@ -0,0 +1,166 @@ +use std::sync::Arc; + +use axum::http::StatusCode; +use axum::response::Json; +use serde::{Deserialize, Serialize}; +use tracing::{info, warn}; + +use crate::consts::{ + AREA_SUMMARY_MAX_TOKENS, AREA_SUMMARY_SYSTEM_PROMPT, AREA_SUMMARY_TEMPERATURE, +}; +use crate::state::AppState; + +#[derive(Deserialize)] +pub struct NumericStat { + name: String, + mean: f64, +} + +#[derive(Deserialize)] +pub struct EnumStat { + name: String, + counts: std::collections::HashMap, +} + +#[derive(Deserialize)] +pub struct AreaSummaryRequest { + count: usize, + location: String, + is_postcode: bool, + #[serde(default)] + filters: Vec, + #[serde(default)] + numeric_stats: Vec, + #[serde(default)] + enum_stats: Vec, +} + +#[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 contain {} properties matching the filters.", + area_type, req.location, req.count + )); + + if !req.filters.is_empty() { + parts.push(format!("Active filters: {}.", req.filters.join(", "))); + } + + if !req.numeric_stats.is_empty() { + let stats: Vec = req + .numeric_stats + .iter() + .map(|stat| format!("{}: {:.1}", stat.name, stat.mean)) + .collect(); + parts.push(format!("Average values: {}.", 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 = 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 +} + +/// Strip `...` blocks from model output +fn strip_think_blocks(text: &str) -> String { + let mut result = String::new(); + let mut remaining = text; + while let Some(start) = remaining.find("") { + result.push_str(&remaining[..start]); + if let Some(end) = remaining[start..].find("") { + remaining = &remaining[start + end + 8..]; + } else { + return result; + } + } + result.push_str(remaining); + result +} + +pub async fn post_area_summary( + state: Arc, + Json(req): Json, +) -> Result, (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 response = state + .http_client + .post(&url) + .json(&body) + .send() + .await + .map_err(|err| { + warn!(error = %err, "Failed to connect to Ollama"); + ( + StatusCode::BAD_GATEWAY, + format!("Failed to connect to Ollama: {}", err), + ) + })?; + + if !response.status().is_success() { + let status = response.status(); + let body_text = response.text().await.unwrap_or_default(); + warn!(status = %status, body = %body_text, "Ollama returned error"); + return Err(( + StatusCode::BAD_GATEWAY, + format!("Ollama error {}: {}", status, body_text), + )); + } + + let json: serde_json::Value = response.json().await.map_err(|err| { + warn!(error = %err, "Failed to parse Ollama response"); + ( + StatusCode::BAD_GATEWAY, + format!("Failed to parse Ollama response: {}", err), + ) + })?; + + let content = json + .get("choices") + .and_then(|ch| ch.get(0)) + .and_then(|ch| ch.get("message")) + .and_then(|msg| msg.get("content")) + .and_then(|ct| ct.as_str()) + .unwrap_or(""); + + let summary = strip_think_blocks(content).trim().to_string(); + + Ok(Json(AreaSummaryResponse { summary })) +} diff --git a/server-rs/src/routes/pb_proxy.rs b/server-rs/src/routes/pb_proxy.rs index 62787a0..a9f658c 100644 --- a/server-rs/src/routes/pb_proxy.rs +++ b/server-rs/src/routes/pb_proxy.rs @@ -9,15 +9,7 @@ use tracing::warn; use crate::state::AppState; pub async fn proxy_to_pocketbase(state: Arc, req: Request) -> impl IntoResponse { - let pb_url = match &state.pocketbase_url { - Some(url) => url.trim_end_matches('/'), - None => { - return Response::builder() - .status(StatusCode::SERVICE_UNAVAILABLE) - .body(Body::from("PocketBase not configured")) - .unwrap(); - } - }; + let pb_url = state.pocketbase_url.trim_end_matches('/'); let path = req.uri().path(); let target_path = path.strip_prefix("/pb").unwrap_or(path);