Sam-max1 commited on
Commit
839b163
·
verified ·
1 Parent(s): 5ddcc92

Upload folder using huggingface_hub

Browse files
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', 'my_user') or keyring.get_password('huggingface', 'hf_token')
653
  except Exception:
654
  pass
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
655
 
656
  kbdocs_dir = Path(__file__).parent / "kbdocs"
657
  kbdocs_dir.mkdir(exist_ok=True)
658
 
659
- log.info("Syncing fresh files from Sam-max1/he_data to local kbdocs...")
 
 
 
 
 
660
  snapshot_download(
661
- repo_id="Sam-max1/he_data",
662
  repo_type="dataset",
663
- local_dir=str(kbdocs_dir),
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
- existing_docs = vector_store.list_documents("admin")
672
- existing_names = {d["source"] for d in existing_docs}
 
673
 
674
- ingested_count = 0
675
- for file_path in kbdocs_dir.glob("*.*"):
676
- if file_path.is_file() and file_path.name not in existing_names:
677
- log.info(f"Auto-ingesting new file: {file_path.name}")
678
- process_document_pipeline(str(file_path), file_path.name, "foundation", "admin", delete_after=False)
679
- ingested_count += 1
680
-
681
- # show number of chunks and nodes
682
- log.info("=== Full Data Ingestion Complete ===")
683
- log.info(f"Vector DB Chunks: {vector_store.count()}")
684
 
685
- from pipeline import graph_store
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 he_data: %s", e)
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
- const SESSION_TOKEN = crypto.randomUUID();
 
 
 
 
 
 
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) {