This commit is contained in:
Andras Schmelczer 2026-02-14 12:53:29 +00:00
parent 3a3f899ea2
commit 128b3191e7
68 changed files with 28060 additions and 1152 deletions

View file

@ -6,6 +6,7 @@ mod features;
mod metrics;
mod og_middleware;
pub mod parsing;
mod pocketbase;
mod routes;
mod state;
pub mod utils;
@ -23,7 +24,7 @@ use tower_http::compression::CompressionLayer;
use tower_http::cors::{Any, CorsLayer};
use tower_http::services::{ServeDir, ServeFile};
use tower_http::trace::TraceLayer;
use tracing::{info, warn};
use tracing::info;
use tracing_subscriber::EnvFilter;
use state::AppState;
@ -39,6 +40,10 @@ struct Cli {
#[arg(long)]
pois: PathBuf,
/// Path to the places parquet file
#[arg(long)]
places: PathBuf,
/// Path to the postcode boundaries directory
#[arg(long)]
postcodes: PathBuf,
@ -56,28 +61,36 @@ struct Cli {
screenshot_url: String,
/// Public-facing URL for absolute og:image URLs
#[arg(
long,
env = "PUBLIC_URL",
default_value = "https://perfectpostcodes.schmelczer.dev"
)]
#[arg(long, env = "PUBLIC_URL")]
public_url: String,
/// PocketBase server URL for authentication (e.g. http://localhost:8090)
#[arg(long, env = "POCKETBASE_URL")]
pocketbase_url: String,
/// PocketBase superuser email (for auto-creating collections at startup)
#[arg(long, env = "POCKETBASE_ADMIN_EMAIL")]
pocketbase_admin_email: Option<String>,
/// PocketBase superuser password (for auto-creating collections at startup)
#[arg(long, env = "POCKETBASE_ADMIN_PASSWORD")]
pocketbase_admin_password: Option<String>,
/// Ollama server URL for AI area summaries (e.g. http://ollama:11434)
#[arg(long, env = "OLLAMA_URL")]
ollama_url: String,
/// Ollama model name for area summaries
#[arg(long, env = "OLLAMA_MODEL", default_value = "gemma3:12b")]
#[arg(long, env = "OLLAMA_MODEL")]
ollama_model: String,
/// R5 routing service URL for real-time travel times (e.g. http://r5:8003)
#[arg(long, env = "R5_URL", default_value = "")]
r5_url: String,
/// R5 routing service URL for all travel times (e.g. http://r5:8003)
#[arg(long, env = "R5_URL")]
r5_url: Option<String>,
/// Google Maps API key for Street View metadata lookups
#[arg(long, env = "GOOGLE_MAPS_API_KEY")]
google_maps_api_key: String,
}
#[tokio::main]
@ -138,6 +151,15 @@ async fn main() -> anyhow::Result<()> {
info!("Building POI spatial grid index");
let poi_grid = utils::GridIndex::build(&poi_data.lat, &poi_data.lng, consts::GRID_CELL_SIZE);
// Load place data
let places_path = &cli.places;
if !places_path.exists() {
bail!("Places parquet file not found: {}", places_path.display());
}
info!("Loading place data from {}", places_path.display());
let place_data = data::PlaceData::load(places_path)?;
info!(places = place_data.name.len(), "Place data loaded");
// Load postcode boundaries
let postcodes_path = &cli.postcodes;
if !postcodes_path.exists() {
@ -191,26 +213,15 @@ async fn main() -> anyhow::Result<()> {
let poi_category_groups = poi_data.category_groups()?;
// Read index.html at startup for crawler OG injection
let frontend_dist = cli
.dist
.unwrap_or_else(|| PathBuf::from("frontend/dist"));
let index_html = {
let index_path = frontend_dist.join("index.html");
match std::fs::read_to_string(&index_path) {
Ok(html) => {
info!("Loaded index.html for OG injection");
Some(html)
}
Err(err) => {
warn!(
"Could not read {}: {} (OG injection disabled)",
index_path.display(),
err
);
None
}
}
let (frontend_dist, index_html) = if let Some(dist) = cli.dist {
let index_path = dist.join("index.html");
let html = std::fs::read_to_string(&index_path)
.with_context(|| format!("Failed to read {}", index_path.display()))?;
info!("Loaded index.html for OG injection");
(Some(dist), Some(html))
} else {
info!("No --dist provided, static serving and OG injection disabled");
(None, None)
};
let http_client = reqwest::Client::new();
@ -223,6 +234,10 @@ async fn main() -> anyhow::Result<()> {
"Precomputed features response"
);
let ai_filters_schema = routes::build_ollama_schema(&features_response);
let ai_filters_system_prompt = routes::build_system_prompt(&features_response);
info!("Precomputed AI filters schema and system prompt");
// Record data loading metrics
metrics::record_data_stats(
property_data.lat.len(),
@ -231,12 +246,21 @@ async fn main() -> anyhow::Result<()> {
);
info!("PocketBase configured: {}", cli.pocketbase_url);
if let (Some(ref email), Some(ref password)) =
(&cli.pocketbase_admin_email, &cli.pocketbase_admin_password)
{
pocketbase::ensure_collections(&http_client, &cli.pocketbase_url, email, password).await?;
} else {
info!("PocketBase admin credentials not set — skipping collection auto-creation");
}
info!(
"Ollama configured: {} (model: {})",
cli.ollama_url, cli.ollama_model
);
if !cli.r5_url.is_empty() {
info!("R5 routing service configured: {}", cli.r5_url);
if let Some(ref url) = cli.r5_url {
info!("R5 routing service configured: {}", url);
} else {
info!("R5 routing service not configured (travel time queries disabled)");
}
@ -249,6 +273,7 @@ async fn main() -> anyhow::Result<()> {
h3_cells,
poi_data,
poi_grid,
place_data,
postcode_data,
feature_name_to_index,
min_keys,
@ -265,6 +290,9 @@ async fn main() -> anyhow::Result<()> {
ollama_model: cli.ollama_model,
r5_url: cli.r5_url,
token_cache,
ai_filters_schema,
ai_filters_system_prompt,
google_maps_api_key: cli.google_maps_api_key,
});
let cors = CorsLayer::new()
@ -286,8 +314,11 @@ async fn main() -> anyhow::Result<()> {
let state_pb = state.clone();
let state_postcode_stats = state.clone();
let state_area_summary = state.clone();
let state_places = state.clone();
let state_shorten = state.clone();
let state_short_url = state.clone();
let state_ai_filters = state.clone();
let state_streetview = state.clone();
let api = Router::new()
.route(
@ -314,6 +345,10 @@ async fn main() -> anyhow::Result<()> {
"/api/poi-categories",
get(move || routes::get_poi_categories(state_poi_categories.clone())),
)
.route(
"/api/places",
get(move |query| routes::get_places(state_places.clone(), query)),
)
.route(
"/api/hexagon-properties",
get(move |query| {
@ -345,6 +380,14 @@ async fn main() -> anyhow::Result<()> {
"/api/shorten",
post(move |body| routes::post_shorten(state_shorten.clone(), body)),
)
.route(
"/api/ai-filters",
post(move |body| routes::post_ai_filters(state_ai_filters.clone(), body)),
)
.route(
"/api/streetview",
get(move |query| routes::get_streetview(state_streetview.clone(), query)),
)
.route(
"/s/{code}",
get(move |path| routes::get_short_url(state_short_url.clone(), path)),
@ -364,6 +407,7 @@ async fn main() -> anyhow::Result<()> {
routes::get_style(axum::extract::State(reader_style.clone()), headers, query)
}),
)
.route("/health", get(|| async { "ok" }))
.route(
"/metrics",
get(move || metrics::metrics_handler(metrics_handle.clone())),
@ -373,10 +417,9 @@ async fn main() -> anyhow::Result<()> {
any(move |req| routes::proxy_to_pocketbase(state_pb.clone(), req)),
);
let app = if frontend_dist.exists() {
let app = if let Some(ref dist) = frontend_dist {
api.fallback_service(
ServeDir::new(&frontend_dist)
.not_found_service(ServeFile::new(frontend_dist.join("index.html"))),
ServeDir::new(dist).not_found_service(ServeFile::new(dist.join("index.html"))),
)
} else {
api