Vamshi Pokala commited on
Commit
e40f786
·
1 Parent(s): 5976cc0

fix demo mode query routing isolation

Browse files

Route Streamlit demo queries through the in-process orchestrator to avoid localhost API startup races on Spaces, while preserving non-demo HTTP behavior. Add readiness polling for embedded FastAPI startup and test coverage for demo vs non-demo routing paths.

Made-with: Cursor

README.md CHANGED
@@ -31,6 +31,7 @@ Citation-aware RAG system for ingesting documents and generating grounded answer
31
  ### Option 1 — Try online (no install)
32
 
33
  Open the [Hugging Face Spaces demo](https://huggingface.co/spaces/vampokala/doc-ingestion). Sample documents about RAG, vector databases, and BM25 are pre-loaded. Paste your OpenAI, Anthropic, or Gemini key in the sidebar.
 
34
 
35
  ---
36
 
@@ -79,6 +80,8 @@ PYTHONPATH=. streamlit run src/web/streamlit_app.py
79
  PYTHONPATH=. python -m src.query "What is RAG?"
80
  ```
81
 
 
 
82
  ---
83
 
84
  ## Features
 
31
  ### Option 1 — Try online (no install)
32
 
33
  Open the [Hugging Face Spaces demo](https://huggingface.co/spaces/vampokala/doc-ingestion). Sample documents about RAG, vector databases, and BM25 are pre-loaded. Paste your OpenAI, Anthropic, or Gemini key in the sidebar.
34
+ In hosted demo mode (`DOC_PROFILE=demo`), Streamlit executes queries in-process through the shared orchestrator so demo usage is not blocked by localhost API startup races.
35
 
36
  ---
37
 
 
80
  PYTHONPATH=. python -m src.query "What is RAG?"
81
  ```
82
 
83
+ Note: non-demo local mode keeps the standard split architecture (Streamlit calls FastAPI over HTTP), so running both API and UI processes is still required.
84
+
85
  ---
86
 
87
  ## Features
spaces/README.md CHANGED
@@ -27,6 +27,7 @@ A live demo of **Doc-Ingestion**, a citation-aware Retrieval-Augmented Generatio
27
  - **Uploads are disabled** — this demo runs on pre-ingested sample documents only.
28
  - **No persistence** — embeddings are stored in-memory and reset on each Space restart.
29
  - **Cloud LLM only** — Ollama (local model) is not available in this hosted environment.
 
30
 
31
  ## Run locally with full features
32
 
@@ -45,6 +46,7 @@ docker compose -f docker/docker-compose.yml up
45
  ```
46
 
47
  Open http://localhost:8501 for the UI.
 
48
 
49
  ## Source code
50
 
 
27
  - **Uploads are disabled** — this demo runs on pre-ingested sample documents only.
28
  - **No persistence** — embeddings are stored in-memory and reset on each Space restart.
29
  - **Cloud LLM only** — Ollama (local model) is not available in this hosted environment.
30
+ - **Demo query path** — Streamlit runs demo queries in-process via `RAGOrchestrator`; FastAPI is still started for health/metrics and API compatibility.
31
 
32
  ## Run locally with full features
33
 
 
46
  ```
47
 
48
  Open http://localhost:8501 for the UI.
49
+ For standard local development (non-demo), keep running FastAPI and Streamlit as separate processes.
50
 
51
  ## Source code
52
 
spaces/app.py CHANGED
@@ -14,6 +14,8 @@ import threading
14
  import time
15
  from pathlib import Path
16
 
 
 
17
  logging.basicConfig(level=logging.INFO)
18
 
19
  # Ensure repo root is importable regardless of where HF Spaces runs this.
@@ -45,11 +47,28 @@ def _start_api() -> None:
45
  uvicorn.run(fastapi_app, host="127.0.0.1", port=8000, log_level="warning")
46
 
47
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
48
  _api_thread = threading.Thread(target=_start_api, daemon=True)
49
  _api_thread.start()
50
 
51
- # Give the API a moment to bind before Streamlit renders its first page.
52
- time.sleep(3)
53
 
54
  # Hand off to the main Streamlit app.
55
  from src.web.streamlit_app import main # noqa: E402
 
14
  import time
15
  from pathlib import Path
16
 
17
+ import requests
18
+
19
  logging.basicConfig(level=logging.INFO)
20
 
21
  # Ensure repo root is importable regardless of where HF Spaces runs this.
 
47
  uvicorn.run(fastapi_app, host="127.0.0.1", port=8000, log_level="warning")
48
 
49
 
50
+ def _wait_for_api_ready(timeout_seconds: float = 20.0) -> bool:
51
+ """Poll the local health endpoint until API is ready or timeout elapses."""
52
+ deadline = time.time() + timeout_seconds
53
+ url = "http://127.0.0.1:8000/health"
54
+ while time.time() < deadline:
55
+ try:
56
+ resp = requests.get(url, timeout=1.5)
57
+ if resp.ok:
58
+ logging.info("FastAPI ready on %s", url)
59
+ return True
60
+ except Exception:
61
+ pass
62
+ time.sleep(0.5)
63
+ logging.warning("FastAPI did not become ready within %.1f seconds", timeout_seconds)
64
+ return False
65
+
66
+
67
  _api_thread = threading.Thread(target=_start_api, daemon=True)
68
  _api_thread.start()
69
 
70
+ # Wait for API readiness, but keep demo UI available even if API startup is slow.
71
+ _wait_for_api_ready()
72
 
73
  # Hand off to the main Streamlit app.
74
  from src.web.streamlit_app import main # noqa: E402
src/web/streamlit_app.py CHANGED
@@ -6,6 +6,7 @@ import os
6
 
7
  import requests
8
  import streamlit as st
 
9
  from src.utils.config import load_config, provider_api_key_env
10
  from src.web.ingestion_service import run_ingest, save_uploaded_files
11
 
@@ -34,6 +35,74 @@ _DEMO_QUESTIONS = [
34
  ]
35
 
36
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
37
  def _truthfulness_badge(score: float) -> str:
38
  """Return a coloured emoji label for a truthfulness score in [0, 1]."""
39
  if score >= 0.8:
@@ -212,18 +281,20 @@ def _render_query_tab() -> None:
212
  headers["X-API-Key"] = resolved_api_key
213
  with st.spinner("Running retrieval and generation..."):
214
  try:
215
- resp = requests.post(f"{API_BASE_URL}/query", json=payload, headers=headers, timeout=120)
216
- resp.raise_for_status()
217
- data = resp.json()
218
- except requests.HTTPError:
 
219
  msg = "Request failed."
220
  try:
221
- err = resp.json()
222
  detail = err.get("detail")
223
  if detail:
224
  msg = f"Request failed: {detail}"
225
  except Exception:
226
- msg = f"Request failed: {resp.status_code} {resp.reason}"
 
227
  st.error(msg)
228
  return
229
  except Exception as exc:
 
6
 
7
  import requests
8
  import streamlit as st
9
+ from src.core.rag_orchestrator import QueryRequest, QueryResponse, RAGOrchestrator
10
  from src.utils.config import load_config, provider_api_key_env
11
  from src.web.ingestion_service import run_ingest, save_uploaded_files
12
 
 
35
  ]
