Prepared demo files and demo explanation file. Added debug only button to trigger demo file ingest on the server to queue and prepare the files. Added small expressjs server for talking between the web app and the rust engine containers.

This commit is contained in:
Christbru 2025-10-19 05:55:41 -05:00
commit a03969e497
28 changed files with 232 additions and 7 deletions

View file

@ -0,0 +1,48 @@
## Demo Runbook: ISS Systems (3-minute showcase)
This demo uses ~20 public NASA PDFs covering ISS Electrical Power, ECLSS, Avionics, and Structures. They live in `rust-engine/demo-data` and are automatically ingested via the server.
### 1) Seed demo data (one-click)
- Trigger ingestion (cloud): POST `/api/files/import-demo` (UI button available when `?debug=1` is present)
- The backend copies PDFs into storage, inserts DB rows with `pending_analysis = true`, and the FileWorker processes them.
- Processing pipeline per file:
- Gemini Flash → comprehensive description (facts/keywords/components)
- Gemini Pro → deep vector graph data (keywords/use cases/relationships)
- Embed + upsert to Qdrant, mark file ready (`pending_analysis = false`)
Tip: You can list files at `GET /api/files/list`. Ready files will start to appear as analysis completes.
### 2) Showcase flow (suggested script)
1. “We ingested real ISS technical PDFs. The worker analyzes each file with Gemini and builds vector graph data for robust retrieval.”
2. Show the files list. Point out a couple of recognizable titles.
3. Run two queries (examples below) and open their results (the app calls `POST /api/query/create` then polls `/api/query/result`).
4. Highlight the grounded answer: related_files, relationships, and final_answer fields.
5. Call out that if info isnt present in the PDFs, the system explicitly states uncertainty (no guessing).
### 3) Demo queries (pick 23)
- Electrical Power System (EPS)
- “Trace the power path from the P6 solar array to the BCDU. Where are likely ground fault points?”
- “What is the role of the DC Switching Unit in array power management?”
- ECLSS
- “Which modules are part of water recovery, and how does the Oxygen Generator Assembly interface?”
- “Summarize the CDRA cycle and downstream subsystems it impacts.”
- C&DH / Avionics
- “In the US Lab, a blue/white wire connects to MDM LAB1. What are possible data pathways?”
- “Describe the onboard LAN segments and links to MDMs.”
- Structures / Robotics
- “Where does the Latching End Effector connect on S1 truss?”
- “What is the Mobile Transporters role in SSRMS operations?”
### 4) Reset/refresh (optional)
- POST `/api/files/import-demo?force=1` to overwrite by filename and re-queue analysis.
### Appendix: Example sources
- EPS: 20110014867, 20040171627, 19900007297, 20120002931, 20100029672
- ECLSS: 20170008316, 20070019910, 20080039691, 20100029191, 20070019929
- C&DH: 20000012543, 20100029690, 19950014639, 20010023477, 19980227289
- Structures/Robotics: 20020054238, 20010035542, 20140001008, Destiny fact sheet, 20020088289

View file

@ -66,6 +66,14 @@
1. docker compose up -d mysql qdrant 1. docker compose up -d mysql qdrant
2. set env DATABASE_URL and QDRANT_URL 2. set env DATABASE_URL and QDRANT_URL
3. cargo run 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.
- Call the endpoint:
- POST <http://localhost:8000/api/files/import-demo>
- Optional query `?force=1` to overwrite existing by filename
- Or run the PowerShell helper:
- `./scripts/import_demo.ps1` (adds all PDFs in demo-data)
- `./scripts/import_demo.ps1 -Force` (overwrite existing)
## Notes ## Notes

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

View file

