Upload folder using huggingface_hub
Browse files- NITDAA_ARCHITECTURE_DESIGN.md +21 -0
- NITDAA_HEALTHEXPERT_USER_GUIDE.md +31 -0
- NITDAA_HEALTHEXPERT_USER_GUIDE.pdf +3 -0
- NITDAA_HF_DEPLOYMENT_GUIDE.md +32 -0
- app.py +129 -23
- requirements.txt +1 -0
- requirements_hf.txt +1 -0
- templates/index.html +84 -2
NITDAA_ARCHITECTURE_DESIGN.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Nitdaa Sibling Project - Architecture Design
|
| 2 |
+
|
| 3 |
+
## 1. System Overview
|
| 4 |
+
Nitdaa is the mobile-friendly MVP sibling to the full HealthExpert enterprise platform. It removes administrative overhead, heavy RBAC UI components, and complex multi-pane layouts in favor of a lean, mobile-first edge interface.
|
| 5 |
+
|
| 6 |
+
## 2. Core Differences from Base HealthExpert
|
| 7 |
+
- **UI/UX**: Transitioned from a 3-pane desktop layout to a single-pane vertical mobile layout.
|
| 8 |
+
- **Admin Constraints**: Admin controls (Kill Switch, DB Purge) are entirely hidden in the HTML to prevent public tampering.
|
| 9 |
+
- **Session Audit**: Uses `nitdaa_sessions.json` and `nitdaa_summary.json` for granular system telemetry rather than database-backed logs.
|
| 10 |
+
|
| 11 |
+
## 3. Data Flow
|
| 12 |
+
1. **Startup**: Flask initializes. `sync_he_data()` runs asynchronously to pull the latest dataset from `Sam-max1/he_data` using `huggingface_hub`.
|
| 13 |
+
2. **Ingestion**: If new files are detected, `process_document_pipeline` splits them into 512-token chunks, vectorizes them into ChromaDB, and extracts graph entities locally using `spaCy`.
|
| 14 |
+
3. **Retrieval**: When a query hits `/api/query`, it triggers Tri-modal Hybrid Retrieval (Vector + BM25 + Graph).
|
| 15 |
+
4. **Synthesis**: A Cross-Encoder reranks the chunks, and the context is fed into a reasoning-distilled tiny model (e.g., `Jackrong/Qwen3.5-2B-Claude-4.6-Opus-Reasoning-Distilled-GGUF`) running on `llama-cpp-python`.
|
| 16 |
+
|
| 17 |
+
## 4. Resource Allocation
|
| 18 |
+
Nitdaa is tuned for the HuggingFace Spaces free tier:
|
| 19 |
+
- **vCPU constraints**: Threads are explicitly clamped to prevent CPU thrashing.
|
| 20 |
+
- **Vector Limit**: A hard limit of 10,000 chunks is enforced globally to prevent `OutOfMemory` crashes.
|
| 21 |
+
- **Session Quotas**: Max 5 file uploads per session to prevent abuse.
|
NITDAA_HEALTHEXPERT_USER_GUIDE.md
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Nitdaa HealthExpert Mobile MVP - User Guide
|
| 2 |
+
|
| 3 |
+
## Overview
|
| 4 |
+
Nitdaa HealthExpert is a mobile-optimized, single-pane web interface designed to provide users with direct access to the HealthExpert AI knowledge base. Deployed on HuggingFace Spaces, Nitdaa brings the power of Agentic AI, Hybrid RAG, and Edge Compute into a sandboxed, responsive environment.
|
| 5 |
+
|
| 6 |
+
## 1. Accessing the Application
|
| 7 |
+
- Navigate to the HuggingFace Spaces URL provided by your administrator (e.g., `https://huggingface.co/spaces/Sam-max1/nitdaa`).
|
| 8 |
+
- The application will load automatically. Note that the first boot may take 3-5 minutes as the system pulls the latest knowledge datasets securely from the cloud.
|
| 9 |
+
- Once the **Status Pill** in the top-right corner turns **Green** (Ready), the system is online.
|
| 10 |
+
|
| 11 |
+
## 2. Using the Dashboard
|
| 12 |
+
The dashboard is designed for mobile efficiency.
|
| 13 |
+
- **Top Header**: Displays the application branding ("Healthcare Policy Expert") and current status.
|
| 14 |
+
- **Scrollable Output Window**: The large central area displays the conversation history. It automatically scrolls down as new answers are generated.
|
| 15 |
+
- **Input Area**: Located at the bottom of the screen.
|
| 16 |
+
|
| 17 |
+
## 3. Asking Questions
|
| 18 |
+
You can query the system in two ways:
|
| 19 |
+
1. **Quick Questions Dropdown**: Tap the dropdown menu above the text input to select a preset question. Selecting a question will automatically submit it to the AI.
|
| 20 |
+
2. **Manual Input**: Type your query directly into the text box and press the **Send** button.
|
| 21 |
+
|
| 22 |
+
## 4. Understanding Output
|
| 23 |
+
When a question is submitted:
|
| 24 |
+
- Your question will instantly appear in the output window.
|
| 25 |
+
- The system will display a **"Generating answer.. (1.2s)"** timer.
|
| 26 |
+
- Once context is retrieved from the Vector and Graph databases, the LLM will stream the Markdown-formatted answer back to the screen.
|
| 27 |
+
- Responses remain preserved in the scrollable window for the duration of your session.
|
| 28 |
+
|
| 29 |
+
## 5. Security & Session Handling
|
| 30 |
+
- **Multi-tenant Isolation**: Your conversation is strictly isolated from other users using an invisible `X-Session-Token`.
|
| 31 |
+
- **Session Expiration**: If the page is reloaded, or if you are inactive for an extended period, your session token resets, and previous context is cleared to ensure privacy.
|
NITDAA_HEALTHEXPERT_USER_GUIDE.pdf
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:24283966d70a60ceef384e6a772e1e52106a7ce15beda33aba30e7480af3088b
|
| 3 |
+
size 186919
|
NITDAA_HF_DEPLOYMENT_GUIDE.md
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Nitdaa Sibling Project - HF Deployment Guide
|
| 2 |
+
|
| 3 |
+
## Overview
|
| 4 |
+
This document outlines the specific steps and constraints required to deploy the Nitdaa mobile sibling to HuggingFace Spaces free tier.
|
| 5 |
+
|
| 6 |
+
## 1. Prerequisites
|
| 7 |
+
- A HuggingFace account.
|
| 8 |
+
- A HuggingFace Space configured with the **Docker** SDK.
|
| 9 |
+
- Hardware allocation: Free Tier (2 vCPU, 16GB Disk, 12GB RAM).
|
| 10 |
+
|
| 11 |
+
## 2. HuggingFace Secrets Configuration
|
| 12 |
+
To enable dynamic Dataset Synchronization, you must configure the following variable in your Space's Settings under **Variables and Secrets**:
|
| 13 |
+
- `HF_TOKEN`: A HuggingFace access token with "Read" permissions to the `he_data` dataset.
|
| 14 |
+
- `ADMIN_MODE`: Set to `0` to completely lock down the UI for public usage.
|
| 15 |
+
|
| 16 |
+
## 3. Deployment Steps
|
| 17 |
+
1. Clone the `nitdaa` folder locally.
|
| 18 |
+
2. Initialize a git repository pointing to your HF Space remote.
|
| 19 |
+
3. Use the HuggingFace CLI to upload large directories without timing out:
|
| 20 |
+
```bash
|
| 21 |
+
huggingface-cli upload <your_username>/nitdaa . . --repo-type space
|
| 22 |
+
```
|
| 23 |
+
4. HuggingFace will automatically detect the `Dockerfile` in the root and begin building.
|
| 24 |
+
|
| 25 |
+
## 4. Cold Boot Times
|
| 26 |
+
Due to the CPU-bound nature of the free tier:
|
| 27 |
+
- **Docker Build**: ~5-8 minutes (first boot only).
|
| 28 |
+
- **Dataset Sync**: Variable depending on the size of `he_data`.
|
| 29 |
+
- **Model Load**: The `Qwen3.5-2B` GGUF model and PyTorch embeddings take ~1-2 minutes to spin up into memory on boot.
|
| 30 |
+
|
| 31 |
+
## 5. Security Safeguards
|
| 32 |
+
Nitdaa implements headless API blocking. Any direct programmatic access to the REST endpoints (without proper CORS headers or session tokens) will automatically return `403 Forbidden` to prevent bot-net abuse on the public URL.
|
app.py
CHANGED
|
@@ -80,6 +80,57 @@ app.secret_key = config.SECRET_KEY
|
|
| 80 |
app.config["MAX_CONTENT_LENGTH"] = config.MAX_CONTENT_LENGTH
|
| 81 |
os.makedirs(config.UPLOAD_FOLDER, exist_ok=True)
|
| 82 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
# In-memory job tracker for async ingestion
|
| 84 |
_jobs: dict[str, dict] = {}
|
| 85 |
_active_graph_tasks = 0
|
|
@@ -154,6 +205,8 @@ def log_session(event_type: str, token: str, ip: str):
|
|
| 154 |
entry = {"timestamp": ts, "event": event_type, "session_token": token, "ip_address": ip}
|
| 155 |
with open(session_file, "a") as f:
|
| 156 |
f.write(json.dumps(entry) + "\n")
|
|
|
|
|
|
|
| 157 |
except Exception as e:
|
| 158 |
log.error(f"Failed to log session: {e}")
|
| 159 |
|
|
@@ -181,6 +234,8 @@ def log_query_summary(token: str, ip: str, query: str, chunks_retrieved: int, ge
|
|
| 181 |
}
|
| 182 |
with open(summary_file, "a") as f:
|
| 183 |
f.write(json.dumps(entry) + "\n")
|
|
|
|
|
|
|
| 184 |
except Exception as e:
|
| 185 |
log.error(f"Failed to log query summary: {e}")
|
| 186 |
|
|
@@ -642,58 +697,108 @@ def process_document_pipeline(path: str, orig_name: str, tier: str, token: str,
|
|
| 642 |
|
| 643 |
def sync_he_data():
|
| 644 |
try:
|
| 645 |
-
import os
|
| 646 |
from huggingface_hub import snapshot_download
|
| 647 |
import keyring
|
| 648 |
|
| 649 |
token = os.environ.get("HF_TOKEN")
|
| 650 |
if not token:
|
| 651 |
try:
|
| 652 |
-
token = keyring.get_password('huggingface', '
|
| 653 |
except Exception:
|
| 654 |
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 655 |
|
| 656 |
kbdocs_dir = Path(__file__).parent / "kbdocs"
|
| 657 |
kbdocs_dir.mkdir(exist_ok=True)
|
| 658 |
|
| 659 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 660 |
snapshot_download(
|
| 661 |
-
repo_id="Sam-max1/
|
| 662 |
repo_type="dataset",
|
| 663 |
-
local_dir=str(
|
| 664 |
token=token,
|
| 665 |
ignore_patterns=[".git*"]
|
| 666 |
)
|
| 667 |
-
log.info("Successfully synced he_data repository.")
|
| 668 |
|
| 669 |
-
from pipeline import vector_store
|
| 670 |
|
| 671 |
-
|
| 672 |
-
|
|
|
|
| 673 |
|
| 674 |
-
|
| 675 |
-
|
| 676 |
-
|
| 677 |
-
|
| 678 |
-
|
| 679 |
-
|
| 680 |
-
|
| 681 |
-
|
| 682 |
-
log.info("=== Full Data Ingestion Complete ===")
|
| 683 |
-
log.info(f"Vector DB Chunks: {vector_store.count()}")
|
| 684 |
|
| 685 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 686 |
if graph_store.is_available():
|
| 687 |
stats = graph_store.get_stats()
|
| 688 |
log.info(f"Kuzu DB Nodes: {stats.get('nodes', 0)}, Edges: {stats.get('edges', 0)}")
|
| 689 |
|
| 690 |
except Exception as e:
|
| 691 |
-
log.error("Failed to sync
|
| 692 |
|
| 693 |
threading.Thread(target=sync_he_data, daemon=True).start()
|
| 694 |
|
| 695 |
-
|
| 696 |
@app.route("/api/ingest", methods=["POST"])
|
|
|
|
| 697 |
def ingest():
|
| 698 |
"""Upload and asynchronously ingest one or more documents."""
|
| 699 |
log.info("Ingest request received. Files in request: %s",
|
|
@@ -818,10 +923,11 @@ def delete_document(source_name: str):
|
|
| 818 |
# ── Query ─────────────────────────────────────────────────────────────────────
|
| 819 |
|
| 820 |
@app.route("/api/query", methods=["POST"])
|
|
|
|
| 821 |
def query():
|
| 822 |
"""RAG query — returns a streaming SSE response."""
|
| 823 |
data = request.get_json()
|
| 824 |
-
q = (data or {}).get("query", "").strip()
|
| 825 |
top_k = (data or {}).get("top_k")
|
| 826 |
max_tokens = (data or {}).get("max_tokens")
|
| 827 |
use_vector = (data or {}).get("use_vector", True)
|
|
|
|
| 80 |
app.config["MAX_CONTENT_LENGTH"] = config.MAX_CONTENT_LENGTH
|
| 81 |
os.makedirs(config.UPLOAD_FOLDER, exist_ok=True)
|
| 82 |
|
| 83 |
+
from flask_limiter import Limiter
|
| 84 |
+
from flask_limiter.util import get_remote_address
|
| 85 |
+
from werkzeug.utils import escape
|
| 86 |
+
from huggingface_hub import HfApi, hf_hub_download
|
| 87 |
+
|
| 88 |
+
# Configure Rate Limiter (Memory Storage for MVP)
|
| 89 |
+
limiter = Limiter(
|
| 90 |
+
get_remote_address,
|
| 91 |
+
app=app,
|
| 92 |
+
default_limits=["200 per day", "50 per hour"],
|
| 93 |
+
storage_uri="memory://"
|
| 94 |
+
)
|
| 95 |
+
|
| 96 |
+
@app.after_request
|
| 97 |
+
def add_security_headers(response):
|
| 98 |
+
response.headers['X-Content-Type-Options'] = 'nosniff'
|
| 99 |
+
response.headers['X-Frame-Options'] = 'SAMEORIGIN'
|
| 100 |
+
response.headers['Strict-Transport-Security'] = 'max-age=31536000; includeSubDomains'
|
| 101 |
+
response.headers['Content-Security-Policy'] = "default-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.jsdelivr.net;"
|
| 102 |
+
return response
|
| 103 |
+
|
| 104 |
+
# Global background API for dataset upload
|
| 105 |
+
_hf_api = None
|
| 106 |
+
def get_hf_api():
|
| 107 |
+
global _hf_api
|
| 108 |
+
if _hf_api is None:
|
| 109 |
+
import keyring
|
| 110 |
+
token = os.environ.get("HF_TOKEN")
|
| 111 |
+
if not token:
|
| 112 |
+
try:
|
| 113 |
+
token = keyring.get_password('huggingface', 'hf_token') or keyring.get_password('huggingface', 'my_user')
|
| 114 |
+
except: pass
|
| 115 |
+
if token:
|
| 116 |
+
_hf_api = HfApi(token=token)
|
| 117 |
+
return _hf_api
|
| 118 |
+
|
| 119 |
+
def async_sync_log(local_path: str, repo_path: str):
|
| 120 |
+
def _upload():
|
| 121 |
+
api = get_hf_api()
|
| 122 |
+
if api:
|
| 123 |
+
try:
|
| 124 |
+
api.upload_file(
|
| 125 |
+
path_or_fileobj=local_path,
|
| 126 |
+
path_in_repo=repo_path,
|
| 127 |
+
repo_id="Sam-max1/mat_data",
|
| 128 |
+
repo_type="dataset"
|
| 129 |
+
)
|
| 130 |
+
except Exception as e:
|
| 131 |
+
log.warning(f"Failed to push {repo_path} to mat_data: {e}")
|
| 132 |
+
threading.Thread(target=_upload, daemon=True).start()
|
| 133 |
+
|
| 134 |
# In-memory job tracker for async ingestion
|
| 135 |
_jobs: dict[str, dict] = {}
|
| 136 |
_active_graph_tasks = 0
|
|
|
|
| 205 |
entry = {"timestamp": ts, "event": event_type, "session_token": token, "ip_address": ip}
|
| 206 |
with open(session_file, "a") as f:
|
| 207 |
f.write(json.dumps(entry) + "\n")
|
| 208 |
+
|
| 209 |
+
async_sync_log(str(session_file), "nitdaa_sessions.json")
|
| 210 |
except Exception as e:
|
| 211 |
log.error(f"Failed to log session: {e}")
|
| 212 |
|
|
|
|
| 234 |
}
|
| 235 |
with open(summary_file, "a") as f:
|
| 236 |
f.write(json.dumps(entry) + "\n")
|
| 237 |
+
|
| 238 |
+
async_sync_log(str(summary_file), "nitdaa_summary.json")
|
| 239 |
except Exception as e:
|
| 240 |
log.error(f"Failed to log query summary: {e}")
|
| 241 |
|
|
|
|
| 697 |
|
| 698 |
def sync_he_data():
|
| 699 |
try:
|
| 700 |
+
import os, shutil
|
| 701 |
from huggingface_hub import snapshot_download
|
| 702 |
import keyring
|
| 703 |
|
| 704 |
token = os.environ.get("HF_TOKEN")
|
| 705 |
if not token:
|
| 706 |
try:
|
| 707 |
+
token = keyring.get_password('huggingface', 'hf_token') or keyring.get_password('huggingface', 'my_user')
|
| 708 |
except Exception:
|
| 709 |
pass
|
| 710 |
+
|
| 711 |
+
# --- 2-Way Log Sync on Startup ---
|
| 712 |
+
log_dir = Path(__file__).parent / "app" / "logs"
|
| 713 |
+
log_dir.mkdir(parents=True, exist_ok=True)
|
| 714 |
+
try:
|
| 715 |
+
if token:
|
| 716 |
+
for log_file in ["nitdaa_sessions.json", "nitdaa_summary.json"]:
|
| 717 |
+
local_p = log_dir / log_file
|
| 718 |
+
try:
|
| 719 |
+
dl_path = hf_hub_download(repo_id="Sam-max1/mat_data", filename=log_file, repo_type="dataset", token=token)
|
| 720 |
+
if os.path.exists(dl_path):
|
| 721 |
+
# Append any existing local logs not in the remote file to the remote file, then replace local
|
| 722 |
+
remote_lines = set(open(dl_path).readlines())
|
| 723 |
+
if local_p.exists():
|
| 724 |
+
for line in open(local_p).readlines():
|
| 725 |
+
if line not in remote_lines:
|
| 726 |
+
remote_lines.add(line)
|
| 727 |
+
with open(local_p, "w") as f:
|
| 728 |
+
for line in sorted(list(remote_lines)):
|
| 729 |
+
f.write(line)
|
| 730 |
+
log.info(f"Successfully merged {log_file} from mat_data.")
|
| 731 |
+
except Exception as e:
|
| 732 |
+
log.warning(f"Could not download {log_file} from mat_data (it may not exist yet): {e}")
|
| 733 |
+
except Exception as e:
|
| 734 |
+
log.warning(f"Log sync failed: {e}")
|
| 735 |
+
# ---------------------------------
|
| 736 |
|
| 737 |
kbdocs_dir = Path(__file__).parent / "kbdocs"
|
| 738 |
kbdocs_dir.mkdir(exist_ok=True)
|
| 739 |
|
| 740 |
+
tmp_sync_dir = Path("/tmp/he_data_sync")
|
| 741 |
+
if tmp_sync_dir.exists():
|
| 742 |
+
shutil.rmtree(tmp_sync_dir)
|
| 743 |
+
tmp_sync_dir.mkdir(exist_ok=True)
|
| 744 |
+
|
| 745 |
+
log.info("Syncing fresh files from Sam-max1/he-data to local /tmp...")
|
| 746 |
snapshot_download(
|
| 747 |
+
repo_id="Sam-max1/he-data",
|
| 748 |
repo_type="dataset",
|
| 749 |
+
local_dir=str(tmp_sync_dir),
|
| 750 |
token=token,
|
| 751 |
ignore_patterns=[".git*"]
|
| 752 |
)
|
|
|
|
| 753 |
|
| 754 |
+
from pipeline import vector_store, graph_store
|
| 755 |
|
| 756 |
+
# Determine if there are differences between kbdocs and the downloaded he-data
|
| 757 |
+
local_files = {f.name: f.stat().st_size for f in kbdocs_dir.glob("*.*") if f.is_file()}
|
| 758 |
+
remote_files = {f.name: f.stat().st_size for f in tmp_sync_dir.glob("*.*") if f.is_file()}
|
| 759 |
|
| 760 |
+
is_different = False
|
| 761 |
+
if set(local_files.keys()) != set(remote_files.keys()):
|
| 762 |
+
is_different = True
|
| 763 |
+
else:
|
| 764 |
+
for k in local_files:
|
| 765 |
+
if local_files[k] != remote_files[k]:
|
| 766 |
+
is_different = True
|
| 767 |
+
break
|
|
|
|
|
|
|
| 768 |
|
| 769 |
+
if is_different:
|
| 770 |
+
log.info("Detected changes in Sam-max1/he-data! Purging databases and re-syncing kbdocs.")
|
| 771 |
+
# Clear DBs
|
| 772 |
+
vector_store.purge()
|
| 773 |
+
if graph_store.is_available():
|
| 774 |
+
graph_store.purge()
|
| 775 |
+
|
| 776 |
+
# Wipe local kbdocs and replace
|
| 777 |
+
shutil.rmtree(kbdocs_dir)
|
| 778 |
+
shutil.copytree(tmp_sync_dir, kbdocs_dir)
|
| 779 |
+
|
| 780 |
+
ingested_count = 0
|
| 781 |
+
for file_path in kbdocs_dir.glob("*.*"):
|
| 782 |
+
if file_path.is_file():
|
| 783 |
+
log.info(f"Auto-ingesting file: {file_path.name}")
|
| 784 |
+
process_document_pipeline(str(file_path), file_path.name, "foundation", "admin", delete_after=False)
|
| 785 |
+
ingested_count += 1
|
| 786 |
+
log.info("=== Full Data Re-Ingestion Complete ===")
|
| 787 |
+
else:
|
| 788 |
+
log.info("kbdocs is completely up to date with he-data. No ingestion needed.")
|
| 789 |
+
|
| 790 |
+
log.info(f"Vector DB Chunks: {vector_store.count()}")
|
| 791 |
if graph_store.is_available():
|
| 792 |
stats = graph_store.get_stats()
|
| 793 |
log.info(f"Kuzu DB Nodes: {stats.get('nodes', 0)}, Edges: {stats.get('edges', 0)}")
|
| 794 |
|
| 795 |
except Exception as e:
|
| 796 |
+
log.error("Failed to sync he-data: %s", e)
|
| 797 |
|
| 798 |
threading.Thread(target=sync_he_data, daemon=True).start()
|
| 799 |
|
|
|
|
| 800 |
@app.route("/api/ingest", methods=["POST"])
|
| 801 |
+
@limiter.limit("50 per hour")
|
| 802 |
def ingest():
|
| 803 |
"""Upload and asynchronously ingest one or more documents."""
|
| 804 |
log.info("Ingest request received. Files in request: %s",
|
|
|
|
| 923 |
# ── Query ─────────────────────────────────────────────────────────────────────
|
| 924 |
|
| 925 |
@app.route("/api/query", methods=["POST"])
|
| 926 |
+
@limiter.limit("120 per minute")
|
| 927 |
def query():
|
| 928 |
"""RAG query — returns a streaming SSE response."""
|
| 929 |
data = request.get_json()
|
| 930 |
+
q = escape((data or {}).get("query", "").strip())
|
| 931 |
top_k = (data or {}).get("top_k")
|
| 932 |
max_tokens = (data or {}).get("max_tokens")
|
| 933 |
use_vector = (data or {}).get("use_vector", True)
|
requirements.txt
CHANGED
|
@@ -59,3 +59,4 @@ psutil>=5.9.0
|
|
| 59 |
rank_bm25
|
| 60 |
spacy>=3.7.0
|
| 61 |
en-core-web-sm @ https://github.com/explosion/spacy-models/releases/download/en_core_web_sm-3.8.0/en_core_web_sm-3.8.0-py3-none-any.whl
|
|
|
|
|
|
| 59 |
rank_bm25
|
| 60 |
spacy>=3.7.0
|
| 61 |
en-core-web-sm @ https://github.com/explosion/spacy-models/releases/download/en_core_web_sm-3.8.0/en_core_web_sm-3.8.0-py3-none-any.whl
|
| 62 |
+
Flask-Limiter>=3.0.0
|
requirements_hf.txt
CHANGED
|
@@ -58,3 +58,4 @@ psutil>=5.9.0
|
|
| 58 |
rank_bm25
|
| 59 |
spacy>=3.7.0
|
| 60 |
en-core-web-sm @ https://github.com/explosion/spacy-models/releases/download/en_core_web_sm-3.8.0/en_core_web_sm-3.8.0-py3-none-any.whl
|
|
|
|
|
|
| 58 |
rank_bm25
|
| 59 |
spacy>=3.7.0
|
| 60 |
en-core-web-sm @ https://github.com/explosion/spacy-models/releases/download/en_core_web_sm-3.8.0/en_core_web_sm-3.8.0-py3-none-any.whl
|
| 61 |
+
Flask-Limiter>=3.0.0
|
templates/index.html
CHANGED
|
@@ -160,10 +160,47 @@
|
|
| 160 |
white-space: nowrap;
|
| 161 |
border-width: 0;
|
| 162 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 163 |
</style>
|
| 164 |
</head>
|
| 165 |
<body>
|
| 166 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 167 |
<header class="banner" role="banner">
|
| 168 |
<div>
|
| 169 |
<h1>Healthcare Policy Expert</h1>
|
|
@@ -224,9 +261,55 @@
|
|
| 224 |
};
|
| 225 |
let notReadyStartTime = null;
|
| 226 |
const NOT_READY_TIMEOUT_MS = 300 * 1000;
|
| 227 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 228 |
|
| 229 |
document.addEventListener("DOMContentLoaded", () => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 230 |
const quickSelect = document.getElementById('quick-questions');
|
| 231 |
const questionInput = document.getElementById('question-input');
|
| 232 |
const sendBtn = document.getElementById('send-btn');
|
|
@@ -287,7 +370,6 @@
|
|
| 287 |
}
|
| 288 |
setTimeout(pollStatus, 5000);
|
| 289 |
}
|
| 290 |
-
pollStatus();
|
| 291 |
|
| 292 |
quickSelect.addEventListener('change', () => {
|
| 293 |
if (quickSelect.value) {
|
|
|
|
| 160 |
white-space: nowrap;
|
| 161 |
border-width: 0;
|
| 162 |
}
|
| 163 |
+
|
| 164 |
+
/* CAPTCHA Modal Styling */
|
| 165 |
+
#captcha-modal {
|
| 166 |
+
position: fixed;
|
| 167 |
+
top: 0; left: 0; right: 0; bottom: 0;
|
| 168 |
+
background: var(--bg-color);
|
| 169 |
+
z-index: 9999;
|
| 170 |
+
display: flex;
|
| 171 |
+
flex-direction: column;
|
| 172 |
+
align-items: center;
|
| 173 |
+
justify-content: center;
|
| 174 |
+
padding: 1rem;
|
| 175 |
+
text-align: center;
|
| 176 |
+
}
|
| 177 |
+
#captcha-modal .card {
|
| 178 |
+
background: var(--white);
|
| 179 |
+
padding: 2rem;
|
| 180 |
+
border-radius: 8px;
|
| 181 |
+
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
| 182 |
+
max-width: 320px;
|
| 183 |
+
width: 100%;
|
| 184 |
+
}
|
| 185 |
+
#captcha-modal h2 { margin-top: 0; color: var(--primary-color); }
|
| 186 |
+
#captcha-modal .challenge { font-size: 1.5rem; font-weight: bold; margin: 1rem 0; }
|
| 187 |
+
#captcha-error { color: #d32f2f; margin-top: 0.5rem; display: none; font-weight: bold; }
|
| 188 |
</style>
|
| 189 |
</head>
|
| 190 |
<body>
|
| 191 |
|
| 192 |
+
<!-- Security CAPTCHA Overlay -->
|
| 193 |
+
<div id="captcha-modal">
|
| 194 |
+
<div class="card">
|
| 195 |
+
<h2>Security Check</h2>
|
| 196 |
+
<p>Please solve this math problem to continue:</p>
|
| 197 |
+
<div class="challenge" id="captcha-challenge"></div>
|
| 198 |
+
<input type="number" id="captcha-input" placeholder="Your answer" aria-label="Captcha answer">
|
| 199 |
+
<div id="captcha-error">Incorrect, try again!</div>
|
| 200 |
+
<button id="captcha-btn" style="margin-top: 1rem;">Verify</button>
|
| 201 |
+
</div>
|
| 202 |
+
</div>
|
| 203 |
+
|
| 204 |
<header class="banner" role="banner">
|
| 205 |
<div>
|
| 206 |
<h1>Healthcare Policy Expert</h1>
|
|
|
|
| 261 |
};
|
| 262 |
let notReadyStartTime = null;
|
| 263 |
const NOT_READY_TIMEOUT_MS = 300 * 1000;
|
| 264 |
+
let sessionToken = localStorage.getItem('he_session_token');
|
| 265 |
+
if (!sessionToken) {
|
| 266 |
+
sessionToken = 'sess_' + Math.random().toString(36).substring(2, 11);
|
| 267 |
+
localStorage.setItem('he_session_token', sessionToken);
|
| 268 |
+
}
|
| 269 |
+
|
| 270 |
+
const SESSION_TOKEN = sessionToken;
|
| 271 |
|
| 272 |
document.addEventListener("DOMContentLoaded", () => {
|
| 273 |
+
// --- CAPTCHA Logic ---
|
| 274 |
+
const captchaModal = document.getElementById('captcha-modal');
|
| 275 |
+
const captchaChallenge = document.getElementById('captcha-challenge');
|
| 276 |
+
const captchaInput = document.getElementById('captcha-input');
|
| 277 |
+
const captchaBtn = document.getElementById('captcha-btn');
|
| 278 |
+
const captchaError = document.getElementById('captcha-error');
|
| 279 |
+
let captchaExpected = 0;
|
| 280 |
+
|
| 281 |
+
function generateCaptcha() {
|
| 282 |
+
const num1 = Math.floor(Math.random() * 10) + 1;
|
| 283 |
+
const num2 = Math.floor(Math.random() * 10) + 1;
|
| 284 |
+
captchaExpected = num1 + num2;
|
| 285 |
+
captchaChallenge.textContent = `${num1} + ${num2} = ?`;
|
| 286 |
+
captchaInput.value = '';
|
| 287 |
+
}
|
| 288 |
+
|
| 289 |
+
function verifyCaptcha() {
|
| 290 |
+
const val = parseInt(captchaInput.value, 10);
|
| 291 |
+
if (val === captchaExpected) {
|
| 292 |
+
captchaModal.style.display = 'none';
|
| 293 |
+
sessionStorage.setItem('captcha_solved', 'true');
|
| 294 |
+
pollStatus();
|
| 295 |
+
} else {
|
| 296 |
+
captchaError.style.display = 'block';
|
| 297 |
+
generateCaptcha();
|
| 298 |
+
}
|
| 299 |
+
}
|
| 300 |
+
|
| 301 |
+
captchaBtn.addEventListener('click', verifyCaptcha);
|
| 302 |
+
captchaInput.addEventListener('keypress', (e) => {
|
| 303 |
+
if (e.key === 'Enter') verifyCaptcha();
|
| 304 |
+
});
|
| 305 |
+
|
| 306 |
+
if (sessionStorage.getItem('captcha_solved') === 'true') {
|
| 307 |
+
captchaModal.style.display = 'none';
|
| 308 |
+
pollStatus();
|
| 309 |
+
} else {
|
| 310 |
+
generateCaptcha();
|
| 311 |
+
}
|
| 312 |
+
// ---------------------
|
| 313 |
const quickSelect = document.getElementById('quick-questions');
|
| 314 |
const questionInput = document.getElementById('question-input');
|
| 315 |
const sendBtn = document.getElementById('send-btn');
|
|
|
|
| 370 |
}
|
| 371 |
setTimeout(pollStatus, 5000);
|
| 372 |
}
|
|
|
|
| 373 |
|
| 374 |
quickSelect.addEventListener('change', () => {
|
| 375 |
if (quickSelect.value) {
|