36
 
37
 
38
+ @st.cache_resource(show_spinner=False)
39
+ def _get_demo_orchestrator() -> RAGOrchestrator:
40
+ """Build one orchestrator per Streamlit process for demo in-process querying."""
41
+ return RAGOrchestrator(load_config("config.yaml"))
42
+
43
+
44
+ def _normalize_orchestrator_response(out: QueryResponse) -> dict:
45
+ retrieved = []
46
+ for item in out.retrieved:
47
+ legacy = item.to_legacy_dict()
48
+ retrieved.append(
49
+ {
50
+ "id": legacy["id"],
51
+ "score": float(legacy.get("score") or 0.0),
52
+ "source": str(legacy.get("source") or "hybrid"),
53
+ "confidence": float(legacy.get("confidence") or 0.0),
54
+ "metadata": dict(legacy.get("metadata") or {}),
55
+ "preview": (legacy.get("text") or "")[:240],
56
+ }
57
+ )
58
+
59
+ truthfulness = None
60
+ if out.truthfulness is not None:
61
+ truthfulness = {
62
+ "nli_faithfulness": out.truthfulness.nli_faithfulness,
63
+ "citation_groundedness": out.truthfulness.citation_groundedness,
64
+ "uncited_claims": out.truthfulness.uncited_claims,
65
+ "score": out.truthfulness.score,
66
+ }
67
+
68
+ return {
69
+ "query": out.query,
70
+ "provider": out.provider,
71
+ "model": out.model,
72
+ "answer": out.answer,
73
+ "processing_time_ms": out.processing_time_ms,
74
+ "cached": out.cached,
75
+ "validation_issues": out.validation_issues,
76
+ "citations": out.citations,
77
+ "retrieved": retrieved,
78
+ "truthfulness": truthfulness,
79
+ }
80
+
81
+
82
+ def _run_query_in_demo_mode(payload: dict) -> dict:
83
+ """Run query through shared orchestrator instead of localhost HTTP API."""
84
+ orchestrator = _get_demo_orchestrator()
85
+ req = QueryRequest(
86
+ query_text=str(payload.get("query", "")),
87
+ top_k=int(payload.get("top_k", 5)),
88
+ use_llm=bool(payload.get("use_llm", True)),
89
+ use_rerank=bool(payload.get("use_rerank", True)),
90
+ stream=bool(payload.get("stream", False)),
91
+ provider=str(payload.get("provider") or "") or None,
92
+ model=str(payload.get("model") or "") or None,
93
+ provider_api_key=str(payload.get("provider_api_key") or "") or None,
94
+ include_citations=bool(payload.get("include_citations", True)),
95
+ )
96
+ out = orchestrator.run(req)
97
+ return _normalize_orchestrator_response(out)
98
+
99
+
100
+ def _run_query_via_api(payload: dict, headers: dict) -> dict:
101
+ resp = requests.post(f"{API_BASE_URL}/query", json=payload, headers=headers, timeout=120)
102
+ resp.raise_for_status()
103
+ return resp.json()
104
+
105
+
106
  def _truthfulness_badge(score: float) -> str:
