rohitsar567 commited on
Commit
cd72f85
Β·
verified Β·
1 Parent(s): 5de86d2

Deploy: Stack A (NIM brain + Maverick judge + Sarvam voice). D-019.

Browse files
README.md CHANGED
@@ -72,3 +72,14 @@ See [`docs/01-requirements.md` Β§7](docs/01-requirements.md).
72
  - **UI**: Streamlit
73
 
74
  Each pick is justified in [`docs/decisions.md`](docs/decisions.md).
 
 
 
 
 
 
 
 
 
 
 
 
72
  - **UI**: Streamlit
73
 
74
  Each pick is justified in [`docs/decisions.md`](docs/decisions.md).
75
+
76
+ ## Deploying changes
77
+
78
+ All extraction, embedding, and Chroma writes for the curated corpus run on the developer's Mac. The deployed HF Space serves pre-built indexes and does not auto-ingest (the one exception is user-uploaded PDFs, which embed on-demand into an isolated `user_uploads_quarantine` collection). Full rationale and the per-stage where-it-runs table: [`docs/ingestion_policy.md`](docs/ingestion_policy.md).
79
+
80
+ After any corpus change, run these four commands sequentially from the project root with the local `.venv` active:
81
+
82
+ - [ ] `.venv/bin/python -m rag.ingest` β€” rebuild the Chroma index locally
83
+ - [ ] `.venv/bin/python tools/upload_vectors_to_dataset.py` β€” push `rag/vectors/` to the HF dataset
84
+ - [ ] `.venv/bin/python tools/upload_extracted_to_dataset.py` β€” push `rag/extracted/` (only if structured JSONs changed)
85
+ - [ ] `.venv/bin/python tools/upload_to_hf.py` β€” push code to the Space (triggers a Docker rebuild that pulls the dataset)
backend/providers/local_embeddings.py CHANGED
@@ -27,11 +27,39 @@ class LocalEmbeddings(EmbeddingsProvider):
27
  device: Optional[str] = None,
28
  ):
29
  # Lazy import so this module loads fast even if model isn't downloaded
 
30
  from sentence_transformers import SentenceTransformer
31
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32
  self.model_name = model_name
 
33
  self.model = SentenceTransformer(model_name, device=device)
34
  self.dimension = self.model.get_sentence_embedding_dimension()
 
 
 
 
 
 
 
 
35
 
