AI Agent commited on
Commit ·
03cfa1a
1
Parent(s): bfd4772
Refactor RAG query architecture to async job queue to fix mobile background disconnects
Browse files- app.py +64 -48
- static/app.js +53 -24
app.py
CHANGED
|
@@ -819,10 +819,12 @@ def delete_document(source_name: str):
|
|
| 819 |
|
| 820 |
# ── Query ─────────────────────────────────────────────────────────────────────
|
| 821 |
|
| 822 |
-
|
|
|
|
|
|
|
| 823 |
@limiter.limit("120 per minute")
|
| 824 |
-
def
|
| 825 |
-
"""RAG query
|
| 826 |
data = request.get_json()
|
| 827 |
q = escape((data or {}).get("query", "").strip())
|
| 828 |
top_k = (data or {}).get("top_k")
|
|
@@ -832,68 +834,82 @@ def query():
|
|
| 832 |
use_bm25 = False # Disabled per request
|
| 833 |
use_gpu = bool((data or {}).get("use_gpu", False))
|
| 834 |
cpu_threads = int((data or {}).get("cpu_threads", 2))
|
|
|
|
| 835 |
if not q:
|
| 836 |
return jsonify({"error": "Empty query"}), 400
|
| 837 |
|
| 838 |
token = get_session_token()
|
| 839 |
-
|
| 840 |
chunk_count = vector_store.count()
|
| 841 |
if chunk_count == 0:
|
| 842 |
return jsonify({"error": "No documents ingested yet. Please upload documents first."}), 400
|
| 843 |
|
| 844 |
log.info("Query received (%d chars) | vector store has %d chunks", len(q), chunk_count)
|
| 845 |
-
|
| 846 |
remote_addr = request.remote_addr
|
| 847 |
|
| 848 |
-
|
| 849 |
-
|
| 850 |
-
|
| 851 |
-
|
| 852 |
-
|
| 853 |
-
|
| 854 |
-
try:
|
| 855 |
-
def cb(status):
|
| 856 |
-
if isinstance(status, dict):
|
| 857 |
-
q_events.put(status)
|
| 858 |
-
else:
|
| 859 |
-
q_events.put({"status": status})
|
| 860 |
-
ans, metrics = run_query_crew(q, top_k=top_k, max_tokens=max_tokens, use_vector=use_vector, use_graph=use_graph, use_bm25=use_bm25, session_token=token, status_callback=cb, use_gpu=use_gpu, cpu_threads=cpu_threads)
|
| 861 |
-
q_events.put({"done": True, "answer": ans, "metrics": metrics})
|
| 862 |
-
|
| 863 |
-
gen_time = metrics.get("time_seconds", 0)
|
| 864 |
-
log_query_summary(token, remote_addr, q, top_k or 10, gen_time, True)
|
| 865 |
-
except Exception as e:
|
| 866 |
-
log.exception("Query pipeline error")
|
| 867 |
-
q_events.put({"error": str(e)})
|
| 868 |
-
log_query_summary(token, remote_addr, q, top_k or 10, 0, False, str(e))
|
| 869 |
|
| 870 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 871 |
|
|
|
|
|
|
|
|
|
|
| 872 |
while True:
|
| 873 |
-
|
| 874 |
-
event =
|
| 875 |
-
|
| 876 |
-
|
| 877 |
-
|
| 878 |
-
|
| 879 |
-
|
| 880 |
-
|
| 881 |
-
break
|
| 882 |
-
elif "status" in event:
|
| 883 |
-
yield f"data: {json.dumps({'status': event['status']})}\n\n"
|
| 884 |
-
elif "done" in event:
|
| 885 |
-
answer = event["answer"]
|
| 886 |
-
log.info("Query answered — %d chars", len(answer))
|
| 887 |
-
for i in range(0, len(answer), 80):
|
| 888 |
-
chunk = answer[i:i + 80]
|
| 889 |
-
payload = json.dumps({"chunk": chunk})
|
| 890 |
-
yield f"data: {payload}\n\n"
|
| 891 |
-
yield f"data: {json.dumps({'metrics': event['metrics']})}\n\n"
|
| 892 |
-
yield "data: {\"done\": true}\n\n"
|
| 893 |
break
|
|
|
|
|
|
|
|
|
|
| 894 |
|
| 895 |
return Response(
|
| 896 |
-
stream_with_context(_generate(
|
| 897 |
mimetype="text/event-stream",
|
| 898 |
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
| 899 |
)
|
|
|
|
| 819 |
|
| 820 |
# ── Query ─────────────────────────────────────────────────────────────────────
|
| 821 |
|
| 822 |
+
_query_jobs = {}
|
| 823 |
+
|
| 824 |
+
@app.route("/api/query/start", methods=["POST"])
|
| 825 |
@limiter.limit("120 per minute")
|
| 826 |
+
def query_start():
|
| 827 |
+
"""Starts a RAG query job and returns a job_id."""
|
| 828 |
data = request.get_json()
|
| 829 |
q = escape((data or {}).get("query", "").strip())
|
| 830 |
top_k = (data or {}).get("top_k")
|
|
|
|
| 834 |
use_bm25 = False # Disabled per request
|
| 835 |
use_gpu = bool((data or {}).get("use_gpu", False))
|
| 836 |
cpu_threads = int((data or {}).get("cpu_threads", 2))
|
| 837 |
+
|
| 838 |
if not q:
|
| 839 |
return jsonify({"error": "Empty query"}), 400
|
| 840 |
|
| 841 |
token = get_session_token()
|
|
|
|
| 842 |
chunk_count = vector_store.count()
|
| 843 |
if chunk_count == 0:
|
| 844 |
return jsonify({"error": "No documents ingested yet. Please upload documents first."}), 400
|
| 845 |
|
| 846 |
log.info("Query received (%d chars) | vector store has %d chunks", len(q), chunk_count)
|
|
|
|
| 847 |
remote_addr = request.remote_addr
|
| 848 |
|
| 849 |
+
job_id = uuid.uuid4().hex[:8]
|
| 850 |
+
_query_jobs[job_id] = {
|
| 851 |
+
"events": [],
|
| 852 |
+
"done": False,
|
| 853 |
+
"error": None
|
| 854 |
+
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 855 |
|
| 856 |
+
def _run():
|
| 857 |
+
config.current_session.set(token)
|
| 858 |
+
try:
|
| 859 |
+
def cb(status):
|
| 860 |
+
if isinstance(status, dict):
|
| 861 |
+
_query_jobs[job_id]["events"].append(status)
|
| 862 |
+
else:
|
| 863 |
+
_query_jobs[job_id]["events"].append({"status": status})
|
| 864 |
+
|
| 865 |
+
ans, metrics = run_query_crew(q, top_k=top_k, max_tokens=max_tokens, use_vector=use_vector, use_graph=use_graph, use_bm25=use_bm25, session_token=token, status_callback=cb, use_gpu=use_gpu, cpu_threads=cpu_threads)
|
| 866 |
+
|
| 867 |
+
for i in range(0, len(ans), 80):
|
| 868 |
+
_query_jobs[job_id]["events"].append({"chunk": ans[i:i + 80]})
|
| 869 |
+
_query_jobs[job_id]["events"].append({"metrics": metrics})
|
| 870 |
+
_query_jobs[job_id]["events"].append({"done": True})
|
| 871 |
+
_query_jobs[job_id]["done"] = True
|
| 872 |
+
|
| 873 |
+
gen_time = metrics.get("time_seconds", 0)
|
| 874 |
+
log_query_summary(token, remote_addr, q, top_k or 10, gen_time, True)
|
| 875 |
+
except Exception as e:
|
| 876 |
+
log.exception("Query pipeline error")
|
| 877 |
+
_query_jobs[job_id]["events"].append({"error": str(e)})
|
| 878 |
+
_query_jobs[job_id]["error"] = str(e)
|
| 879 |
+
_query_jobs[job_id]["done"] = True
|
| 880 |
+
log_query_summary(token, remote_addr, q, top_k or 10, 0, False, str(e))
|
| 881 |
+
|
| 882 |
+
_llm_query_queue.put((_run, ()))
|
| 883 |
+
return jsonify({"job_id": job_id})
|
| 884 |
+
|
| 885 |
+
@app.route("/api/query/stream/<job_id>")
|
| 886 |
+
def query_stream(job_id):
|
| 887 |
+
"""Streams events for a specific query job starting from an offset."""
|
| 888 |
+
offset = int(request.args.get("offset", 0))
|
| 889 |
+
job = _query_jobs.get(job_id)
|
| 890 |
+
|
| 891 |
+
if not job:
|
| 892 |
+
return jsonify({"error": "Job not found or expired"}), 404
|
| 893 |
|
| 894 |
+
def _generate():
|
| 895 |
+
import time
|
| 896 |
+
idx = offset
|
| 897 |
while True:
|
| 898 |
+
while idx < len(job["events"]):
|
| 899 |
+
event = job["events"][idx]
|
| 900 |
+
yield f"data: {json.dumps(event)}\n\n"
|
| 901 |
+
if "error" in event or "done" in event:
|
| 902 |
+
return
|
| 903 |
+
idx += 1
|
| 904 |
+
|
| 905 |
+
if job["error"] or job["done"]:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 906 |
break
|
| 907 |
+
|
| 908 |
+
time.sleep(0.5)
|
| 909 |
+
yield ": keep-alive\n\n"
|
| 910 |
|
| 911 |
return Response(
|
| 912 |
+
stream_with_context(_generate()),
|
| 913 |
mimetype="text/event-stream",
|
| 914 |
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
| 915 |
)
|
static/app.js
CHANGED
|
@@ -853,35 +853,56 @@ async function submitQuery() {
|
|
| 853 |
if (!useVector) $(`ms-vector-${qId}`)?.classList.add('disabled');
|
| 854 |
if (!useBm25) $(`ms-bm25-${qId}`)?.classList.add('disabled');
|
| 855 |
|
| 856 |
-
const
|
| 857 |
method: 'POST',
|
| 858 |
headers: { 'Content-Type': 'application/json' },
|
| 859 |
body: JSON.stringify({ query: q, top_k: topK, max_tokens: maxTokens, use_vector: useVector, use_graph: useGraph, use_bm25: useBm25, use_gpu: useGpu, cpu_threads: cpuThreads }),
|
| 860 |
});
|
| 861 |
|
| 862 |
-
if (!
|
| 863 |
-
const errData = await
|
| 864 |
-
throw new Error(errData.error || `HTTP ${
|
| 865 |
}
|
| 866 |
|
| 867 |
-
const
|
| 868 |
-
const
|
| 869 |
-
let buffer = '';
|
| 870 |
|
| 871 |
-
|
| 872 |
-
|
| 873 |
-
if (done) break;
|
| 874 |
-
buffer += decoder.decode(value, { stream: true });
|
| 875 |
-
const lines = buffer.split('\n');
|
| 876 |
-
buffer = lines.pop();
|
| 877 |
|
| 878 |
-
|
| 879 |
-
|
| 880 |
-
|
| 881 |
-
|
| 882 |
-
|
|
|
|
|
|
|
|
|
|
| 883 |
|
| 884 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 885 |
|
| 886 |
if (payload.status) {
|
| 887 |
diag.info('SSE status:', payload.status);
|
|
@@ -979,7 +1000,19 @@ async function submitQuery() {
|
|
| 979 |
}
|
| 980 |
}
|
| 981 |
|
| 982 |
-
if (payload.done) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 983 |
}
|
| 984 |
}
|
| 985 |
|
|
@@ -1006,11 +1039,7 @@ async function submitQuery() {
|
|
| 1006 |
diag.error('Query error:', err);
|
| 1007 |
stopTimer();
|
| 1008 |
|
| 1009 |
-
// Check if the browser recently suspended the tab (common on mobile)
|
| 1010 |
let errorMsg = err.message;
|
| 1011 |
-
if (document.hidden || document.visibilityState === 'hidden') {
|
| 1012 |
-
errorMsg = "Connection lost because the app was moved to the background. Please keep the app open during analysis to prevent the network from dropping.";
|
| 1013 |
-
}
|
| 1014 |
|
| 1015 |
if (currentMsId) {
|
| 1016 |
const el = $(currentMsId);
|
|
|
|
| 853 |
if (!useVector) $(`ms-vector-${qId}`)?.classList.add('disabled');
|
| 854 |
if (!useBm25) $(`ms-bm25-${qId}`)?.classList.add('disabled');
|
| 855 |
|
| 856 |
+
const startResp = await fetch('/api/query/start', {
|
| 857 |
method: 'POST',
|
| 858 |
headers: { 'Content-Type': 'application/json' },
|
| 859 |
body: JSON.stringify({ query: q, top_k: topK, max_tokens: maxTokens, use_vector: useVector, use_graph: useGraph, use_bm25: useBm25, use_gpu: useGpu, cpu_threads: cpuThreads }),
|
| 860 |
});
|
| 861 |
|
| 862 |
+
if (!startResp.ok) {
|
| 863 |
+
const errData = await startResp.json();
|
| 864 |
+
throw new Error(errData.error || `HTTP ${startResp.status}`);
|
| 865 |
}
|
| 866 |
|
| 867 |
+
const startData = await startResp.json();
|
| 868 |
+
const jobId = startData.job_id;
|
|
|
|
| 869 |
|
| 870 |
+
let currentOffset = 0;
|
| 871 |
+
let jobDone = false;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 872 |
|
| 873 |
+
while (!jobDone) {
|
| 874 |
+
try {
|
| 875 |
+
const resp = await fetch(`/api/query/stream/${jobId}?offset=${currentOffset}`);
|
| 876 |
+
if (!resp.ok) {
|
| 877 |
+
const errData = await resp.json().catch(() => ({}));
|
| 878 |
+
if (resp.status === 404) throw new Error("Job not found or expired on server.");
|
| 879 |
+
throw new Error(errData.error || `HTTP ${resp.status}`);
|
| 880 |
+
}
|
| 881 |
|
| 882 |
+
const reader = resp.body.getReader();
|
| 883 |
+
const decoder = new TextDecoder();
|
| 884 |
+
let buffer = '';
|
| 885 |
+
|
| 886 |
+
while (!jobDone) {
|
| 887 |
+
const { done, value } = await reader.read();
|
| 888 |
+
if (done) break;
|
| 889 |
+
buffer += decoder.decode(value, { stream: true });
|
| 890 |
+
const lines = buffer.split('\n');
|
| 891 |
+
buffer = lines.pop();
|
| 892 |
+
|
| 893 |
+
for (const line of lines) {
|
| 894 |
+
if (!line.startsWith('data: ')) continue;
|
| 895 |
+
let payload;
|
| 896 |
+
try { payload = JSON.parse(line.slice(6)); }
|
| 897 |
+
catch(pe) { diag.warn('SSE parse error:', pe, line); continue; }
|
| 898 |
+
|
| 899 |
+
// Successfully received a valid JSON payload, increment offset for next reconnect
|
| 900 |
+
currentOffset++;
|
| 901 |
+
|
| 902 |
+
if (payload.error) {
|
| 903 |
+
jobDone = true;
|
| 904 |
+
throw new Error(payload.error);
|
| 905 |
+
}
|
| 906 |
|
| 907 |
if (payload.status) {
|
| 908 |
diag.info('SSE status:', payload.status);
|
|
|
|
| 1000 |
}
|
| 1001 |
}
|
| 1002 |
|
| 1003 |
+
if (payload.done) {
|
| 1004 |
+
diag.info('SSE: done');
|
| 1005 |
+
jobDone = true;
|
| 1006 |
+
break;
|
| 1007 |
+
}
|
| 1008 |
+
}
|
| 1009 |
+
}
|
| 1010 |
+
} catch (err) {
|
| 1011 |
+
if (err.message.includes("Job not found") || jobDone) {
|
| 1012 |
+
throw err;
|
| 1013 |
+
}
|
| 1014 |
+
diag.warn('Network dropped, reconnecting to job...', err);
|
| 1015 |
+
await new Promise(r => setTimeout(r, 1500));
|
| 1016 |
}
|
| 1017 |
}
|
| 1018 |
|
|
|
|
| 1039 |
diag.error('Query error:', err);
|
| 1040 |
stopTimer();
|
| 1041 |
|
|
|
|
| 1042 |
let errorMsg = err.message;
|
|
|
|
|
|
|
|
|
|
| 1043 |
|
| 1044 |
if (currentMsId) {
|
| 1045 |
const el = $(currentMsId);
|