107
  """Return a coloured emoji label for a truthfulness score in [0, 1]."""
108
  if score >= 0.8:
 
281
  headers["X-API-Key"] = resolved_api_key
282
  with st.spinner("Running retrieval and generation..."):
283
  try:
284
+ if _DEMO_MODE:
285
+ data = _run_query_in_demo_mode(payload)
286
+ else:
287
+ data = _run_query_via_api(payload, headers)
288
+ except requests.HTTPError as exc:
289
  msg = "Request failed."
290
  try:
291
+ err = exc.response.json() if exc.response is not None else {}
292
  detail = err.get("detail")
293
  if detail:
294
  msg = f"Request failed: {detail}"
295
  except Exception:
296
+ if exc.response is not None:
297
+ msg = f"Request failed: {exc.response.status_code} {exc.response.reason}"
298
  st.error(msg)
299
  return
300
  except Exception as exc:
tests/unit/test_streamlit_demo_routing.py ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from types import SimpleNamespace
4
+
5
+ import src.web.streamlit_app as streamlit_app
6
+
7
+
8
+ class _DummyRetrieval:
9
+ def to_legacy_dict(self):
10
+ return {
11
+ "id": "chunk-1",
12
+ "score": 0.91,
13
+ "source": "hybrid",
14
+ "confidence": 0.88,
15
+ "metadata": {"title": "doc.md"},
16
+ "text": "Demo preview text",
17
+ }
18
+
19
+
20
+ def test_run_query_in_demo_mode_uses_inprocess_orchestrator(monkeypatch):
21
+ captured = {}
22
+
23
+ class _FakeOrchestrator:
24
+ def run(self, req):
25
+ captured["provider"] = req.provider
26
+ captured["model"] = req.model
27
+ captured["provider_api_key"] = req.provider_api_key
28
+ return SimpleNamespace(
29
+ query=req.query_text,
30
+ provider=req.provider or "openai",
31
+ model=req.model or "gpt-4o-mini",
32
+ answer="demo answer",
33
+ processing_time_ms=12.3,
34
+ cached=False,
35
+ validation_issues=[],
36
+ citations=[{"chunk_id": "chunk-1", "resolved": True, "verification_score": 0.9}],
37
+ retrieved=[_DummyRetrieval()],
38
+ truthfulness=SimpleNamespace(
39
+ nli_faithfulness=0.8,
40
+ citation_groundedness=0.9,
41
+ uncited_claims=0,
42
+ score=0.85,
43
+ ),
44
+ )
45
+
46
+ monkeypatch.setattr(streamlit_app, "_get_demo_orchestrator", lambda: _FakeOrchestrator())
47
+
48
+ out = streamlit_app._run_query_in_demo_mode(
49
+ {
50
+ "query": "what is rag?",
51
+ "provider": "openai",
52
+ "model": "gpt-4o-mini",
53
+ "provider_api_key": "provider-key",
54
+ "top_k": 5,
55
+ "include_citations": True,
56
+ }
57
+ )
58
+
59
+ assert captured["provider"] == "openai"
60
+ assert captured["model"] == "gpt-4o-mini"
61
+ assert captured["provider_api_key"] == "provider-key"
62
+ assert out["answer"] == "demo answer"
63
+ assert out["provider"] == "openai"
64
+ assert out["retrieved"][0]["id"] == "chunk-1"
65
+ assert out["truthfulness"]["score"] == 0.85
66
+
67
+
68
+ def test_run_query_via_api_uses_http_post(monkeypatch):
69
+ captured = {}
70
+
71
+ class _Resp:
72
+ def raise_for_status(self):
73
+ return None
74
+
75
+ def json(self):
76
+ return {"answer": "ok"}
77
+
78
+ def _fake_post(url, json, headers, timeout):
79
+ captured["url"] = url
80
+ captured["json"] = json
81
+ captured["headers"] = headers
82
+ captured["timeout"] = timeout
83
+ return _Resp()
84
+
85
+ monkeypatch.setattr(streamlit_app.requests, "post", _fake_post)
86
+
87
+ payload = {"query": "hello"}
88
+ headers = {"Content-Type": "application/json", "X-API-Key": "k"}
89
+ out = streamlit_app._run_query_via_api(payload, headers)
90
+
91
+ assert out["answer"] == "ok"
92
+ assert captured["url"] == f"{streamlit_app.API_BASE_URL}/query"
93
+ assert captured["json"] == payload
94
+ assert captured["headers"] == headers
95
+ assert captured["timeout"] == 120