From 606c1dff53127d505cab0411972cccdb4837e1e2 Mon Sep 17 00:00:00 2001 From: Christbru Date: Sun, 19 Oct 2025 09:40:59 -0500 Subject: [PATCH] Prep worker management corrections and debug --- docker-compose.prod.yml | 2 + docker-compose.yml | 2 + rust-engine/README.md | 13 +++- rust-engine/src/api.rs | 117 ++++++++++++++++++++++++------- rust-engine/src/file_worker.rs | 43 +++++++----- rust-engine/src/gemini_client.rs | 16 +++-- rust-engine/src/main.rs | 24 ++++--- rust-engine/src/models.rs | 6 +- rust-engine/src/vector_db.rs | 18 +++-- rust-engine/src/worker.rs | 69 ++++++++++++------ web-app/server.mjs | 4 ++ 11 files changed, 225 insertions(+), 89 deletions(-) diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index b055e2d..0da1524 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -8,6 +8,8 @@ services: - DATABASE_URL=mysql://${MYSQL_USER}:${MYSQL_PASSWORD}@mysql:3306/${MYSQL_DATABASE} - RUST_ENGINE_URL=http://rust-engine:8000 - GEMINI_API_KEY=${GEMINI_API_KEY} + volumes: + - rust-storage:/app/storage:ro depends_on: - mysql - rust-engine diff --git a/docker-compose.yml b/docker-compose.yml index 78e4c61..393e5d9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,6 +13,8 @@ services: - DATABASE_URL=mysql://${MYSQL_USER}:${MYSQL_PASSWORD}@mysql:3306/${MYSQL_DATABASE} - RUST_ENGINE_URL=http://rust-engine:8000 - GEMINI_API_KEY=${GEMINI_API_KEY} + volumes: + - rust-storage:/app/storage:ro depends_on: - mysql # <-- Updated dependency - rust-engine diff --git a/rust-engine/README.md b/rust-engine/README.md index 1f815c4..037ee2a 100644 --- a/rust-engine/README.md +++ b/rust-engine/README.md @@ -11,6 +11,8 @@ - DATABASE_URL: mysql://USER:PASS@HOST:3306/DB - QDRANT_URL: default - GEMINI_API_KEY: used for Gemini content generation (optional in demo) +- DEMO_DATA_DIR: path to the folder containing PDF demo data (default resolves to `demo-data` under the repo or `/app/demo-data` in containers) +- ASTRA_STORAGE: directory for uploaded file blobs (default `/app/storage`) ## Endpoints (JSON) @@ -19,7 +21,12 @@ - Response: {"success": true} - GET /api/files/list - - Response: {"files": [{"id","filename","path","description"}]} + - Response: {"files": [{"id","filename","path","storage_url","description"}]} + +- POST /api/files/import-demo[?force=1] + - Copies PDFs from the demo directory into storage and queues them for analysis. + - Response: {"imported": N, "skipped": M, "files_found": K, "source_dir": "...", "attempted_paths": [...], "force": bool} + - `force=1` deletes prior records with the same filename before re-importing. - GET /api/files/delete?id= - Response: {"deleted": true|false} @@ -67,10 +74,10 @@ 2. set env DATABASE_URL and QDRANT_URL 3. cargo run 4. (optional) import demo PDFs - - Ensure demo files are located in `rust-engine/demo-data` (default) or set `DEMO_DATA_DIR` env var to a folder containing PDFs. + - Populate a folder with PDFs under `rust-engine/demo-data` (or point `DEMO_DATA_DIR` to a custom path). The server auto-resolves common locations such as the repo root, `/app/demo-data`, and the working directory when running in Docker. - Call the endpoint: - POST - - Optional query `?force=1` to overwrite existing by filename + - Optional query `?force=1` to overwrite existing by filename. The JSON response also echoes where the engine looked (`source_dir`, `attempted_paths`) and how many PDFs were detected (`files_found`) so misconfigurations are easy to spot. Imported files are written to the shared `/app/storage` volume; the web-app container mounts this volume read-only and serves the contents at `/storage/`. - Or run the PowerShell helper: - `./scripts/import_demo.ps1` (adds all PDFs in demo-data) - `./scripts/import_demo.ps1 -Force` (overwrite existing) diff --git a/rust-engine/src/api.rs b/rust-engine/src/api.rs index 1de0c81..2950125 100644 --- a/rust-engine/src/api.rs +++ b/rust-engine/src/api.rs @@ -1,10 +1,11 @@ -use crate::vector_db::QdrantClient; use crate::storage; +use crate::vector_db::QdrantClient; use anyhow::Result; use bytes::Buf; use futures_util::TryStreamExt; use serde::Deserialize; use sqlx::{MySqlPool, Row}; +use tracing::info; use warp::{multipart::FormData, Filter, Rejection, Reply}; #[derive(Debug, Deserialize)] @@ -21,7 +22,7 @@ pub fn routes(pool: MySqlPool) -> impl Filter>() .or(warp::any().map(|| std::collections::HashMap::new())) - .unify() + .unify(), ) .and(pool_filter.clone()) .and_then(handle_import_demo); @@ -74,14 +75,21 @@ pub fn routes(pool: MySqlPool) -> impl Filter Result { let mut created_files = Vec::new(); while let Some(field) = form.try_next().await.map_err(|_| warp::reject())? { - let _name = field.name().to_string(); + let _name = field.name().to_string(); let filename = field .filename() .map(|s| s.to_string()) @@ -138,13 +146,22 @@ async fn handle_upload(mut form: FormData, pool: MySqlPool) -> Result, pool: MySqlPool) -> Result { +async fn handle_import_demo( + params: std::collections::HashMap, + pool: MySqlPool, +) -> Result { use std::fs; use std::path::PathBuf; - let force = params.get("force").map(|v| v == "1" || v.eq_ignore_ascii_case("true")).unwrap_or(false); - let demo_dir_setting = std::env::var("DEMO_DATA_DIR").unwrap_or_else(|_| "demo-data".to_string()); + let force = params + .get("force") + .map(|v| v == "1" || v.eq_ignore_ascii_case("true")) + .unwrap_or(false); + let demo_dir_setting = + std::env::var("DEMO_DATA_DIR").unwrap_or_else(|_| "demo-data".to_string()); let base = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); + info!(force, requested_dir = %demo_dir_setting, "demo import requested"); + // Build a list of plausible demo-data locations so local runs and containers both work. let mut candidates: Vec = Vec::new(); let configured = PathBuf::from(&demo_dir_setting); @@ -171,33 +188,45 @@ async fn handle_import_demo(params: std::collections::HashMap, p let mut resolved_dir: Option = None; for candidate in candidates { if candidate.exists() && candidate.is_dir() { - resolved_dir = Some(candidate); + resolved_dir = Some(candidate.clone()); break; } attempted.push(candidate); } + let attempted_paths: Vec = attempted.iter().map(|p| p.display().to_string()).collect(); let src_dir = match resolved_dir { Some(path) => path, None => { - let attempted_paths: Vec = attempted - .into_iter() - .map(|p| p.display().to_string()) - .collect(); return Ok(warp::reply::json(&serde_json::json!({ "imported": 0, "skipped": 0, - "error": format!("demo dir not found (checked: {})", attempted_paths.join(", ")) + "files_found": 0, + "attempted_paths": attempted_paths, + "error": format!("demo dir not found; set DEMO_DATA_DIR or bind mount demo PDFs") }))); } }; + let src_dir_display = src_dir.display().to_string(); + info!(source = %src_dir_display, attempted_paths = ?attempted_paths, "demo import source resolved"); let mut imported = 0; let mut skipped = 0; + let mut files_found = 0; for entry in fs::read_dir(&src_dir).map_err(|_| warp::reject())? { let entry = entry.map_err(|_| warp::reject())?; let path = entry.path(); - if path.extension().and_then(|e| e.to_str()).map(|e| e.eq_ignore_ascii_case("pdf")).unwrap_or(false) { - let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("unknown.pdf").to_string(); + if path + .extension() + .and_then(|e| e.to_str()) + .map(|e| e.eq_ignore_ascii_case("pdf")) + .unwrap_or(false) + { + files_found += 1; + let filename = path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("unknown.pdf") + .to_string(); // check if exists if !force { @@ -205,8 +234,10 @@ async fn handle_import_demo(params: std::collections::HashMap, p .bind(&filename) .fetch_optional(&pool) .await - .map_err(|_| warp::reject())? { + .map_err(|_| warp::reject())? + { skipped += 1; + info!(%filename, "skipping demo import; already present"); continue; } } @@ -214,6 +245,7 @@ async fn handle_import_demo(params: std::collections::HashMap, p // read and save to storage let data = fs::read(&path).map_err(|_| warp::reject())?; let stored_path = storage::save_file(&filename, &data).map_err(|_| warp::reject())?; + info!(%filename, dest = %stored_path.to_string_lossy(), "demo file copied to storage"); // insert or upsert db record let id = uuid::Uuid::new_v4().to_string(); @@ -222,11 +254,12 @@ async fn handle_import_demo(params: std::collections::HashMap, p .bind(&filename) .execute(&pool) .await; + info!(%filename, "existing file records removed due to force import"); } sqlx::query("INSERT INTO files (id, filename, path, description, pending_analysis, analysis_status) VALUES (?, ?, ?, ?, ?, 'Queued')") .bind(&id) .bind(&filename) - .bind(stored_path.to_str().unwrap()) + .bind(stored_path.to_string_lossy().to_string()) .bind(Option::::None) .bind(true) .execute(&pool) @@ -235,10 +268,36 @@ async fn handle_import_demo(params: std::collections::HashMap, p tracing::error!("DB insert error: {}", e); warp::reject() })?; + info!(%filename, file_id = %id, "demo file inserted into database"); + + // queue for worker + sqlx::query("INSERT INTO file_jobs (file_id, status) VALUES (?, 'Queued')") + .bind(&id) + .execute(&pool) + .await + .map_err(|_| warp::reject())?; + info!(%filename, file_id = %id, "demo file queued for analysis"); + imported += 1; } } - Ok(warp::reply::json(&serde_json::json!({ "imported": imported, "skipped": skipped }))) + info!( + source = %src_dir_display, + files_found, + attempted_paths = ?attempted_paths, + imported, + skipped, + force, + "demo import completed" + ); + Ok(warp::reply::json(&serde_json::json!({ + "imported": imported, + "skipped": skipped, + "files_found": files_found, + "source_dir": src_dir_display, + "attempted_paths": attempted_paths, + "force": force + }))) } async fn handle_delete(q: DeleteQuery, pool: MySqlPool) -> Result { @@ -251,10 +310,14 @@ async fn handle_delete(q: DeleteQuery, pool: MySqlPool) -> Result Result { tracing::error!("DB list error: {}", e); warp::reject() })?; - let files: Vec = rows .into_iter() .map(|r| { @@ -278,10 +340,12 @@ async fn handle_list(pool: MySqlPool) -> Result { let description: Option = r.get("description"); let pending: bool = r.get("pending_analysis"); let status: Option = r.try_get("analysis_status").ok(); + let storage_url = format!("/storage/{}", filename); serde_json::json!({ "id": id, "filename": filename, "path": path, + "storage_url": storage_url, "description": description, "pending_analysis": pending, "analysis_status": status @@ -292,7 +356,10 @@ async fn handle_list(pool: MySqlPool) -> Result { Ok(warp::reply::json(&serde_json::json!({"files": files}))) } -async fn handle_create_query(body: serde_json::Value, pool: MySqlPool) -> Result { +async fn handle_create_query( + body: serde_json::Value, + pool: MySqlPool, +) -> Result { // Insert query as queued, worker will pick it up let id = uuid::Uuid::new_v4().to_string(); let payload = body; @@ -317,9 +384,11 @@ async fn handle_query_status(q: DeleteQuery, pool: MySqlPool) -> Result Result { diff --git a/rust-engine/src/file_worker.rs b/rust-engine/src/file_worker.rs index 0c2e4b7..c9ec244 100644 --- a/rust-engine/src/file_worker.rs +++ b/rust-engine/src/file_worker.rs @@ -1,9 +1,9 @@ use crate::gemini_client::{demo_text_embedding, generate_text_with_model, DEMO_EMBED_DIM}; use crate::vector; use crate::vector_db::QdrantClient; -use sqlx::MySqlPool; use anyhow::Result; -use tracing::{info, error}; +use sqlx::MySqlPool; +use tracing::{error, info}; pub struct FileWorker { pool: MySqlPool, @@ -12,7 +12,8 @@ pub struct FileWorker { impl FileWorker { pub fn new(pool: MySqlPool) -> Self { - let qdrant_url = std::env::var("QDRANT_URL").unwrap_or_else(|_| "http://qdrant:6333".to_string()); + let qdrant_url = + std::env::var("QDRANT_URL").unwrap_or_else(|_| "http://qdrant:6333".to_string()); let qdrant = QdrantClient::new(&qdrant_url); Self { pool, qdrant } } @@ -70,8 +71,8 @@ impl FileWorker { .bind(file_id) .fetch_one(&self.pool) .await?; - let filename: String = row.get("filename"); - let _path: String = row.get("path"); + let filename: String = row.get("filename"); + let _path: String = row.get("path"); // Stage 1: Gemini 2.5 Flash for description let desc = generate_text_with_model( @@ -82,11 +83,13 @@ impl FileWorker { ) .await .unwrap_or_else(|e| format!("[desc error: {}]", e)); - sqlx::query("UPDATE files SET description = ?, analysis_status = 'InProgress' WHERE id = ?") - .bind(&desc) - .bind(file_id) - .execute(&self.pool) - .await?; + sqlx::query( + "UPDATE files SET description = ?, analysis_status = 'InProgress' WHERE id = ?", + ) + .bind(&desc) + .bind(file_id) + .execute(&self.pool) + .await?; // Stage 2: Gemini 2.5 Pro for deep vector graph data let vector_graph = generate_text_with_model( @@ -111,18 +114,22 @@ impl FileWorker { } // Mark file as ready - sqlx::query("UPDATE files SET pending_analysis = FALSE, analysis_status = 'Completed' WHERE id = ?") - .bind(file_id) - .execute(&self.pool) - .await?; + sqlx::query( + "UPDATE files SET pending_analysis = FALSE, analysis_status = 'Completed' WHERE id = ?", + ) + .bind(file_id) + .execute(&self.pool) + .await?; Ok(()) } async fn mark_failed(&self, file_id: &str, reason: &str) -> Result<()> { - sqlx::query("UPDATE files SET analysis_status = 'Failed', pending_analysis = TRUE WHERE id = ?") - .bind(file_id) - .execute(&self.pool) - .await?; + sqlx::query( + "UPDATE files SET analysis_status = 'Failed', pending_analysis = TRUE WHERE id = ?", + ) + .bind(file_id) + .execute(&self.pool) + .await?; sqlx::query("UPDATE files SET description = COALESCE(description, ?) WHERE id = ?") .bind(format!("[analysis failed: {}]", reason)) .bind(file_id) diff --git a/rust-engine/src/gemini_client.rs b/rust-engine/src/gemini_client.rs index 5741166..a74a1a1 100644 --- a/rust-engine/src/gemini_client.rs +++ b/rust-engine/src/gemini_client.rs @@ -63,13 +63,21 @@ pub async fn generate_text_with_model(model: &str, prompt: &str) -> Result } + struct Part { + text: Option, + } #[derive(Deserialize)] - struct Content { parts: Vec } + struct Content { + parts: Vec, + } #[derive(Deserialize)] - struct Candidate { content: Content } + struct Candidate { + content: Content, + } #[derive(Deserialize)] - struct Response { candidates: Option> } + struct Response { + candidates: Option>, + } let data: Response = serde_json::from_str(&txt).unwrap_or(Response { candidates: None }); let out = data diff --git a/rust-engine/src/main.rs b/rust-engine/src/main.rs index 68b2663..98f89c2 100644 --- a/rust-engine/src/main.rs +++ b/rust-engine/src/main.rs @@ -1,12 +1,12 @@ -mod file_worker; mod api; mod db; +mod file_worker; mod gemini_client; mod models; mod storage; mod vector; -mod worker; mod vector_db; +mod worker; use std::env; use std::error::Error; @@ -30,7 +30,9 @@ async fn main() -> Result<(), Box> { storage::ensure_storage_dir().expect("storage dir"); // Initialize DB - let pool = db::init_db(&database_url).await.map_err(|e| -> Box { Box::new(e) })?; + let pool = db::init_db(&database_url) + .await + .map_err(|e| -> Box { Box::new(e) })?; // Spawn query worker let worker = worker::Worker::new(pool.clone()); @@ -42,17 +44,17 @@ async fn main() -> Result<(), Box> { // API routes let api_routes = api::routes(pool.clone()) - .with(warp::cors() - .allow_any_origin() - .allow_headers(vec!["content-type", "authorization"]) - .allow_methods(vec!["GET", "POST", "PUT", "DELETE", "OPTIONS"])) + .with( + warp::cors() + .allow_any_origin() + .allow_headers(vec!["content-type", "authorization"]) + .allow_methods(vec!["GET", "POST", "PUT", "DELETE", "OPTIONS"]), + ) .with(warp::log("rust_engine")); info!("Rust Engine started on http://0.0.0.0:8000"); - warp::serve(api_routes) - .run(([0, 0, 0, 0], 8000)) - .await; + warp::serve(api_routes).run(([0, 0, 0, 0], 8000)).await; Ok(()) -} \ No newline at end of file +} diff --git a/rust-engine/src/models.rs b/rust-engine/src/models.rs index 59f0e12..ecea22f 100644 --- a/rust-engine/src/models.rs +++ b/rust-engine/src/models.rs @@ -15,7 +15,11 @@ pub struct FileRecord { impl FileRecord { #[allow(dead_code)] - pub fn new(filename: impl Into, path: impl Into, description: Option) -> Self { + pub fn new( + filename: impl Into, + path: impl Into, + description: Option, + ) -> Self { Self { id: Uuid::new_v4().to_string(), filename: filename.into(), diff --git a/rust-engine/src/vector_db.rs b/rust-engine/src/vector_db.rs index cb09d79..b8142a5 100644 --- a/rust-engine/src/vector_db.rs +++ b/rust-engine/src/vector_db.rs @@ -1,7 +1,7 @@ use anyhow::Result; use reqwest::Client; -use serde_json::json; use serde::Deserialize; +use serde_json::json; #[derive(Clone)] pub struct QdrantClient { @@ -10,7 +10,6 @@ pub struct QdrantClient { } impl QdrantClient { - /// Delete a point from collection 'files' by id pub async fn delete_point(&self, id: &str) -> Result<()> { let url = format!("{}/collections/files/points/delete", self.base); @@ -67,7 +66,11 @@ impl QdrantClient { } else { let status = resp.status(); let t = resp.text().await.unwrap_or_default(); - Err(anyhow::anyhow!("qdrant ensure collection failed: {} - {}", status, t)) + Err(anyhow::anyhow!( + "qdrant ensure collection failed: {} - {}", + status, + t + )) } } @@ -85,9 +88,14 @@ impl QdrantClient { return Err(anyhow::anyhow!("qdrant search failed: {} - {}", status, t)); } #[derive(Deserialize)] - struct Hit { id: serde_json::Value, score: f32 } + struct Hit { + id: serde_json::Value, + score: f32, + } #[derive(Deserialize)] - struct Data { result: Vec } + struct Data { + result: Vec, + } let data: Data = resp.json().await?; let mut out = Vec::new(); for h in data.result { diff --git a/rust-engine/src/worker.rs b/rust-engine/src/worker.rs index e3e96bb..cdd471b 100644 --- a/rust-engine/src/worker.rs +++ b/rust-engine/src/worker.rs @@ -14,7 +14,8 @@ pub struct Worker { impl Worker { pub fn new(pool: MySqlPool) -> Self { - let qdrant_url = std::env::var("QDRANT_URL").unwrap_or_else(|_| "http://qdrant:6333".to_string()); + let qdrant_url = + std::env::var("QDRANT_URL").unwrap_or_else(|_| "http://qdrant:6333".to_string()); let qdrant = QdrantClient::new(&qdrant_url); Self { pool, qdrant } } @@ -56,18 +57,22 @@ impl Worker { async fn fetch_and_claim(&self) -> Result> { // Note: MySQL transactional SELECT FOR UPDATE handling is more complex; for this hackathon scaffold // we do a simple two-step: select one queued id, then update it to InProgress if it is still queued. - if let Some(row) = sqlx::query("SELECT id, payload FROM queries WHERE status = 'Queued' ORDER BY created_at LIMIT 1") - .fetch_optional(&self.pool) - .await? + if let Some(row) = sqlx::query( + "SELECT id, payload FROM queries WHERE status = 'Queued' ORDER BY created_at LIMIT 1", + ) + .fetch_optional(&self.pool) + .await? { use sqlx::Row; let id: String = row.get("id"); let payload: serde_json::Value = row.get("payload"); - let updated = sqlx::query("UPDATE queries SET status = 'InProgress' WHERE id = ? AND status = 'Queued'") - .bind(&id) - .execute(&self.pool) - .await?; + let updated = sqlx::query( + "UPDATE queries SET status = 'InProgress' WHERE id = ? AND status = 'Queued'", + ) + .bind(&id) + .execute(&self.pool) + .await?; if updated.rows_affected() == 1 { let mut q = QueryRecord::new(payload); @@ -90,7 +95,9 @@ impl Worker { let top_k = top_k.max(1).min(20); // Check cancellation - if self.is_cancelled(&q.id).await? { return Ok(()); } + if self.is_cancelled(&q.id).await? { + return Ok(()); + } // Stage 3: search top-K in Qdrant let hits = match self.qdrant.search_top_k(emb.clone(), top_k).await { @@ -115,7 +122,9 @@ impl Worker { }; // Check cancellation - if self.is_cancelled(&q.id).await? { return Ok(()); } + if self.is_cancelled(&q.id).await? { + return Ok(()); + } // Stage 4: fetch file metadata for IDs let mut files_json = Vec::new(); @@ -222,13 +231,18 @@ impl Worker { } fn build_relationships_prompt(query: &str, files: &Vec) -> String { - let files_snippets: Vec = files.iter().map(|f| format!( - "- id: {id}, filename: {name}, path: {path}, desc: {desc}", - id=f.get("id").and_then(|v| v.as_str()).unwrap_or(""), - name=f.get("filename").and_then(|v| v.as_str()).unwrap_or(""), - path=f.get("path").and_then(|v| v.as_str()).unwrap_or(""), - desc=f.get("description").and_then(|v| v.as_str()).unwrap_or("") - )).collect(); + let files_snippets: Vec = files + .iter() + .map(|f| { + format!( + "- id: {id}, filename: {name}, path: {path}, desc: {desc}", + id = f.get("id").and_then(|v| v.as_str()).unwrap_or(""), + name = f.get("filename").and_then(|v| v.as_str()).unwrap_or(""), + path = f.get("path").and_then(|v| v.as_str()).unwrap_or(""), + desc = f.get("description").and_then(|v| v.as_str()).unwrap_or("") + ) + }) + .collect(); format!( "You are an assistant analyzing relationships STRICTLY within the provided files.\n\ Query: {query}\n\ @@ -243,12 +257,21 @@ fn build_relationships_prompt(query: &str, files: &Vec) -> St ) } -fn build_final_answer_prompt(query: &str, files: &Vec, relationships: &str) -> String { - let files_short: Vec = files.iter().map(|f| format!( - "- {name} ({id})", - id=f.get("id").and_then(|v| v.as_str()).unwrap_or(""), - name=f.get("filename").and_then(|v| v.as_str()).unwrap_or("") - )).collect(); +fn build_final_answer_prompt( + query: &str, + files: &Vec, + relationships: &str, +) -> String { + let files_short: Vec = files + .iter() + .map(|f| { + format!( + "- {name} ({id})", + id = f.get("id").and_then(|v| v.as_str()).unwrap_or(""), + name = f.get("filename").and_then(|v| v.as_str()).unwrap_or("") + ) + }) + .collect(); format!( "You are to compose a final answer to the user query using only the information from the files.\n\ Query: {query}\n\ diff --git a/web-app/server.mjs b/web-app/server.mjs index e811db1..2c9fd8b 100644 --- a/web-app/server.mjs +++ b/web-app/server.mjs @@ -15,6 +15,7 @@ const RUST_ENGINE_BASE = process.env.RUST_ENGINE_BASE || process.env.RUST_ENGINE_URL || 'http://rust-engine:8000'; +const STORAGE_DIR = path.resolve(process.env.ASTRA_STORAGE || '/app/storage'); app.set('trust proxy', true); app.use(helmet({ contentSecurityPolicy: false })); @@ -43,6 +44,9 @@ app.post('/api/files/import-demo', async (req, res) => { const distDir = path.resolve(__dirname, 'dist'); app.use(express.static(distDir)); +// Expose imported files for the UI (read-only) +app.use('/storage', express.static(STORAGE_DIR)); + // SPA fallback (Express 5 requires middleware instead of bare '*') app.use((req, res) => { res.sendFile(path.join(distDir, 'index.html'));