Add LLM summary

This commit is contained in:
Andras Schmelczer 2026-02-07 21:32:28 +00:00
parent f5e6894c0f
commit 9e71ed77df
3 changed files with 275 additions and 9 deletions

View file

@ -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<string | null>(null);
const abortRef = useRef<AbortController | null>(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 };
}

View file

@ -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<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 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<String> = 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<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
}
/// Strip `<think>...</think>` 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("<think>") {
result.push_str(&remaining[..start]);
if let Some(end) = remaining[start..].find("</think>") {
remaining = &remaining[start + end + 8..];
} else {
return result;
}
}
result.push_str(remaining);
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 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 }))
}

View file

@ -9,15 +9,7 @@ use tracing::warn;
use crate::state::AppState;
pub async fn proxy_to_pocketbase(state: Arc<AppState>, 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);