36
  async def embed(
37
  self,
@@ -43,10 +71,13 @@ class LocalEmbeddings(EmbeddingsProvider):
43
  # BGE recommends a small query-side instruction; not strictly required
44
  if input_type == "query":
45
  texts = [f"Represent this sentence for searching relevant passages: {t}" for t in texts]
46
- # Encode synchronously on CPU/GPU; small batches don't need true async
 
 
 
47
  vectors = self.model.encode(
48
  texts,
49
- batch_size=32,
50
  show_progress_bar=False,
51
  convert_to_numpy=True,
52
  normalize_embeddings=True,
 
27
  device: Optional[str] = None,
28
  ):
29
  # Lazy import so this module loads fast even if model isn't downloaded
30
+ import os
31
  from sentence_transformers import SentenceTransformer
32
 
33
+ # Device autodetect: MPS on Apple Silicon when available (2-3x faster
34
+ # than CPU on long chunks), CUDA if present, else CPU. Honor explicit
35
+ # override via constructor arg OR EMBED_DEVICE env var so HF Space
36
+ # (no MPS) and local Mac (with MPS) pick the right path.
37
+ if device is None:
38
+ device = os.environ.get("EMBED_DEVICE", "").strip() or None
39
+ if device is None:
40
+ try:
41
+ import torch
42
+ if torch.backends.mps.is_available():
43
+ device = "mps"
44
+ elif torch.cuda.is_available():
45
+ device = "cuda"
46
+ else:
47
+ device = "cpu"
48
+ except Exception:
49
+ device = "cpu"
50
+
51
  self.model_name = model_name
52
+ self.device = device
53
  self.model = SentenceTransformer(model_name, device=device)
54
  self.dimension = self.model.get_sentence_embedding_dimension()
55
+ # Warm-up call on MPS β€” first kernel JIT compile is ~3-5s; doing it
56
+ # in __init__ rather than first encode() makes the first user request
57
+ # fast. CPU/CUDA skip this (their first call has no JIT penalty).
58
+ if device == "mps":
59
+ try:
60
+ self.model.encode(["warmup"] * 2, batch_size=2, show_progress_bar=False)
61
+ except Exception:
62
+ pass
63
 
64
  async def embed(
65
  self,
 
71
  # BGE recommends a small query-side instruction; not strictly required
72
  if input_type == "query":
73
  texts = [f"Represent this sentence for searching relevant passages: {t}" for t in texts]
74
+ # Batch size scales by device: MPS / CUDA throughput benefits from
75
+ # bigger batches; CPU prefers smaller to avoid memory pressure on M1.
76
+ # 800-token chunks at batch_size=64 is ~50 MB which fits 8GB M1 fine.
77
+ batch = 64 if self.device in ("mps", "cuda") else 32
78
  vectors = self.model.encode(
79
  texts,
80
+ batch_size=batch,
81
  show_progress_bar=False,
82
  convert_to_numpy=True,
83
  normalize_embeddings=True,
docs/ingestion_policy.md ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Ingestion Policy (2026-05-14)
2
+
3
+ **Headline:** All extraction, embedding, and Chroma writes for the curated
4
+ policy corpus run on the developer's Mac. The deployed Hugging Face Space
5
+ serves pre-built indexes only. The single exception is user-uploaded PDFs,
6
+ which are embedded on-demand into a SEPARATE `user_uploads_quarantine`
7
+ Chroma collection isolated from the main `policies` corpus.
8
+
9
+ ---
10
+
11
+ ## Where things run
12
+
13
+ | Pipeline stage | Runs on Mac (`.venv`) | Runs in Space (Docker) |
14
+ |---------------------------------------------|:---------------------:|:----------------------:|
15
+ | Source-PDF download (`rag/corpus/**`) | yes | no |
16
+ | Text extraction (pdfplumber β†’ JSON) | yes | no |
17
+ | Chunking (800-token windows, 120 overlap) | yes | no |
18
+ | Embedding (BAAI/bge-small-en-v1.5) | yes | **only quarantine** |
19
+ | Chroma write β€” `policies` collection | yes | no |
20
+ | Chroma write β€” `user_uploads_quarantine` | no | yes (on-demand) |
21
+ | HF dataset push (`rag/vectors/**`, `rag/extracted/**`, `rag/corpus/**`) | yes | no |
22
+ | HF Space push (code only) | yes | no |
23
+ | Serving (retrieval, LLM, voice, frontend) | no | yes |
24
+
25
+ Reading the column "Runs in Space": the only embedding work the deployed
26
+ container ever performs is on-demand quarantine embedding of a user-uploaded
27
+ PDF inside `POST /api/upload-policy`. Every other embedding has been baked
28
+ into the image at Docker build time via `huggingface_hub.snapshot_download`
29
+ of the companion dataset (`rohitsar567/insurance-bot-data`).
30
+
31
+ ## Local workflow
32
+
33
+ After any change to the curated corpus, run these on the Mac in order:
34
+
35
+ ```bash
36
+ # 1. Re-build the Chroma index against the local PDFs + extracted JSONs.
37
+ .venv/bin/python -m rag.ingest
38
+
39
+ # 2. Push the freshly-built rag/vectors/ to the HF dataset so the next
40
+ # Space rebuild pulls a ready-to-serve index.
41
+ .venv/bin/python tools/upload_vectors_to_dataset.py
42
+
43
+ # 3. Only if structured extraction JSONs changed (rag/extracted/*.json) β€”
44
+ # e.g. you re-ran an NIM extraction batch.
45
+ .venv/bin/python tools/upload_extracted_to_dataset.py
46
+
47
+ # 4. Push the code to the HF Space. This triggers a Docker rebuild; the
48
+ # build pulls rag/vectors/ + rag/extracted/ + rag/corpus/ from the
49
+ # dataset (see Dockerfile `allow_patterns`).
50
+ .venv/bin/python tools/upload_to_hf.py
51
+ ```
52
+
53
+ The four steps are sequential β€” step 4 must come last because the Space
54
+ rebuild snapshots whatever state the dataset is in at build time. If you
55
+ push code before the vectors are synced, the Space will boot against a
56
+ stale index (or, if the schema changed, fail the entrypoint validation).
57
+
58
+ ## Why
59
+
60
+ Prior policy auto-ingested on Space boot if Chroma looked empty or broken.
61
+ That created two bad failure modes:
62
+
63
+ 1. **Confusing schema breakage with normal boot.** A breaking Chroma schema
64
+ change (e.g. a chromadb version bump) silently triggered a 20+ minute
65
+ re-ingest during `APP_STARTING`. The Space logged nothing user-visible
66
+ while it churned, then either succeeded (slow, but fine) or failed deep
67
+ inside the embedder. Operators could not tell which.
68
+ 2. **Resource cost on the wrong machine.** Free-tier Spaces have a CPU
69
+ budget and a 1 GB image cap. Embedding ~190 PDFs on Space CPU was
70
+ slower than on a developer Mac and pushed boot far past the platform's
71
+ health-check window.
72
+
73
+ Fail-fast is better: `entrypoint.sh` now validates Chroma is readable and
74
+ populated, and exits with a loud error if not. The fix is documented at
75
+ exit time (run `rag.ingest` locally, push vectors, redeploy). Total
76
+ boot-to-serving time on the Space is now seconds, not tens of minutes.
77
+
78
+ ## Exception β€” user uploads
79
+
80
+ `POST /api/upload-policy` accepts an arbitrary PDF from the public web and
81
+ must embed it before the chatbot can answer questions about it. To keep
82
+ this off the main corpus while still allowing the feature:
83
+
84
+ - The endpoint writes into a SEPARATE Chroma collection named
85
+ `user_uploads_quarantine` (created lazily via
86
+ `rag.ingest.get_quarantine_collection`).
87
+ - Every chunk in that collection is tagged with the uploading session's
88
+ `session_id`. Retrieval against the quarantine collection is scoped to
89
+ that `session_id`, so one user's upload never surfaces in another
90
+ user's session β€” and never surfaces in queries against the main
91
+ `policies` collection at all.
92
+ - The quarantine collection is the ONLY place the deployed Space ever
93
+ writes embeddings. It does not back-propagate to the dataset, does not
94
+ persist across Space rebuilds (it lives only in the Space's working
95
+ Chroma directory), and is not part of any evaluation set.
96
+
97
+ If a user-uploaded PDF turns out to be worth curating, the operator pulls
98
+ it into `rag/corpus/<insurer>/` on the Mac and re-runs the local workflow
99
+ above. There is no in-place promotion from quarantine to `policies`.
entrypoint.sh CHANGED
@@ -1,9 +1,16 @@
1
  #!/bin/sh
2
- # Container entrypoint:
3
- # 1. If the Chroma vector store is empty, run ingestion from rag/corpus/
4
- # (one-time ~5 min on first boot; cached on HF persistent disk for
5
- # subsequent boots)
6
- # 2. Start uvicorn
 
 
 
 
 
 
 
7
 
8
  set -e
9
 
@@ -46,9 +53,16 @@ except Exception as e:
46
  print(f'[entrypoint] Chroma load FAILED: {type(e).__name__}: {e}')
47
  sys.exit(1)
48
  " || (
49
- echo "[entrypoint] Wiping vector store and re-ingesting from corpus..."
50
- rm -rf /app/rag/vectors/* 2>/dev/null || true
51
- python -m rag.ingest 2>&1 | tail -30
 
 
 
 
 
 
 
52
  )
53
 
54
  # Start the server
 
1
  #!/bin/sh
2
+ # Container entrypoint (2026-05-14 policy update):
3
+ # 1. Validate Chroma is readable + populated.
4
+ # 2. If empty/broken: FAIL FAST with a loud error β€” DO NOT auto-ingest.
5
+ # Ingestion runs on the developer's local Mac (faster CPU, visible
6
+ # progress). The deployed Space serves pre-built indexes only.
7
+ # The single exception is the user_uploads_quarantine collection,
8
+ # which /api/upload-policy embeds on-demand per uploading session.
9
+ # 3. Start uvicorn.
10
+ #
11
+ # Why: previously the Space silently re-ingested for 20+ min during APP_STARTING
12
+ # (output piped through `tail -30` so nothing visible), making schema breakage
13
+ # look identical to "still booting". Fail-fast surfaces ingest gaps immediately.
14
 
15
  set -e
16
 
 
53
  print(f'[entrypoint] Chroma load FAILED: {type(e).__name__}: {e}')
54
  sys.exit(1)
55
  " || (
56
+ echo "[entrypoint] ============================================================"
57
+ echo "[entrypoint] FATAL: Chroma vector store is empty or schema-incompatible."
58
+ echo "[entrypoint] Auto-ingest is DISABLED (2026-05-14 policy)."
59
+ echo "[entrypoint]"
60
+ echo "[entrypoint] Fix on the developer Mac:"
61
+ echo "[entrypoint] .venv/bin/python -m rag.ingest"
62
+ echo "[entrypoint] .venv/bin/python tools/upload_extracted_to_dataset.py"
63
+ echo "[entrypoint] # plus sync rag/vectors/ to the dataset, then redeploy"
64
+ echo "[entrypoint] ============================================================"
65
+ exit 1
66
  )
67
 
68
  # Start the server
rag/retrieve.py CHANGED
@@ -68,6 +68,24 @@ def _is_regulatory_intent(query: str) -> bool:
68
  return bool(_REGULATORY_TRIGGERS.search(query or ""))
69
 
70
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
71
  def _build_chunk(cid: str, doc: str, meta: dict, score: float) -> RetrievedChunk:
72
  return RetrievedChunk(
73
  chunk_id=cid,
@@ -215,6 +233,42 @@ async def retrieve(
215
  # Regulatory boost is additive; failure shouldn't kill the main result
216
  pass
217
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
218
  return out
219
 
220
 
 
68
  return bool(_REGULATORY_TRIGGERS.search(query or ""))
69
 
70
 
71
+ # Review-intent triggers: when a query is about insurer reputation /
72
+ # claim experience / customer satisfaction, surface the review chunks
73
+ # (ingested via tools/ingest_reviews.py, doc_type='review').
74
+ _REVIEW_TRIGGERS = _re.compile(
75
+ r"\b(review|reviews|rating|ratings|reputation|complaint|complaints|"
76
+ r"trustpilot|policybazaar|insurancedekho|joinditto|claim experience|"
77
+ r"claim settlement|claim ratio|complaint ratio|customer service|"
78
+ r"good service|bad service|user experience|reddit|youtube|"
79
+ r"feedback|testimonial|sentiment|trust|reliable|reliability)\b",
80
+ flags=_re.IGNORECASE,
81
+ )
82
+ REVIEW_BOOST = 1.15 # slight boost so reviews appear in reputation Qs
83
+
84
+
85
+ def _is_review_intent(query: str) -> bool:
86
+ return bool(_REVIEW_TRIGGERS.search(query or ""))
87
+
88
+
89
  def _build_chunk(cid: str, doc: str, meta: dict, score: float) -> RetrievedChunk:
90
  return RetrievedChunk(
91
  chunk_id=cid,
 
233
  # Regulatory boost is additive; failure shouldn't kill the main result
234
  pass
235
 
236
+ # Review boost pass β€” when query asks about insurer reputation / claim
237
+ # experience / service quality. Filters to the relevant insurer if
238
+ # insurer_slugs is set; otherwise queries across all reviews.
239
+ if _is_review_intent(query):
240
+ try:
241
+ where_review: dict = {"doc_type": "review"}
242
+ if insurer_slugs:
243
+ # Combine doc_type + insurer filter
244
+ where_review = {
245
+ "$and": [
246
+ {"doc_type": "review"},
247
+ {"insurer_slug": {"$in": insurer_slugs}},
248
+ ]
249
+ }
250
+ rev_res = collection.query(
251
+ query_embeddings=[query_vec],
252
+ n_results=3,
253
+ where=where_review,
254
+ )
255
+ if rev_res["ids"] and rev_res["ids"][0]:
256
+ seen = {c.chunk_id for c in out}
257
+ rev_chunks: list[RetrievedChunk] = []
258
+ for cid, doc, meta, dist in zip(
259
+ rev_res["ids"][0], rev_res["documents"][0],
260
+ rev_res["metadatas"][0], rev_res["distances"][0],
261
+ ):
262
+ if cid in seen:
263
+ continue
264
+ boosted = (1.0 - dist) * REVIEW_BOOST
265
+ rev_chunks.append(_build_chunk(cid, doc, meta, boosted))
266
+ merged = sorted(out + rev_chunks, key=lambda c: c.score, reverse=True)
267
+ out = merged[:top_k]
268
+ except Exception:
269
+ # Review boost is additive; failure shouldn't kill the main result
270
+ pass
271
+
272
  return out
273
 
274
 
requirements.txt CHANGED
@@ -15,7 +15,11 @@ requests==2.34.0
15
  pdfplumber==0.11.4
16
 
17
  # RAG: vector store + structured store
18
- chromadb==0.5.20
 
 
 
 
19
  duckdb==1.1.3
20
 
21
  # Local embeddings (BGE small) β€” replaces Voyage in v1 to bypass free-tier rate limits
 
15
  pdfplumber==0.11.4
16
 
17
  # RAG: vector store + structured store
18
+ # 2026-05-14: pinned to 1.5.9 to match the local .venv version that builds
19
+ # rag/vectors. Schema drift between chroma versions caused KeyError('_type')
20
+ # in earlier deploys, wiping the index and forcing a 45-min re-ingest.
21
+ # Bump both local AND this pin together if upgrading.
22
+ chromadb==1.5.9
23
  duckdb==1.1.3
24
 
25
  # Local embeddings (BGE small) β€” replaces Voyage in v1 to bypass free-tier rate limits
tools/ingest_reviews.py ADDED
@@ -0,0 +1,182 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Ingest insurer reviews into the main Chroma `policies` collection.
2
+
3
+ For each insurer review JSON in `data/reviews/`:
4
+ 1. Render the structured review into a natural-language paragraph that
5
+ captures the gist of an insurer's reputation: claim settlement %,
6
+ complaint rate, aggregator ratings, sentiment summary, news flags.
7
+ 2. Embed via LocalEmbeddings (BGE-small).
8
+ 3. Write to the same Chroma `policies` collection with
9
+ insurer_slug = <slug>
10
+ policy_id = "review_<slug>"
11
+ doc_type = "review"
12
+ source_url = first verified review URL we have
13
+ 4. Idempotent: re-running replaces the existing chunk for that insurer.
14
+
15
+ After ingest, `retrieve()` will surface these chunks for queries like
16
+ "is HDFC ERGO's claim experience good?" or "what do users say about
17
+ Care Health?" β€” semantic recall over reviews, citable back to source.
18
+
19
+ Run AFTER the main rag.ingest finishes (avoids Chroma write contention).
20
+ .venv/bin/python tools/ingest_reviews.py
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import asyncio
25
+ import json
26
+ from pathlib import Path
27
+
28
+ import chromadb
29
+ from chromadb.config import Settings
30
+
31
+ from backend.config import settings
32
+ from backend.providers.local_embeddings import LocalEmbeddings
33
+
34
+
35
+ ROOT = Path(__file__).resolve().parent.parent
36
+ REVIEWS_DIR = ROOT / "data" / "reviews"
37
+
38
+
39
+ def review_to_paragraph(d: dict) -> str:
40
+ """Render a structured review JSON into a single English paragraph
41
+ suitable for embedding + retrieval."""
42
+ parts: list[str] = []
43
+ name = d.get("insurer_name") or d.get("insurer_slug")
44
+ parts.append(f"USER REVIEWS AND REPUTATION β€” {name}.")
45
+
46
+ # Hard claim metrics first β€” most-cited numbers
47
+ cm = d.get("claim_metrics") or {}
48
+ if cm.get("claim_settlement_ratio_pct") is not None:
49
+ parts.append(
50
+ f"Claim Settlement Ratio: {cm['claim_settlement_ratio_pct']}% "
51
+ f"({cm.get('claim_settlement_ratio_year','recent')}, per IRDAI)."
52
+ )
53
+ if cm.get("complaints_per_10k_policies") is not None:
54
+ parts.append(
55
+ f"Complaints per 10,000 policies: {cm['complaints_per_10k_policies']} "
56
+ f"({cm.get('complaints_year','recent')})."
57
+ )
58
+ if cm.get("incurred_claim_ratio_pct") is not None:
59
+ parts.append(f"Incurred Claim Ratio: {cm['incurred_claim_ratio_pct']}%.")
60
+
61
+ # Aggregator star ratings (Policybazaar, InsuranceDekho, Ditto, etc.)
62
+ agg = d.get("aggregator_ratings") or {}
63
+ for site, info in agg.items():
64
+ star = (info or {}).get("avg_star")
65
+ if star is not None:
66
+ count = info.get("review_count")
67
+ count_part = f" ({count} reviews)" if count else ""
68
+ parts.append(f"{site.replace('_',' ').title()} rating: {star}/5{count_part}.")
69
+
70
+ # Trustpilot
71
+ tp = d.get("trustpilot") or {}
72
+ if tp.get("score") is not None:
73
+ parts.append(f"Trustpilot: {tp['score']}/5 over {tp.get('review_count','few')} reviews.")
74
+
75
+ # Reddit / Youtube sentiment summaries (text fields)
76
+ for key, label in [
77
+ ("reddit_sentiment", "Reddit user sentiment"),
78
+ ("youtube_coverage", "YouTube coverage"),
79
+ ("in_news", "Recent news"),
80
+ ]:
81
+ v = d.get(key)
82
+ if isinstance(v, dict):
83
+ summary = v.get("summary") or v.get("note")
84
+ if summary:
85
+ parts.append(f"{label}: {summary}")
86
+ elif isinstance(v, str) and v.strip():
87
+ parts.append(f"{label}: {v.strip()}")
88
+
89
+ # Aggregate score the bot has computed
90
+ agg_score = d.get("aggregate_score")
91
+ if isinstance(agg_score, dict):
92
+ s = agg_score.get("score")
93
+ rationale = agg_score.get("rationale") or ""
94
+ if s is not None:
95
+ parts.append(f"Overall trust score (internal): {s}. {rationale}")
96
+
97
+ parts.append(
98
+ "Use these reviews when a user asks about claim experience, "
99
+ "service quality, or general reputation. Reviews are dated; "
100
+ f"last updated {d.get('last_updated','recent')}."
101
+ )
102
+ return "\n".join(parts)
103
+
104
+
105
+ def first_verified_url(d: dict) -> str:
106
+ """Pick a single canonical URL to attach as source_url on the chunk.
107
+ Prefer IRDAI claim-stats page, then Policybazaar, then company."""
108
+ cm = d.get("claim_metrics") or {}
109
+ for k in ("source_irdai_url", "source_secondary_url", "source_company_url"):
110
+ if cm.get(k):
111
+ return cm[k]
112
+ agg = d.get("aggregator_ratings") or {}
113
+ for site_info in agg.values():
114
+ if isinstance(site_info, dict) and site_info.get("url"):
115
+ return site_info["url"]
116
+ return ""
117
+
118
+
119
+ async def main():
120
+ files = sorted(REVIEWS_DIR.glob("*.json"))
121
+ if not files:
122
+ print(f"No review JSONs found in {REVIEWS_DIR}")
123
+ return
124
+
125
+ client = chromadb.PersistentClient(
126
+ path=str(settings.VECTORS_DIR),
127
+ settings=Settings(anonymized_telemetry=False),
128
+ )
129
+ coll = client.get_or_create_collection(
130
+ name="policies",
131
+ metadata={"hnsw:space": "cosine"},
132
+ )
133
+ embedder = LocalEmbeddings()
134
+
135
+ ok, skipped = 0, 0
136
+ for f in files:
137
+ try:
138
+ d = json.load(open(f))
139
+ except Exception as e:
140
+ print(f" SKIP {f.name}: {type(e).__name__}: {e}")
141
+ skipped += 1
142
+ continue
143
+ slug = d.get("insurer_slug") or f.stem
144
+ chunk_id = f"review_{slug}"
145
+ text = review_to_paragraph(d)
146
+ if len(text) < 100:
147
+ print(f" SKIP {slug}: rendered text too short ({len(text)} chars)")
148
+ skipped += 1
149
+ continue
150
+ [vec] = await embedder.embed([text], input_type="document")
151
+
152
+ # Replace any prior chunk for this insurer (idempotent)
153
+ try:
154
+ coll.delete(where={"policy_id": chunk_id})
155
+ except Exception:
156
+ pass
157
+
158
+ coll.add(
159
+ ids=[chunk_id],
160
+ documents=[text],
161
+ embeddings=[vec],
162
+ metadatas=[{
163
+ "policy_id": chunk_id,
164
+ "insurer_slug": slug,
165
+ "policy_name": f"{d.get('insurer_name', slug)} reviews",
166
+ "doc_type": "review",
167
+ "source_url": first_verified_url(d),
168
+ "page_start": 0,
169
+ "page_end": 0,
170
+ "chunk_idx": 0,
171
+ "local_path": str(f),
172
+ }],
173
+ )
174
+ print(f" OK {slug:22s} {len(text):4d} chars -> {chunk_id}")
175
+ ok += 1
176
+
177
+ print()
178
+ print(f"Done. Embedded: {ok}, skipped: {skipped}, total: {len(files)}")
179
+
180
+
181
+ if __name__ == "__main__":
182
+ asyncio.run(main())
tools/set_hf_secrets.py CHANGED
@@ -23,10 +23,18 @@ ROOT = Path(__file__).resolve().parent.parent
23
  load_dotenv(ROOT / ".env")
24
 
25
  REPO_ID = "rohitsar567/InsuranceBot"
26
- # Active secrets (D-019 Stack A): only these are read by the running code.
27
- SECRETS_TO_SET = ["SARVAM_API_KEY", "VOYAGE_API_KEY", "NVIDIA_NIM_API_KEY"]
28
- # Legacy secrets to delete (retired providers β€” see D-019).
29
- SECRETS_TO_DELETE = ["GROQ_API_KEY", "OPENROUTER_API_KEY", "CEREBRAS_API_KEY", "DEEPSEEK_API_KEY"]
 
 
 
 
 
 
 
 
30
 
31
 
32
  def main():
 
23
  load_dotenv(ROOT / ".env")
24
 
25
  REPO_ID = "rohitsar567/InsuranceBot"
26
+ # Active secrets read by the running code (post-D-022 + admin panel):
27
+ # - SARVAM / NIM = the live providers
28
+ # - VOYAGE = legacy (kept for back-compat with old extracted artifacts)
29
+ # - OPENROUTER / GROQ = optional cross-provider fallbacks (re-added 2026-05-14)
30
+ # - ADMIN_* = control-panel gate (IP allowlist + password)
31
+ SECRETS_TO_SET = [
32
+ "SARVAM_API_KEY", "VOYAGE_API_KEY", "NVIDIA_NIM_API_KEY",
33
+ "OPENROUTER_API_KEY", "GROQ_API_KEY",
34
+ "ADMIN_IP_ALLOWLIST", "ADMIN_PASSWORD",
35
+ ]
36
+ # Truly retired providers β€” delete from Space to prevent confusion.
37
+ SECRETS_TO_DELETE = ["CEREBRAS_API_KEY", "DEEPSEEK_API_KEY"]
38
 
39
 
40
  def main():
tools/upload_all_to_dataset.py ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """One-command sync: extracted + corpus + vectors -> insurance-bot-data dataset.
2
+
3
+ Runs the three companion uploaders sequentially. Use this after a full local
4
+ pipeline run on the developer Mac (extract -> corpus refresh -> rag.ingest).
5
+
6
+ .venv/bin/python tools/upload_all_to_dataset.py
7
+
8
+ Each step exits non-zero on failure and short-circuits the rest, so a partial
9
+ sync never silently happens. The Space's Dockerfile snapshot_downloads the
10
+ whole dataset at build time, so all three folders need to be in sync before
11
+ running `tools/upload_to_hf.py`.
12
+ """
13
+ from __future__ import annotations
14
+ import sys
15
+
16
+ from tools import (
17
+ upload_extracted_to_dataset,
18
+ upload_corpus_to_dataset,
19
+ upload_vectors_to_dataset,
20
+ )
21
+
22
+
23
+ STEPS = [
24
+ ("extracted JSONs", upload_extracted_to_dataset.main),
25
+ ("corpus PDFs", upload_corpus_to_dataset.main),
26
+ ("Chroma vectors", upload_vectors_to_dataset.main),
27
+ ]
28
+
29
+
30
+ def main() -> int:
31
+ n = len(STEPS)
32
+ for i, (label, fn) in enumerate(STEPS, start=1):
33
+ print(f"\n=== [{i}/{n}] {label} ===")
34
+ rc = fn() or 0
35
+ if rc != 0:
36
+ print(f"\nABORT: step {i}/{n} ({label}) returned exit {rc}. "
37
+ "Fix and re-run; later steps were NOT executed.")
38
+ return rc
39
+ print(f"\nAll {n} dataset folders synced. Next: `tools/upload_to_hf.py` to deploy the Space.")
40
+ return 0
41
+
42
+
43
+ if __name__ == "__main__":
44
+ sys.exit(main())
tools/upload_corpus_to_dataset.py ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Sync rag/corpus/ (raw policy PDFs) to insurance-bot-data HF dataset.
2
+
3
+ Run after any corpus refresh (new PDFs added or refreshed via
4
+ rag/download_corpus.py). The Space's Dockerfile pulls rag/corpus/** at build
5
+ time so the deployed container has the source PDFs available for citation +
6
+ ingestion pipelines.
7
+ """
8
+ from __future__ import annotations
9
+ import os
10
+ from pathlib import Path
11
+ from dotenv import load_dotenv
12
+ from huggingface_hub import HfApi
13
+
14
+ ROOT = Path(__file__).resolve().parent.parent
15
+ load_dotenv(ROOT / ".env")
16
+
17
+ def main():
18
+ api = HfApi(token=os.environ["HF_TOKEN"])
19
+ corpus_dir = ROOT / "rag" / "corpus"
20
+ if not corpus_dir.exists() or not any(corpus_dir.iterdir()):
21
+ print("ERROR: rag/corpus/ is empty. Run `rag/download_corpus.py` first.")
22
+ return 1
23
+ # Print size + file count before upload
24
+ total = 0
25
+ nfiles = 0
26
+ for p in corpus_dir.rglob("*"):
27
+ if p.is_file():
28
+ total += p.stat().st_size
29
+ nfiles += 1
30
+ print(f"Syncing rag/corpus/ ({total/1024/1024:.1f} MB, {nfiles} files) to insurance-bot-data ...")
31
+ api.upload_folder(
32
+ folder_path=str(corpus_dir),
33
+ path_in_repo="rag/corpus",
34
+ repo_id="rohitsar567/insurance-bot-data",
35
+ repo_type="dataset",
36
+ commit_message="Sync rag/corpus/ β€” raw policy PDFs (Mac local refresh)",
37
+ ignore_patterns=["**/.DS_Store", "*.tmp"],
38
+ )
39
+ print("Done.")
40
+ return 0
41
+
42
+ if __name__ == "__main__":
43
+ import sys
44
+ sys.exit(main())
tools/upload_vectors_to_dataset.py ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Sync rag/vectors/ (pre-built Chroma index) to insurance-bot-data HF dataset.
2
+
3
+ Run AFTER `.venv/bin/python -m rag.ingest` on the developer Mac. The Space's
4
+ Dockerfile pulls rag/vectors/** at build time so the deployed container has a
5
+ ready-to-serve index. As of 2026-05-14 the Space no longer auto-ingests on
6
+ boot β€” see entrypoint.sh.
7
+ """
8
+ from __future__ import annotations
9
+ import os
10
+ from pathlib import Path
11
+ from dotenv import load_dotenv
12
+ from huggingface_hub import HfApi
13
+
14
+ ROOT = Path(__file__).resolve().parent.parent
15
+ load_dotenv(ROOT / ".env")
16
+
17
+ def main():
18
+ api = HfApi(token=os.environ["HF_TOKEN"])
19
+ vectors_dir = ROOT / "rag" / "vectors"
20
+ if not vectors_dir.exists() or not any(vectors_dir.iterdir()):
21
+ print("ERROR: rag/vectors/ is empty. Run `.venv/bin/python -m rag.ingest` first.")
22
+ return 1
23
+ # Print size + file count before upload
24
+ total = 0
25
+ nfiles = 0
26
+ for p in vectors_dir.rglob("*"):
27
+ if p.is_file():
28
+ total += p.stat().st_size
29
+ nfiles += 1
30
+ print(f"Syncing rag/vectors/ ({total/1024/1024:.1f} MB, {nfiles} files) to insurance-bot-data ...")
31
+ api.upload_folder(
32
+ folder_path=str(vectors_dir),
33
+ path_in_repo="rag/vectors",
34
+ repo_id="rohitsar567/insurance-bot-data",
35
+ repo_type="dataset",
36
+ commit_message="Sync rag/vectors/ β€” pre-built Chroma index (Mac local ingest)",
37
+ ignore_patterns=["*.tmp", "**/.DS_Store"],
38
+ )
39
+ print("Done. Now run `tools/upload_to_hf.py` to deploy Space.")
40
+ return 0
41
+
42
+ if __name__ == "__main__":
43
+ import sys
44
+ sys.exit(main())