Working
This commit is contained in:
parent
14a3555cf1
commit
7e92bf112e
34 changed files with 1214437 additions and 224 deletions
|
|
@ -205,7 +205,6 @@ pub async fn post_ai_filters(
|
|||
) -> Result<Json<AiFiltersResponse>, (StatusCode, String)> {
|
||||
info!(query = %req.query, "POST /api/ai-filters");
|
||||
|
||||
// Use Ollama native API with structured output
|
||||
let url = format!("{}/api/chat", state.ollama_url);
|
||||
let body = json!({
|
||||
"model": state.ollama_model,
|
||||
|
|
@ -221,29 +220,65 @@ pub async fn post_ai_filters(
|
|||
}
|
||||
});
|
||||
|
||||
let json_resp = ollama_chat(&state.http_client, &url, &body).await?;
|
||||
// Try up to 2 attempts — LLMs occasionally return empty content (e.g. only
|
||||
// <think> blocks with no JSON output), which is transient and usually
|
||||
// succeeds on retry.
|
||||
let mut last_err = None;
|
||||
for attempt in 0..2 {
|
||||
let raw = call_ollama_and_parse(&state.http_client, &url, &body).await;
|
||||
match raw {
|
||||
Ok(raw) => {
|
||||
let filters = validate_and_convert(&raw, &state.features_response);
|
||||
let notes = raw
|
||||
.get("notes")
|
||||
.and_then(|val| val.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
return Ok(Json(AiFiltersResponse { filters, notes }));
|
||||
}
|
||||
Err(err) => {
|
||||
if attempt == 0 {
|
||||
warn!("LLM attempt 1 failed, retrying: {}", err.1);
|
||||
}
|
||||
last_err = Some(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(last_err.unwrap())
|
||||
}
|
||||
|
||||
/// Call Ollama and parse the response content as JSON.
|
||||
///
|
||||
/// Returns an error if: the HTTP call fails, the response is malformed,
|
||||
/// the content is empty after stripping think blocks, or the content is
|
||||
/// not valid JSON.
|
||||
async fn call_ollama_and_parse(
|
||||
client: &reqwest::Client,
|
||||
url: &str,
|
||||
body: &Value,
|
||||
) -> Result<Value, (StatusCode, String)> {
|
||||
let json_resp = ollama_chat(client, url, body).await?;
|
||||
let content = extract_ollama_content(&json_resp)?;
|
||||
|
||||
let content = strip_think_blocks(content);
|
||||
let content = content.trim();
|
||||
|
||||
let raw: Value = serde_json::from_str(content).map_err(|err| {
|
||||
if content.is_empty() {
|
||||
warn!("LLM returned empty content after stripping think blocks");
|
||||
return Err((
|
||||
StatusCode::BAD_GATEWAY,
|
||||
"LLM returned empty content (no JSON output)".into(),
|
||||
));
|
||||
}
|
||||
|
||||
serde_json::from_str(content).map_err(|err| {
|
||||
warn!(error = %err, content = %content, "Failed to parse LLM JSON output");
|
||||
(
|
||||
StatusCode::BAD_GATEWAY,
|
||||
format!("Failed to parse LLM output as JSON: {}", err),
|
||||
)
|
||||
})?;
|
||||
|
||||
// Validate and convert to FeatureFilters format
|
||||
let filters = validate_and_convert(&raw, &state.features_response);
|
||||
let notes = raw
|
||||
.get("notes")
|
||||
.and_then(|val| val.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
Ok(Json(AiFiltersResponse { filters, notes }))
|
||||
})
|
||||
}
|
||||
|
||||
/// Validate LLM output against feature metadata and convert to FeatureFilters format.
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ use tracing::info;
|
|||
|
||||
use crate::aggregation::Aggregator;
|
||||
use crate::auth::OptionalUser;
|
||||
use crate::consts::{DEMO_CENTER, DEMO_CENTER_TOLERANCE, MAX_CELLS_PER_REQUEST};
|
||||
use crate::consts::{DEMO_BOUNDS, MAX_CELLS_PER_REQUEST};
|
||||
use crate::data::travel_time::TravelData;
|
||||
use crate::licensing::check_license_bounds;
|
||||
use crate::parsing::{
|
||||
|
|
@ -139,11 +139,7 @@ pub async fn get_hexagons(
|
|||
let (south, west, north, east) =
|
||||
require_bounds(params.bounds).map_err(IntoResponse::into_response)?;
|
||||
|
||||
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;
|
||||
|
||||
let is_demo_view = (south, west, north, east) == DEMO_BOUNDS;
|
||||
if !is_demo_view {
|
||||
check_license_bounds(&user.0, (south, west, north, east))
|
||||
.map_err(|(_, resp)| resp)?;
|
||||
|
|
|
|||
|
|
@ -159,13 +159,26 @@ pub async fn get_invite(
|
|||
}
|
||||
|
||||
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!("Failed to auth as PocketBase superuser: {err}");
|
||||
return StatusCode::BAD_GATEWAY.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let filter = format!("code=\"{}\"", code);
|
||||
let url = format!(
|
||||
"{pb_url}/api/collections/invites/records?filter={}&perPage=1",
|
||||
urlencoding::encode(&filter)
|
||||
);
|
||||
|
||||
let res = match state.http_client.get(&url).send().await {
|
||||
let res = match state.http_client.get(&url)
|
||||
.header("Authorization", format!("Bearer {token}"))
|
||||
.send().await
|
||||
{
|
||||
Ok(r) => r,
|
||||
Err(err) => {
|
||||
warn!("Failed to look up invite: {err}");
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ use crate::consts::{DEFAULT_PROPERTIES_LIMIT, MAX_PROPERTIES_LIMIT, POSTCODE_SEA
|
|||
use crate::licensing::check_license_point;
|
||||
use crate::parsing::{parse_filters, row_passes_filters};
|
||||
use crate::state::AppState;
|
||||
use crate::utils::normalize_postcode;
|
||||
|
||||
use super::properties::{HexagonPropertiesResponse, Property};
|
||||
|
||||
|
|
@ -28,12 +29,7 @@ pub async fn get_postcode_properties(
|
|||
Extension(user): Extension<OptionalUser>,
|
||||
Query(params): Query<PostcodePropertiesParams>,
|
||||
) -> Result<Json<HexagonPropertiesResponse>, axum::response::Response> {
|
||||
let normalized = params
|
||||
.postcode
|
||||
.to_uppercase()
|
||||
.split_whitespace()
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
let normalized = normalize_postcode(¶ms.postcode);
|
||||
|
||||
let pc_idx = match state.postcode_data.postcode_to_idx.get(&normalized) {
|
||||
Some(&idx) => idx,
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ use crate::consts::POSTCODE_SEARCH_OFFSET;
|
|||
use crate::licensing::check_license_point;
|
||||
use crate::parsing::{parse_field_set, parse_filters, row_passes_filters};
|
||||
use crate::state::AppState;
|
||||
use crate::utils::normalize_postcode;
|
||||
|
||||
use super::hexagon_stats::HexagonStatsResponse;
|
||||
use super::stats;
|
||||
|
|
@ -30,13 +31,7 @@ pub async fn get_postcode_stats(
|
|||
Extension(user): Extension<OptionalUser>,
|
||||
Query(params): Query<PostcodeStatsParams>,
|
||||
) -> Result<Json<HexagonStatsResponse>, axum::response::Response> {
|
||||
// Normalize postcode: uppercase, collapse whitespace
|
||||
let normalized = params
|
||||
.postcode
|
||||
.to_uppercase()
|
||||
.split_whitespace()
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
let normalized = normalize_postcode(¶ms.postcode);
|
||||
|
||||
// Look up postcode centroid for spatial search
|
||||
let pc_idx = match state.postcode_data.postcode_to_idx.get(&normalized) {
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ use crate::parsing::{
|
|||
};
|
||||
use crate::routes::travel_time::{parse_travel_entries, TravelTimeAgg};
|
||||
use crate::state::AppState;
|
||||
use crate::utils::normalize_postcode;
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct PostcodesResponse {
|
||||
|
|
@ -361,12 +362,7 @@ pub async fn get_postcode_lookup(
|
|||
state: Arc<AppState>,
|
||||
Path(postcode): Path<String>,
|
||||
) -> Result<Json<Value>, StatusCode> {
|
||||
// Normalize the postcode: uppercase, remove extra spaces, ensure single space
|
||||
let normalized = postcode
|
||||
.to_uppercase()
|
||||
.split_whitespace()
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
let normalized = normalize_postcode(&postcode);
|
||||
|
||||
let postcode_data = &state.postcode_data;
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ use std::sync::Arc;
|
|||
use axum::extract::Query;
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::Json;
|
||||
use rustc_hash::FxHashSet;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::info;
|
||||
|
||||
|
|
@ -62,6 +63,11 @@ pub async fn get_travel_destinations(
|
|||
// Sort: type rank asc, population desc, name length asc
|
||||
matches.sort_unstable_by(|a, b| a.2.cmp(&b.2).then(b.3.cmp(&a.3)).then(a.4.cmp(&b.4)));
|
||||
|
||||
// Deduplicate by slug — multiple places can share a name/slug
|
||||
// (e.g. "Richmond" as city + suburb), keep the best-ranked one
|
||||
let mut seen_slugs = FxHashSet::default();
|
||||
matches.retain(|(_, slug, ..)| seen_slugs.insert(slug.clone()));
|
||||
|
||||
let results: Vec<DestinationResult> = matches
|
||||
.into_iter()
|
||||
.map(|(idx, slug, ..)| DestinationResult {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue