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
Files changed (2) hide show
  1. app.py +64 -48
  2. static/app.js +53 -24
app.py CHANGED
@@ -819,10 +819,12 @@ def delete_document(source_name: str):
819
 
820
  # ── Query ─────────────────────────────────────────────────────────────────────
821
 
822
- @app.route("/api/query", methods=["POST"])
 
 
823
  @limiter.limit("120 per minute")
824
- def query():
825
- """RAG query returns a streaming SSE response."""
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
- def _generate(sess_token):
849
- import queue
850
- q_events = queue.Queue()
851
-
852
- def _run():
853
- config.current_session.set(sess_token)
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
- _llm_query_queue.put((_run, ()))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
871
 
 
 
 
872
  while True:
873
- try:
874
- event = q_events.get(timeout=10)
875
- except queue.Empty:
876
- yield ": keep-alive\n\n"
877
- continue
878
-
879
- if "error" in event:
880
- yield f"data: {json.dumps({'error': event['error']})}\n\n"
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(token)),
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 resp = await fetch('/api/query', {
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 (!resp.ok) {
863
- const errData = await resp.json();
864
- throw new Error(errData.error || `HTTP ${resp.status}`);
865
  }
866
 
867
- const reader = resp.body.getReader();
868
- const decoder = new TextDecoder();
869
- let buffer = '';
870
 
871
- while (true) {
872
- const { done, value } = await reader.read();
873
- if (done) break;
874
- buffer += decoder.decode(value, { stream: true });
875
- const lines = buffer.split('\n');
876
- buffer = lines.pop();
877
 
878
- for (const line of lines) {
879
- if (!line.startsWith('data: ')) continue;
880
- let payload;
881
- try { payload = JSON.parse(line.slice(6)); }
882
- catch(pe) { diag.warn('SSE parse error:', pe, line); continue; }
 
 
 
883
 
884
- if (payload.error) throw new Error(payload.error);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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) { diag.info('SSE: done'); break; }
 
 
 
 
 
 
 
 
 
 
 
 
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);