Spaces:
Sleeping
Sleeping
Vamshi Pokala commited on
Commit ·
e40f786
1
Parent(s): 5976cc0
fix demo mode query routing isolation
Browse filesRoute 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 +3 -0
- spaces/README.md +2 -0
- spaces/app.py +21 -2
- src/web/streamlit_app.py +77 -6
- tests/unit/test_streamlit_demo_routing.py +95 -0
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 |
-
#
|
| 52 |
-
|
| 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 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
|
|
|
| 219 |
msg = "Request failed."
|
| 220 |
try:
|
| 221 |
-
err =
|
| 222 |
detail = err.get("detail")
|
| 223 |
if detail:
|
| 224 |
msg = f"Request failed: {detail}"
|
| 225 |
except Exception:
|
| 226 |
-
|
|
|
|
| 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
|