@ -16,6 +16,17 @@ struct DeleteQuery {
pub fn routes(pool: MySqlPool) -> impl Filter<Extract = impl Reply, Error = Rejection> + Clone { pub fn routes(pool: MySqlPool) -> impl Filter<Extract = impl Reply, Error = Rejection> + Clone {
let pool_filter = warp::any().map(move || pool.clone()); let pool_filter = warp::any().map(move || pool.clone());
// Import demo files from demo-data directory
let import_demo = warp::path!("files" / "import-demo")
.and(warp::post())
.and(
warp::query::<std::collections::HashMap<String, String>>()
.or(warp::any().map(|| std::collections::HashMap::new()))
.unify()
)
.and(pool_filter.clone())
.and_then(handle_import_demo);
// Upload file // Upload file
let upload = warp::path("files") let upload = warp::path("files")
.and(warp::post()) .and(warp::post())
@ -64,7 +75,7 @@ pub fn routes(pool: MySqlPool) -> impl Filter<Extract = impl Reply, Error = Reje
.and(pool_filter.clone()) .and(pool_filter.clone())
.and_then(handle_cancel_query); .and_then(handle_cancel_query);
let api = upload.or(delete).or(list).or(create_q).or(status).or(result).or(cancel); let api = upload.or(import_demo).or(delete).or(list).or(create_q).or(status).or(result).or(cancel);
warp::path("api").and(api) warp::path("api").and(api)
} }
@ -123,6 +134,70 @@ async fn handle_upload(mut form: FormData, pool: MySqlPool) -> Result<impl Reply
Ok(warp::reply::json(&serde_json::json!({"success": true}))) Ok(warp::reply::json(&serde_json::json!({"success": true})))
} }
async fn handle_import_demo(params: std::collections::HashMap<String, String>, pool: MySqlPool) -> Result<impl Reply, Rejection> {
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 = std::env::var("DEMO_DATA_DIR").unwrap_or_else(|_| "demo-data".to_string());
let base = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
let src_dir = base.join(demo_dir);
if !src_dir.exists() {
return Ok(warp::reply::json(&serde_json::json!({
"imported": 0,
"skipped": 0,
"error": format!("demo dir not found: {}", src_dir.display())
})));
}
let mut imported = 0;
let mut skipped = 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();
// check if exists
if !force {
if let Some(_) = sqlx::query("SELECT id FROM files WHERE filename = ?")
.bind(&filename)
.fetch_optional(&pool)
.await
.map_err(|_| warp::reject())? {
skipped += 1;
continue;
}
}
// 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())?;
// insert or upsert db record
let id = uuid::Uuid::new_v4().to_string();
if force {
let _ = sqlx::query("DELETE FROM files WHERE filename = ?")
.bind(&filename)
.execute(&pool)
.await;
}
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(Option::<String>::None)
.bind(true)
.execute(&pool)
.await
.map_err(|e| {
tracing::error!("DB insert error: {}", e);
warp::reject()
})?;
imported += 1;
}
}
Ok(warp::reply::json(&serde_json::json!({ "imported": imported, "skipped": skipped })))
}
async fn handle_delete(q: DeleteQuery, pool: MySqlPool) -> Result<impl Reply, Rejection> { async fn handle_delete(q: DeleteQuery, pool: MySqlPool) -> Result<impl Reply, Rejection> {
if let Some(row) = sqlx::query("SELECT path FROM files WHERE id = ?") if let Some(row) = sqlx::query("SELECT path FROM files WHERE id = ?")
.bind(&q.id) .bind(&q.id)
@ -143,7 +218,7 @@ async fn handle_delete(q: DeleteQuery, pool: MySqlPool) -> Result<impl Reply, Re
} }
async fn handle_list(pool: MySqlPool) -> Result<impl Reply, Rejection> { async fn handle_list(pool: MySqlPool) -> Result<impl Reply, Rejection> {
let rows = sqlx::query("SELECT id, filename, path, description FROM files ORDER BY created_at DESC LIMIT 500") let rows = sqlx::query("SELECT id, filename, path, description, pending_analysis, analysis_status FROM files ORDER BY created_at DESC LIMIT 500")
.fetch_all(&pool) .fetch_all(&pool)
.await .await
.map_err(|e| { .map_err(|e| {
@ -158,7 +233,16 @@ async fn handle_list(pool: MySqlPool) -> Result<impl Reply, Rejection> {
let filename: String = r.get("filename"); let filename: String = r.get("filename");
let path: String = r.get("path"); let path: String = r.get("path");
let description: Option<String> = r.get("description"); let description: Option<String> = r.get("description");
serde_json::json!({"id": id, "filename": filename, "path": path, "description": description}) let pending: bool = r.get("pending_analysis");
let status: Option<String> = r.try_get("analysis_status").ok();
serde_json::json!({
"id": id,
"filename": filename,
"path": path,
"description": description,
"pending_analysis": pending,
"analysis_status": status
})
}) })
.collect(); .collect();

View file

@ -6,4 +6,4 @@ RUN npm ci
COPY . . COPY . .
RUN npm run format && npm run build RUN npm run format && npm run build
EXPOSE 3000 EXPOSE 3000
CMD ["npm", "run", "preview"] CMD ["node", "server.mjs"]

View file

@ -1,6 +1,7 @@
{ {
"compilerOptions": { "compilerOptions": {
"baseUrl": "./" "baseUrl": "./",
"lib": ["es2015", "dom"]
}, },
"include": ["src"] "include": ["src"]
} }

44
web-app/server.mjs Normal file
View file

@ -0,0 +1,44 @@
import express from 'express';
import path from 'node:path';
import helmet from 'helmet';
import cors from 'cors';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const app = express();
const PORT = process.env.PORT || 3000;
const RUST_ENGINE_BASE = process.env.RUST_ENGINE_BASE || 'http://rust-engine:8000';
app.use(helmet());
app.use(cors());
app.use(express.json());
// Proxy minimal API needed by the UI to the rust-engine container
app.post('/api/files/import-demo', async (req, res) => {
try {
const qs = req.url.includes('?') ? req.url.substring(req.url.indexOf('?')) : '';
const url = `${RUST_ENGINE_BASE}/api/files/import-demo${qs}`;
const upstream = await fetch(url, { method: 'POST' });
const text = await upstream.text();
res.status(upstream.status).type(upstream.headers.get('content-type') || 'application/json').send(text);
} catch (err) {
console.error('import-demo proxy failed:', err);
res.status(502).json({ error: 'proxy_failed' });
}
});
// Serve static frontend
const distDir = path.resolve(__dirname, 'dist');
app.use(express.static(distDir));
// SPA fallback
app.get('*', (req, res) => {
res.sendFile(path.join(distDir, 'index.html'));
});
app.listen(PORT, '0.0.0.0', () => {
console.log(`Web app server listening on http://0.0.0.0:${PORT}`);
console.log(`Proxying to rust engine at ${RUST_ENGINE_BASE}`);
});

View file

@ -1,14 +1,54 @@
import React from "react"; import React, { useMemo, useState } from "react";
import { motion } from "motion/react";
import { Rocket } from "lucide-react";
export default function ChatHeader({ title = "Title of Chat" }) { export default function ChatHeader({ title = "Title of Chat" }) {
const isDebug = useMemo(() => {
const p = new URLSearchParams(window.location.search);
return p.get("debug") === "1";
}, []);
const [ingesting, setIngesting] = useState(false);
const [toast, setToast] = useState("");
async function triggerDemoIngest() {
try {
setIngesting(true);
const res = await fetch("/api/files/import-demo", { method: "POST" });
const json = await res.json().catch(() => ({}));
setToast(`Imported: ${json.imported ?? "?"}, Skipped: ${json.skipped ?? "?"}`);
setTimeout(() => setToast(""), 4000);
} catch (e) {
setToast("Import failed");
setTimeout(() => setToast(""), 4000);
} finally {
setIngesting(false);
}
}
return ( return (
<div className="w-full flex justify-center"> <div className="w-full flex justify-center">
<header className="text-slate-100 fixed top-4 "> <header className="text-slate-100 fixed top-4 ">
<div> <div className="flex items-center gap-3">
<h1 className="text-lg font-semibold shadow-md shadow-indigo-600 bg-gray-900 px-6 py-2 rounded-4xl border-2 border-gray-800"> <h1 className="text-lg font-semibold shadow-md shadow-indigo-600 bg-gray-900 px-6 py-2 rounded-4xl border-2 border-gray-800">
{title} {title}
</h1> </h1>
{isDebug && (
<motion.button
onClick={triggerDemoIngest}
className="bg-gray-800 border-2 border-gray-700 rounded-xl px-3 py-2 flex items-center gap-2"
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
disabled={ingesting}
>
<Rocket size={16} />
{ingesting ? "Seeding…" : "Seed Demo Data"}
</motion.button>
)}
</div> </div>
{toast && (
<div className="mt-2 text-xs text-slate-300 bg-gray-800/80 border border-gray-700 rounded px-2 py-1 inline-block">
{toast}
</div>
)}
</header> </header>
</div> </div>
); );