from __future__ import annotations
try:
import spaces
except ImportError:
spaces = None
import html
import os
import threading
import uuid
from pathlib import Path
from typing import Any
import gradio as gr
from config import (
APP_TITLE,
DEFAULT_TOP_K,
EMBEDDING_MODEL_ID,
FAR_DATASET_REPO,
HF_INFERENCE_MODEL,
HF_TOKEN,
LOCAL_MODEL_ID,
MAX_UPLOAD_MB,
)
from document_loader import load_file
from rag_engine import (
KnowledgeBase,
answer_question,
load_far_dataset,
render_sources,
)
SESSIONS: dict[str, KnowledgeBase] = {}
SESSIONS_LOCK = threading.Lock()
def _session(sid: str | None) -> tuple[str, KnowledgeBase]:
sid = sid or uuid.uuid4().hex
with SESSIONS_LOCK:
if sid not in SESSIONS:
SESSIONS[sid] = KnowledgeBase()
return sid, SESSIONS[sid]
def _status(kb: KnowledgeBase, message: str = "Ready") -> str:
return f"""
{html.escape(message)}
{kb.document_count} source document(s) · {len(kb.chunks):,} indexed chunks
"""
def index_uploads(files: list[Any] | None, sid: str | None):
sid, kb = _session(sid)
if not files:
return sid, _status(kb, "No files selected"), {"documents": kb.document_count, "chunks": len(kb.chunks)}
added = 0
names = []
for uploaded in files:
path = Path(uploaded.name if hasattr(uploaded, "name") else str(uploaded))
size_mb = path.stat().st_size / (1024 * 1024)
if size_mb > MAX_UPLOAD_MB:
raise gr.Error(f"{path.name} is {size_mb:.1f} MB. Limit is {MAX_UPLOAD_MB} MB per file.")
try:
chunks = load_file(path)
added += kb.add(chunks)
names.append(path.name)
except Exception as exc:
raise gr.Error(f"Could not index {path.name}: {exc}") from exc
message = f"Indexed {added:,} chunks from {len(names)} file(s)"
diagnostics = {
"documents": kb.document_count,
"chunks": len(kb.chunks),
"added_chunks": added,
"files": names,
"embedding_model": EMBEDDING_MODEL_ID,
}
return sid, _status(kb, message), diagnostics
def _build_far_suggestions(chunks, limit: int = 8) -> list[str]:
"""Build question suggestions from topics actually present in loaded FAR chunks."""
corpus = "\n".join(chunk.text.lower() for chunk in chunks)
topic_questions = [
(("market research",), "What does the FAR require for market research?"),
(("acquisition planning",), "What does the FAR require for acquisition planning?"),
(("full and open competition", "competition requirements"), "What does the FAR require for full and open competition?"),
(("commercial products", "commercial services"), "What FAR requirements apply to commercial products and commercial services?"),
(("contracting officer",), "What responsibilities and authorities does the FAR assign to contracting officers?"),
(("small business",), "What does the FAR require agencies to consider regarding small business participation?"),
(("past performance",), "How does the FAR address contractor past performance?"),
(("sole source", "other than full and open competition"), "When does the FAR permit an acquisition without full and open competition?"),
(("contract modification", "modifications"), "What does the FAR say about contract modifications?"),
(("termination for convenience", "termination for default"), "What does the FAR say about contract termination?"),
]
suggestions: list[str] = []
for keywords, question in topic_questions:
if any(keyword in corpus for keyword in keywords):
suggestions.append(question)
if len(suggestions) >= limit:
break
# Fallback: derive section-specific questions from section metadata if needed
if len(suggestions) < limit:
seen_sections: set[str] = set()
for chunk in chunks:
section = (chunk.section or "").strip()
if not section or section in seen_sections:
continue
seen_sections.add(section)
suggestions.append(f"What requirements are described in {section}?")
if len(suggestions) >= limit:
break
return suggestions[:limit]
def load_far(sid: str | None, row_limit: int):
sid, kb = _session(sid)
try:
chunks = load_far_dataset(max_rows=int(row_limit))
added = kb.add(chunks)
suggestions = _build_far_suggestions(chunks)
except Exception as exc:
raise gr.Error(f"FAR starter load failed: {exc}") from exc
diagnostics = {
"documents": kb.document_count,
"chunks": len(kb.chunks),
"added_far_chunks": added,
"dataset": FAR_DATASET_REPO,
"embedding_model": EMBEDDING_MODEL_ID,
"suggested_questions": len(suggestions),
}
suggestion_component = gr.Radio(
choices=suggestions,
value=None,
label="Suggested questions from the loaded FAR corpus",
info="These appear only after FAR chunks are loaded and are selected from topics found in the indexed corpus.",
interactive=True,
visible=True,
elem_id="far-suggestions",
)
return (
sid,
_status(kb, f"Loaded FAR starter corpus: {added:,} chunks"),
diagnostics,
suggestion_component,
)
def clear_kb(sid: str | None):
sid, kb = _session(sid)
kb.clear()
hidden_suggestions = gr.Radio(
choices=[],
value=None,
label="Suggested questions from the loaded FAR corpus",
interactive=True,
visible=False,
elem_id="far-suggestions",
)
return (
sid,
_status(kb, "Knowledge base cleared"),
{},
"",
"No sources retrieved.
",
hidden_suggestions,
)
def ask(question: str, sid: str | None, top_k: int, temperature: float, max_new_tokens: int):
clean = (question or "").strip()
if not clean:
return "Enter a policy question.", "No sources retrieved.
", {}
sid, kb = _session(sid)
if not kb.chunks:
return (
"Index uploaded documents or load the FAR starter corpus first.",
"No sources retrieved.
",
{"documents": 0, "chunks": 0},
)
hits = kb.search(clean, top_k=int(top_k))
answer, diagnostics = answer_question(
clean,
hits,
temperature=float(temperature),
max_new_tokens=int(max_new_tokens),
)
diagnostics.update({"documents": kb.document_count, "indexed_chunks": len(kb.chunks)})
return answer, render_sources(hits), diagnostics
CSS = r"""
:root {
--bg: #05070a;
--surface: rgba(10, 14, 20, .94);
--surface-2: rgba(16, 23, 34, .92);
--surface-3: rgba(255,255,255,.035);
--line: rgba(255,255,255,.12);
--line-strong: rgba(255,255,255,.22);
--muted: #aab3c2;
--text: #f7f9fc;
--navy: #12345b;
--navy-2: #0a2747;
--red: #b3263f;
--red-2: #8f1830;
--white: #ffffff;
--success: #8fd6ac;
--danger: #ff9eaa;
}
html, body {
background: var(--bg) !important;
}
.gradio-container {
max-width: 1500px !important;
margin: 0 auto !important;
color: var(--text) !important;
background:
radial-gradient(circle at 12% 4%, rgba(18,52,91,.34), transparent 31%),
radial-gradient(circle at 88% 10%, rgba(179,38,63,.13), transparent 26%),
linear-gradient(145deg,#040609 0%,#090d13 46%,#0b111a 100%) !important;
min-height: 100vh;
}
.main-shell {
padding: 28px 24px 44px;
}
.hero {
position: relative;
overflow: hidden;
border: 1px solid var(--line);
background:
linear-gradient(135deg,rgba(11,20,32,.98),rgba(5,8,13,.96));
border-radius: 22px;
padding: 32px 34px 34px;
box-shadow: 0 28px 80px rgba(0,0,0,.42);
margin-bottom: 20px;
}
.hero::before {
content: "";
position: absolute;
inset: 0 auto auto 0;
width: 100%;
height: 4px;
background: linear-gradient(90deg,#ffffff 0 34%,#12345b 34% 67%,#b3263f 67% 100%);
opacity: .95;
}
.hero::after {
content: "";
position: absolute;
width: 430px;
height: 430px;
right: -210px;
top: -210px;
border: 1px solid rgba(255,255,255,.07);
border-radius: 50%;
box-shadow:
0 0 0 54px rgba(18,52,91,.08),
0 0 0 108px rgba(179,38,63,.035);
}
.eyebrow {
position: relative;
z-index: 1;
color: #c5d6ea;
font-size: 12px;
font-weight: 900;
letter-spacing: .17em;
text-transform: uppercase;
}
.hero h1 {
position: relative;
z-index: 1;
margin: 9px 0 8px;
font-size: clamp(34px,5vw,58px);
line-height: 1.02;
letter-spacing: -.045em;
color: #ffffff;
}
.gradient-word {
background: linear-gradient(105deg,#ffffff 0%,#d7e4f2 45%,#8aa8c8 100%);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.hero p {
position: relative;
z-index: 1;
max-width: 940px;
color: #b6c0cf;
font-size: 16px;
line-height: 1.65;
margin: 0;
}
.badges {
position: relative;
z-index: 1;
display: flex;
flex-wrap: wrap;
gap: 9px;
margin-top: 19px;
}
.badge {
border: 1px solid rgba(255,255,255,.13);
background: rgba(255,255,255,.04);
padding: 7px 11px;
border-radius: 999px;
color: #bcc7d6;
font-size: 12px;
backdrop-filter: blur(8px);
}
.badge strong {
color: #ffffff;
margin-right: 4px;
}
.app-panel {
background: var(--surface) !important;
border: 1px solid var(--line) !important;
border-radius: 20px !important;
box-shadow: 0 20px 55px rgba(0,0,0,.30);
overflow: hidden;
padding-top: 14px !important;
}
.sidebar-card {
background: var(--surface-2);
border: 1px solid var(--line);
border-radius: 17px;
padding: 18px;
margin-bottom: 14px;
box-shadow: inset 0 1px 0 rgba(255,255,255,.025);
}
.sidebar-card h3 {
margin: 0 0 8px;
color: #ffffff;
font-size: 14px;
}
.sidebar-card p,
.sidebar-card li {
color: var(--muted);
font-size: 13px;
line-height: 1.58;
}
.sidebar-card ol {
margin: 9px 0 0;
padding-left: 20px;
}
.status-card {
border: 1px solid var(--line);
border-left: 3px solid #587da5;
background: rgba(255,255,255,.025);
border-radius: 13px;
padding: 12px 14px;
color: #b5c0cf;
font-size: 12px;
line-height: 1.55;
}
.status-card strong {
color: #ffffff;
}
#ask-button,
#index-button,
#far-button {
font-weight: 900;
}
button.primary,
#ask-button,
#index-button,
#far-button {
background: linear-gradient(135deg,#173d68,#0b2949) !important;
color: #ffffff !important;
border: 1px solid rgba(157,190,225,.30) !important;
box-shadow: 0 8px 24px rgba(5,18,34,.30) !important;
}
button.primary:hover,
#ask-button:hover,
#index-button:hover,
#far-button:hover {
filter: brightness(1.12);
border-color: rgba(255,255,255,.30) !important;
}
/* Federal-style tab navigation. Build KB is the left/default tab. */
#main-tabs [role="tablist"] {
padding: 14px 22px 16px !important;
gap: 12px !important;
border-bottom: 1px solid var(--line) !important;
background: rgba(255,255,255,.012) !important;
}
#main-tabs button[role="tab"] {
margin: 0 !important;
padding: 11px 18px !important;
min-height: 44px !important;
border: 1px solid rgba(255,255,255,.09) !important;
border-radius: 11px !important;
color: #c0cad8 !important;
background: rgba(255,255,255,.025) !important;
font-weight: 800 !important;
}
#main-tabs button[role="tab"][aria-selected="true"] {
color: #ffffff !important;
background: linear-gradient(135deg,#15375f,#0b2949) !important;
border-color: rgba(158,191,226,.28) !important;
box-shadow: inset 0 -2px 0 #b3263f, 0 8px 18px rgba(0,0,0,.18) !important;
}
.tab-body {
padding: 24px 22px 26px !important;
}
/* Keep main Ask action compact and centered. */
.ask-action-row {
justify-content: center !important;
align-items: center !important;
gap: 12px !important;
margin: 10px 0 5px !important;
}
#ask-button {
flex: 0 0 auto !important;
width: 240px !important;
min-width: 210px !important;
max-width: 260px !important;
}
.clear-answer-button {
flex: 0 0 auto !important;
width: 128px !important;
min-width: 118px !important;
max-width: 140px !important;
}
#far-suggestions {
margin-top: 16px !important;
padding-top: 5px !important;
border-top: 1px solid rgba(255,255,255,.08) !important;
}
.answer-panel {
min-height: 300px;
}
.sources-grid {
display: grid;
grid-template-columns: repeat(auto-fit,minmax(260px,1fr));
gap: 10px;
margin-top: 4px;
}
.source-card {
border: 1px solid var(--line);
background: rgba(255,255,255,.025);
border-radius: 14px;
padding: 13px;
}
.source-top {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.source-id {
display: inline-flex;
min-width: 34px;
justify-content: center;
border: 1px solid rgba(255,255,255,.20);
border-radius: 999px;
padding: 4px 8px;
color: #ffffff;
background: #173d68;
font-weight: 900;
font-size: 11px;
}
.score {
color: #8996a8;
font-size: 11px;
}
.source-title {
color: #f4f7fb;
font-weight: 800;
font-size: 12px;
line-height: 1.45;
margin-bottom: 7px;
}
.source-excerpt {
color: #aeb9c8;
font-size: 12px;
line-height: 1.55;
}
.source-link {
margin-top: 9px;
font-size: 11px;
}
.source-link a {
color: #9fc0e2 !important;
}
.footer-note {
color: #7f8b9c;
font-size: 11px;
text-align: center;
margin-top: 17px;
}
.warning-note {
margin-top: 14px;
padding: 11px 13px;
border: 1px solid rgba(179,38,63,.30);
border-left: 3px solid var(--red);
border-radius: 10px;
background: rgba(179,38,63,.06);
color: #c9cfd8;
font-size: 11px;
line-height: 1.55;
}
@media(max-width:800px) {
.main-shell { padding: 14px 10px 28px; }
.hero { padding: 24px 20px 25px; border-radius: 18px; }
.hero h1 { font-size: 37px; }
#main-tabs [role="tablist"] { padding: 12px 14px 14px !important; }
.tab-body { padding: 20px 16px 22px !important; }
}
"""
HEAD = """
"""
def build_app() -> gr.Blocks:
backend_label = f"HF Inference: {HF_INFERENCE_MODEL}" if HF_TOKEN else f"Local fallback: {LOCAL_MODEL_ID}"
hero = f"""
RAG · Federal Acquisition
Federal Policy RAG Assistant
Upload agency policies, directives, manuals, FAR/DFARS material, and related guidance. Retrieve the most relevant passages and answer with traceable source markers mapped to exact source cards.
Generator {html.escape(HF_INFERENCE_MODEL)}
Embeddings {html.escape(EMBEDDING_MODEL_ID)}
Index FAISS
Citations [S1] source mapping
"""
with gr.Blocks(title=APP_TITLE) as demo:
sid = gr.State("")
with gr.Column(elem_classes=["main-shell"]):
gr.HTML(hero)
with gr.Row(equal_height=False):
with gr.Column(scale=8, min_width=560, elem_classes=["app-panel"]):
with gr.Tabs(selected="build-kb", elem_id="main-tabs"):
# build first so the app opens in the ingestion workflow
with gr.Tab("Build Knowledge Base", id="build-kb"):
with gr.Column(elem_classes=["tab-body"]):
uploads = gr.File(
label="Upload policy documents",
file_count="multiple",
file_types=[".pdf", ".docx", ".txt", ".md", ".html", ".htm", ".csv", ".json", ".jsonl", ".yaml", ".yml"],
)
with gr.Row():
index_button = gr.Button("Index Uploaded Files", variant="primary", elem_id="index-button")
clear_kb_button = gr.Button("Clear Knowledge Base")
gr.Markdown("### Subjective Federal Corpus:")
far_rows = gr.Slider(250, 6000, value=2500, step=250, label="FAR starter chunks (amount of FAR text segments that load into vdb)")
far_button = gr.Button(
"Load FAR corpus (via Hugging Face)",
variant="primary",
elem_id="far-button",
)
suggested_questions = gr.Radio(
choices=[],
value=None,
label="Suggested questions from the loaded FAR corpus",
info="This list appears after indexing. Select a question to populate the Ask policy input.",
interactive=True,
visible=False,
elem_id="far-suggestions",
)
gr.HTML(
"Public Space warning: do not upload classified, CUI, source-selection-sensitive, procurement-sensitive, proprietary, or personal data to a public deployment.
"
)
# ask second so it appears on the right of Build knowledge base
with gr.Tab("Ask Policy", id="ask-policy"):
with gr.Column(elem_classes=["tab-body"]):
question = gr.Textbox(
label="Policy question",
placeholder="Example: Under the indexed FAR material, when is market research required and what should it address?",
lines=3,
max_lines=7,
)
with gr.Row(elem_classes=["ask-action-row"]):
ask_button = gr.Button(
"Ask with Citations",
variant="primary",
elem_id="ask-button",
scale=0,
min_width=210,
)
clear_answer = gr.Button(
"Clear Answer",
scale=0,
min_width=118,
elem_classes=["clear-answer-button"],
)
answer = gr.Markdown("", label="Grounded answer", elem_classes=["answer-panel"])
gr.Markdown("### Retrieved sources")
sources = gr.HTML("No sources retrieved.
")
with gr.Column(scale=5, min_width=360):
kb_status = gr.HTML("Ready
0 source documents · 0 indexed chunks
")
gr.HTML(
f"""
"""
)
with gr.Accordion("Retrieval controls", open=True):
top_k = gr.Slider(2, 12, value=DEFAULT_TOP_K, step=1, label="Retrieved passages")
temperature = gr.Slider(0.0, 0.8, value=0.15, step=0.05, label="Temperature")
max_new_tokens = gr.Slider(256, 1400, value=700, step=64, label="Maximum answer tokens")
with gr.Accordion("Diagnostics", open=False):
diagnostics = gr.JSON(value={}, label="RAG diagnostics")
gr.HTML(
"""
"""
)
gr.HTML("")
index_button.click(index_uploads, inputs=[uploads, sid], outputs=[sid, kb_status, diagnostics])
far_button.click(load_far, inputs=[sid, far_rows], outputs=[sid, kb_status, diagnostics, suggested_questions])
suggested_questions.change(
lambda selected: selected or "",
inputs=[suggested_questions],
outputs=[question],
)
clear_kb_button.click(clear_kb, inputs=[sid], outputs=[sid, kb_status, diagnostics, answer, sources, suggested_questions])
ask_button.click(ask, inputs=[question, sid, top_k, temperature, max_new_tokens], outputs=[answer, sources, diagnostics])
question.submit(ask, inputs=[question, sid, top_k, temperature, max_new_tokens], outputs=[answer, sources, diagnostics])
clear_answer.click(lambda: ("", "No sources retrieved.
", {}), outputs=[answer, sources, diagnostics])
return demo
if __name__ == "__main__":
app = build_app()
app.queue(default_concurrency_limit=2).launch(
server_name="0.0.0.0",
server_port=int(os.getenv("PORT", "7860")),
show_error=True,
ssr_mode=False,
theme=gr.themes.Base(),
css=CSS,
head=HEAD,
)