Add LLM summary
This commit is contained in:
parent
f5e6894c0f
commit
9e71ed77df
3 changed files with 275 additions and 9 deletions
108
frontend/src/hooks/useAreaSummary.ts
Normal file
108
frontend/src/hooks/useAreaSummary.ts
Normal 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 };
|
||||||
|
}
|
||||||
166
server-rs/src/routes/area_summary.rs
Normal file
166
server-rs/src/routes/area_summary.rs
Normal 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 }))
|
||||||
|
}
|
||||||
|
|
@ -9,15 +9,7 @@ use tracing::warn;
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
pub async fn proxy_to_pocketbase(state: Arc<AppState>, req: Request) -> impl IntoResponse {
|
pub async fn proxy_to_pocketbase(state: Arc<AppState>, req: Request) -> impl IntoResponse {
|
||||||
let pb_url = match &state.pocketbase_url {
|
let pb_url = state.pocketbase_url.trim_end_matches('/');
|
||||||
Some(url) => url.trim_end_matches('/'),
|
|
||||||
None => {
|
|
||||||
return Response::builder()
|
|
||||||
.status(StatusCode::SERVICE_UNAVAILABLE)
|
|
||||||
.body(Body::from("PocketBase not configured"))
|
|
||||||
.unwrap();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let path = req.uri().path();
|
let path = req.uri().path();
|
||||||
let target_path = path.strip_prefix("/pb").unwrap_or(path);
|
let target_path = path.strip_prefix("/pb").unwrap_or(path);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue