anmol-iisc commited on
Commit
0d8c22c
Β·
1 Parent(s): 4070852

UI improvements + specs.md update

Browse files
CLAUDE.md CHANGED
@@ -2,6 +2,13 @@
2
 
3
  This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
 
 
 
 
 
 
 
 
5
  ## Project
6
 
7
  **G.U.I.D.E.** (Grievance Utility for Information Extraction, Drafting and Enrichment) β€” a privacy-first consumer complaint assistant for Indian consumers. Users describe their complaint in plain language; the system redacts PII locally, drafts a formal letter, and routes to the correct Indian regulatory authority.
 
2
 
3
  This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
 
5
+ ## Workflow gate (READ FIRST)
6
+
7
+ Before changing anything, read **`SPEC.md`** β€” it defines the mandatory process:
8
+ sanity tests (`tests/test_sanity.py`) must pass before any commit (enforced by
9
+ the git pre-commit hook installed via `bash scripts/install-hooks.sh`), and the
10
+ **How To Guide** tab in `ui/app.py` must be updated on any user-facing UI change.
11
+
12
  ## Project
13
 
14
  **G.U.I.D.E.** (Grievance Utility for Information Extraction, Drafting and Enrichment) β€” a privacy-first consumer complaint assistant for Indian consumers. Users describe their complaint in plain language; the system redacts PII locally, drafts a formal letter, and routes to the correct Indian regulatory authority.
scripts/hooks/pre-commit ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ # G.U.I.D.E. pre-commit gate β€” runs the sanity suite (SPEC.md Β§3-4).
3
+ # No code may be committed while these fail. Bypass only with --no-verify.
4
+ set -euo pipefail
5
+
6
+ ROOT="$(git rev-parse --show-toplevel)"
7
+ cd "$ROOT"
8
+
9
+ if [ -x ".venv/bin/python" ]; then
10
+ PY=".venv/bin/python"
11
+ else
12
+ PY="python3"
13
+ fi
14
+
15
+ echo "[pre-commit] Running G.U.I.D.E. sanity tests ($PY)…"
16
+ if ! "$PY" -m pytest tests/test_sanity.py -q; then
17
+ echo ""
18
+ echo "[pre-commit] ❌ Sanity tests failed β€” commit aborted (see SPEC.md)."
19
+ echo "[pre-commit] Fix the failures, or bypass with 'git commit --no-verify'"
20
+ echo "[pre-commit] (only in a genuine emergency; the change is then not Done)."
21
+ exit 1
22
+ fi
23
+ echo "[pre-commit] βœ… Sanity tests passed."
scripts/install-hooks.sh ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ # Install the G.U.I.D.E. git hooks (SPEC.md Β§4). Idempotent β€” run anytime.
3
+ set -euo pipefail
4
+
5
+ ROOT="$(git rev-parse --show-toplevel)"
6
+ SRC="$ROOT/scripts/hooks/pre-commit"
7
+ DST="$ROOT/.git/hooks/pre-commit"
8
+
9
+ if [ ! -f "$SRC" ]; then
10
+ echo "❌ $SRC not found." >&2
11
+ exit 1
12
+ fi
13
+
14
+ cp "$SRC" "$DST"
15
+ chmod +x "$DST"
16
+ echo "βœ… Installed pre-commit hook β†’ $DST"
17
+ echo " It runs tests/test_sanity.py and blocks commits if they fail."
src/api/routes.py CHANGED
@@ -53,6 +53,48 @@ logger = logging.getLogger(__name__)
53
 
54
  router = APIRouter(prefix="/api")
55
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
  # Upload directory inside the project root β€” survives server restarts unlike
57
  # tempfile.gettempdir() which macOS can purge between sessions.
58
  _UPLOAD_DIR = Path(__file__).resolve().parent.parent.parent / "uploads"
@@ -159,7 +201,7 @@ async def send_message(session_id: str, req: MessageRequest):
159
  )
160
  except Exception as exc:
161
  logger.exception("CMA error in session %s", session_id)
162
- raise HTTPException(status_code=500, detail=str(exc)) from exc
163
 
164
  # 3. Persist conversation turn
165
  session_store.append_history(session_id, "user", redacted_text)
@@ -318,7 +360,7 @@ async def validate_entities(session_id: str, req: ValidateEntitiesRequest):
318
  )
319
  except Exception as exc:
320
  logger.exception("confirm_entities error in session %s", session_id)
321
- raise HTTPException(status_code=500, detail=str(exc)) from exc
322
 
323
  # The [USER CONFIRMED] message and the draft reply are both part of history
324
  confirmed_marker = f"[USER CONFIRMED]: {req.entities}"
@@ -351,7 +393,7 @@ async def escalation_guide(session_id: str):
351
  reply: str = await run_in_threadpool(session.agent.generate_escalation)
352
  except Exception as exc:
353
  logger.exception("generate_escalation error in session %s", session_id)
354
- raise HTTPException(status_code=500, detail=str(exc)) from exc
355
 
356
  session_store.append_history(session_id, "user", "[Generate escalation guide]")
357
  session_store.append_history(session_id, "assistant", reply)
@@ -409,7 +451,7 @@ async def classify_debug(req: ClassifyRequest):
409
  try:
410
  result = await run_in_threadpool(classify, req.text)
411
  except Exception as exc:
412
- raise HTTPException(status_code=500, detail=str(exc)) from exc
413
 
414
  return ClassifyResponse(
415
  domain=result.domain,
@@ -428,7 +470,7 @@ async def extract_debug(req: ExtractRequest):
428
  try:
429
  entities = await run_in_threadpool(extract_entities, req.text)
430
  except Exception as exc:
431
- raise HTTPException(status_code=500, detail=str(exc)) from exc
432
 
433
  return ExtractResponse(
434
  entities=[EntityOut(**dataclasses.asdict(e)) for e in entities]
 
53
 
54
  router = APIRouter(prefix="/api")
55
 
56
+
57
+ def _llm_http_error(exc: Exception) -> HTTPException:
58
+ """Translate an LLM/agent exception into a calm, user-friendly HTTPException.
59
+
60
+ The raw exception is already logged by the caller (logger.exception); this
61
+ only shapes the message the UI shows verbatim. Anthropic surfaces transient
62
+ capacity issues as an 'Overloaded' APIStatusError (HTTP 529) and rate limits
63
+ as RateLimitError (429) β€” both are temporary and worth a retry.
64
+ """
65
+ name = type(exc).__name__
66
+ text = str(exc).lower()
67
+ if "overloaded" in text or name == "OverloadedError" or "529" in text:
68
+ return HTTPException(
69
+ status_code=503,
70
+ detail=("Claude (the AI service) is briefly overloaded with requests. "
71
+ "Please wait a few seconds and send your message again β€” your "
72
+ "conversation is saved."),
73
+ )
74
+ if name == "RateLimitError" or "rate limit" in text or "429" in text:
75
+ return HTTPException(
76
+ status_code=429,
77
+ detail=("The AI service is handling a high volume of requests right now. "
78
+ "Please wait about a minute and try again."),
79
+ )
80
+ if name == "APITimeoutError" or "timeout" in text or "timed out" in text:
81
+ return HTTPException(
82
+ status_code=504,
83
+ detail=("The AI took too long to respond. Please try sending your "
84
+ "message again."),
85
+ )
86
+ if name == "AuthenticationError" or "authentication" in text or "401" in text:
87
+ return HTTPException(
88
+ status_code=502,
89
+ detail=("The assistant isn't configured correctly on the server "
90
+ "(authentication failed). Please contact the site operator."),
91
+ )
92
+ return HTTPException(
93
+ status_code=502,
94
+ detail=("The AI service ran into an unexpected error. Please try again in "
95
+ "a moment; if it keeps happening, try rephrasing your message."),
96
+ )
97
+
98
  # Upload directory inside the project root β€” survives server restarts unlike
99
  # tempfile.gettempdir() which macOS can purge between sessions.
100
  _UPLOAD_DIR = Path(__file__).resolve().parent.parent.parent / "uploads"
 
201
  )
202
  except Exception as exc:
203
  logger.exception("CMA error in session %s", session_id)
204
+ raise _llm_http_error(exc) from exc
205
 
206
  # 3. Persist conversation turn
207
  session_store.append_history(session_id, "user", redacted_text)
 
360
  )
361
  except Exception as exc:
362
  logger.exception("confirm_entities error in session %s", session_id)
363
+ raise _llm_http_error(exc) from exc
364
 
365
  # The [USER CONFIRMED] message and the draft reply are both part of history
366
  confirmed_marker = f"[USER CONFIRMED]: {req.entities}"
 
393
  reply: str = await run_in_threadpool(session.agent.generate_escalation)
394
  except Exception as exc:
395
  logger.exception("generate_escalation error in session %s", session_id)
396
+ raise _llm_http_error(exc) from exc
397
 
398
  session_store.append_history(session_id, "user", "[Generate escalation guide]")
399
  session_store.append_history(session_id, "assistant", reply)
 
451
  try:
452
  result = await run_in_threadpool(classify, req.text)
453
  except Exception as exc:
454
+ raise _llm_http_error(exc) from exc
455
 
456
  return ClassifyResponse(
457
  domain=result.domain,
 
470
  try:
471
  entities = await run_in_threadpool(extract_entities, req.text)
472
  except Exception as exc:
473
+ raise _llm_http_error(exc) from exc
474
 
475
  return ExtractResponse(
476
  entities=[EntityOut(**dataclasses.asdict(e)) for e in entities]
tests/test_sanity.py ADDED
@@ -0,0 +1,178 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ G.U.I.D.E. sanity suite β€” the fast, must-pass gate that runs before every commit.
3
+
4
+ These tests deliberately avoid loading ML checkpoints, the spaCy model, the
5
+ Anthropic API, or the network. They catch the classes of regression we have
6
+ actually hit while iterating on the UI/API:
7
+
8
+ * the Gradio app graph failing to build (output/intput-count or scope bugs),
9
+ * the API request/response schemas breaking,
10
+ * the three privacy/export features (redaction reveal, audit trail, PDF)
11
+ silently breaking,
12
+ * the privacy redactor contract (RedactionResult.spans) changing,
13
+ * the friendly LLM-error mapping regressing,
14
+ * and β€” per SPEC.md β€” the **How To Guide** tab drifting out of sync whenever a
15
+ user-facing tab is added or renamed.
16
+
17
+ Run with: python -m pytest tests/test_sanity.py -q
18
+ This is wired into the git pre-commit hook (see SPEC.md). If any of these fail,
19
+ the commit is blocked.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import importlib
25
+ import re
26
+ import sys
27
+ import tempfile
28
+ from pathlib import Path
29
+
30
+ import pytest
31
+
32
+ _ROOT = Path(__file__).resolve().parent.parent
33
+ _UI = _ROOT / "ui"
34
+ for _p in (str(_ROOT), str(_UI)):
35
+ if _p not in sys.path:
36
+ sys.path.insert(0, _p)
37
+
38
+
39
+ @pytest.fixture(scope="module")
40
+ def app_module():
41
+ """Import ui/app.py once for the whole module."""
42
+ return importlib.import_module("app")
43
+
44
+
45
+ # ---------------------------------------------------------------------------
46
+ # 1. The Gradio app graph must build (catches event/component wiring bugs).
47
+ # ---------------------------------------------------------------------------
48
+
49
+ def test_ui_app_builds(app_module):
50
+ demo = app_module.build_app()
51
+ assert demo is not None
52
+ assert type(demo).__name__ == "Blocks"
53
+
54
+
55
+ # ---------------------------------------------------------------------------
56
+ # 2. API schemas import and validate.
57
+ # ---------------------------------------------------------------------------
58
+
59
+ def test_api_models_and_routes_import():
60
+ from src.api import models, routes # noqa: F401
61
+
62
+ msg = models.MessageResponse(
63
+ reply="hi", pii_redacted=True, pii_types_found=["PERSON"],
64
+ original_text="a", redacted_text="b",
65
+ redactions=[models.RedactionSpanOut(
66
+ entity_type="PERSON", original="a", placeholder="<PERSON>",
67
+ start=0, end=1)],
68
+ )
69
+ assert msg.redactions[0].placeholder == "<PERSON>"
70
+
71
+ audit = models.AuditResponse(session_id="s", entries=[models.AuditEntry(
72
+ timestamp="t", event="outbound_to_anthropic", description="d",
73
+ transmitted_text="b", pii_types_found=["PERSON"], pii_count=1,
74
+ leak_check="passed")])
75
+ assert audit.entries[0].leak_check == "passed"
76
+
77
+
78
+ # ---------------------------------------------------------------------------
79
+ # 3. Privacy redactor contract β€” RedactionResult must carry per-span reveal data.
80
+ # ---------------------------------------------------------------------------
81
+
82
+ def test_redactor_result_contract():
83
+ from src.privacy.redactor import RedactionResult, RedactionSpan
84
+
85
+ r = RedactionResult(redacted_text="x")
86
+ assert hasattr(r, "spans") and r.spans == []
87
+ span = RedactionSpan(entity_type="PERSON", original="Ravi",
88
+ placeholder="<PERSON>", start=0, end=4)
89
+ assert span.placeholder == "<PERSON>"
90
+
91
+
92
+ # ---------------------------------------------------------------------------
93
+ # 4. Feature helpers: redaction reveal, audit render, PDF export.
94
+ # ---------------------------------------------------------------------------
95
+
96
+ def test_redaction_reveal_html(app_module):
97
+ resp = {
98
+ "original_text": "I am Ravi, phone 9876543210.",
99
+ "redacted_text": "I am <PERSON>, phone <PHONE_NUMBER>.",
100
+ "redactions": [
101
+ {"entity_type": "PERSON", "original": "Ravi",
102
+ "placeholder": "<PERSON>", "start": 5, "end": 9},
103
+ {"entity_type": "PHONE_NUMBER", "original": "9876543210",
104
+ "placeholder": "<PHONE_NUMBER>", "start": 17, "end": 27},
105
+ ],
106
+ }
107
+ html = app_module._render_redaction_html(resp)
108
+ assert "Ravi" in html and "&lt;PERSON&gt;" in html
109
+ assert "2 personal identifier" in html
110
+ # Nothing stripped β†’ panel hidden.
111
+ assert app_module._render_redaction_html({"redactions": []}) == ""
112
+
113
+
114
+ def test_audit_render(app_module):
115
+ assert "No activity yet" in app_module._render_audit_html([])
116
+ entries = [{
117
+ "timestamp": "2026-06-23T12:30:01+00:00", "event": "outbound_to_anthropic",
118
+ "description": "Message sent to Claude API β€” 1 personal identifier stripped.",
119
+ "transmitted_text": "I am <PERSON>", "pii_types_found": ["PERSON"],
120
+ "pii_count": 1, "leak_check": "passed",
121
+ }]
122
+ out = app_module._render_audit_html(entries)
123
+ assert "0 raw identifiers transmitted" in out
124
+
125
+
126
+ def test_pdf_export(app_module):
127
+ draft = ("Subject: Test complaint\n\nTo,\nThe Grievance Officer\n\n"
128
+ "Dear Sir/Madam,\n\nThis is a test.\n\nYours sincerely,\n<PERSON>")
129
+ out = tempfile.mktemp(suffix=".pdf")
130
+ app_module._build_pdf(draft, out)
131
+ data = Path(out).read_bytes()
132
+ assert data[:5] == b"%PDF-" and len(data) > 800
133
+
134
+
135
+ # ---------------------------------------------------------------------------
136
+ # 5. Friendly LLM-error mapping (overloaded / rate-limit / timeout).
137
+ # ---------------------------------------------------------------------------
138
+
139
+ def test_llm_error_mapping():
140
+ from src.api.routes import _llm_http_error
141
+
142
+ assert _llm_http_error(Exception("Overloaded")).status_code == 503
143
+ assert _llm_http_error(Exception("rate limit exceeded")).status_code == 429
144
+ assert _llm_http_error(Exception("Request timed out")).status_code == 504
145
+ overloaded = _llm_http_error(Exception("Overloaded"))
146
+ assert "overloaded" in overloaded.detail.lower()
147
+
148
+
149
+ # ---------------------------------------------------------------------------
150
+ # 6. SPEC rule: the "How To Guide" tab must mention every user-facing tab.
151
+ # This fails the moment a tab is added/renamed without updating the guide.
152
+ # ---------------------------------------------------------------------------
153
+
154
+ def _plain(label: str) -> str:
155
+ """Strip emoji/punctuation from a tab label β†’ its plain name."""
156
+ return re.sub(r"[^A-Za-z ]", "", label).strip()
157
+
158
+
159
+ def test_how_to_guide_lists_every_tab():
160
+ src = (_UI / "app.py").read_text(encoding="utf-8")
161
+ labels = re.findall(r'gr\.Tab\("([^"]+)"\)', src)
162
+ assert labels, "No gr.Tab(...) definitions found β€” did the UI structure change?"
163
+
164
+ guide_start = src.index('gr.Tab("πŸ“– How To Guide")')
165
+ guide_end = src.index('gr.Tab("πŸ’¬ Chat")')
166
+ guide_text = src[guide_start:guide_end]
167
+
168
+ # Every tab except the guide itself and the technical About tab must be
169
+ # described in the How To Guide.
170
+ skip = {"How To Guide", "About"}
171
+ missing = [
172
+ _plain(lbl) for lbl in labels
173
+ if _plain(lbl) not in skip and _plain(lbl) not in guide_text
174
+ ]
175
+ assert not missing, (
176
+ f"The 'How To Guide' tab does not mention these tabs: {missing}. "
177
+ "Per SPEC.md, update the How To Guide whenever you add or rename a tab."
178
+ )
ui/app.py CHANGED
@@ -206,27 +206,38 @@ _THEME = gr.themes.Soft(
206
  # on <body>; our custom CSS variables cascade from there too, so one class flip
207
  # restyles both Gradio's components and our bespoke hero/reveal/audit panels.
208
  # The choice is persisted in localStorage and restored on load.
 
 
 
 
 
 
 
 
 
 
 
 
 
209
  _THEME_TOGGLE_JS = """() => {
210
- const isDark = document.body.classList.toggle('dark');
211
- const app = document.querySelector('gradio-app');
212
- if (app) app.classList.toggle('dark', isDark);
213
- const b = document.querySelector('#theme-toggle button');
214
- if (b) b.textContent = isDark ? 'β˜€οΈ Light' : 'πŸŒ™ Dark';
215
- try { localStorage.setItem('guide-theme', isDark ? 'dark' : 'light'); } catch (e) {}
216
- }"""
217
 
218
  _THEME_LOAD_JS = """() => {
219
- // Light is the default: only go dark when the user explicitly chose it before.
 
220
  let pref = 'light';
221
  try { pref = localStorage.getItem('guide-theme') || 'light'; } catch (e) {}
222
- const isDark = pref === 'dark';
223
- const app = document.querySelector('gradio-app');
224
- // Force-clear any system/Gradio-applied dark unless the saved choice is dark.
225
- document.body.classList.toggle('dark', isDark);
226
- if (app) app.classList.toggle('dark', isDark);
227
- const b = document.querySelector('#theme-toggle button');
228
- if (b) b.textContent = isDark ? 'β˜€οΈ Light' : 'πŸŒ™ Dark';
229
- }"""
230
 
231
  # ── API helpers ──────────────────────────────────────────────────────────────
232
 
@@ -286,6 +297,27 @@ def _api_audit(session_id: str) -> dict:
286
  return r.json()
287
 
288
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
289
  # ── Display helpers ──────────────────────────────────────────────────────────
290
 
291
  # Presidio placeholders look like <PERSON>, <EMAIL_ADDRESS>, <PHONE_NUMBER>.
@@ -736,16 +768,17 @@ def build_app() -> gr.Blocks:
736
 
737
  | Tab | What it does |
738
  |-----|-------------|
739
- | πŸ’¬ **Chat** | Describe your complaint in plain language and optionally attach a receipt, bill, or screenshot. G.U.I.D.E. asks follow-up questions, reads any attached document automatically, and collects all the details needed for your letter. |
740
  | βœ… **Verify Entities** | Review and correct the key details G.U.I.D.E. extracted from your complaint. Once you click **Confirm & Generate Draft**, your letter is created. |
741
- | πŸ“„ **Complaint Draft** | Your ready-to-send formal complaint letter. Copy it to your clipboard or download it as a text file. |
742
  | πŸ›οΈ **Escalation Guide** | Tells you exactly which authority to contact β€” NCH, TRAI, RBI Ombudsman, IRDAI β€” with direct links to their complaint portals. |
 
743
 
744
  ---
745
 
746
  ## πŸ”’ Your Privacy
747
 
748
- > **Your details stay private.** Before your complaint is processed, personal information β€” your name, phone number, Aadhaar number, PAN, and bank account details β€” is automatically removed on this server. The AI only ever sees placeholders like `<PERSON>` or `<PHONE_NUMBER>`, never your real data. The πŸ”’ badge in the Chat tab confirms when this has happened.
749
 
750
  ---
751
 
@@ -1080,8 +1113,16 @@ the IISc Deep Learning course project.
1080
  "⚠️ Cannot reach the G.U.I.D.E. backend at "
1081
  "http://localhost:8000. Please start the API server."
1082
  )
 
 
 
 
 
 
 
 
1083
  except Exception as exc:
1084
- return _err(f"⚠️ Error: {exc}")
1085
 
1086
  reply = resp.get("reply", "")
1087
  pii_hit = resp.get("pii_redacted", False)
@@ -1200,12 +1241,10 @@ the IISc Deep Learning course project.
1200
  except requests.exceptions.ConnectionError:
1201
  return _err("⚠️ Cannot reach the backend.")
1202
  except requests.exceptions.HTTPError as exc:
1203
- detail = ""
1204
- try:
1205
- detail = exc.response.json().get("detail", "")
1206
- except Exception:
1207
- pass
1208
- return _err(f"⚠️ Confirmation failed: {detail or exc}")
1209
  except Exception as exc:
1210
  return _err(f"⚠️ Error: {exc}")
1211
 
@@ -1267,14 +1306,11 @@ the IISc Deep Learning course project.
1267
  try:
1268
  resp = _api_escalation_guide(sid)
1269
  except requests.exceptions.HTTPError as exc:
1270
- detail = ""
1271
- try:
1272
- detail = exc.response.json().get("detail", "")
1273
- except Exception:
1274
- pass
1275
  return _keep(
1276
- f"⚠️ Escalation guide failed: {detail or exc}\n\n"
1277
- 'You can ask G.U.I.D.E. "What should I do next?" in the Chat tab.'
 
 
1278
  )
1279
  except Exception as exc:
1280
  return _keep(
 
206
  # on <body>; our custom CSS variables cascade from there too, so one class flip
207
  # restyles both Gradio's components and our bespoke hero/reveal/audit panels.
208
  # The choice is persisted in localStorage and restored on load.
209
+ # Apply the `dark` class to every element Gradio might key off (html / body /
210
+ # gradio-app) so the toggle is robust regardless of which one its theme reads.
211
+ _THEME_APPLY_FN = """
212
+ function _guideApplyTheme(dark) {
213
+ [document.documentElement, document.body,
214
+ document.querySelector('gradio-app')].forEach(function (el) {
215
+ if (el) el.classList.toggle('dark', dark);
216
+ });
217
+ var b = document.querySelector('#theme-toggle button');
218
+ if (b) b.textContent = dark ? 'β˜€οΈ Light' : 'πŸŒ™ Dark';
219
+ }
220
+ """
221
+
222
  _THEME_TOGGLE_JS = """() => {
223
+ %s
224
+ const dark = !document.documentElement.classList.contains('dark');
225
+ _guideApplyTheme(dark);
226
+ try { localStorage.setItem('guide-theme', dark ? 'dark' : 'light'); } catch (e) {}
227
+ }""" % _THEME_APPLY_FN
 
 
228
 
229
  _THEME_LOAD_JS = """() => {
230
+ %s
231
+ // Light is the DEFAULT: go dark only if the user explicitly chose it before.
232
  let pref = 'light';
233
  try { pref = localStorage.getItem('guide-theme') || 'light'; } catch (e) {}
234
+ const dark = pref === 'dark';
235
+ // Re-assert a few times to override any system/Gradio dark applied late
236
+ // during hydration, which would otherwise leave the page dark by default.
237
+ _guideApplyTheme(dark);
238
+ setTimeout(() => _guideApplyTheme(dark), 60);
239
+ setTimeout(() => _guideApplyTheme(dark), 250);
240
+ }""" % _THEME_APPLY_FN
 
241
 
242
  # ── API helpers ──────────────────────────────────────────────────────────────
243
 
 
297
  return r.json()
298
 
299
 
300
+ def _http_error_detail(exc: "requests.exceptions.HTTPError", fallback: str) -> str:
301
+ """Pull the backend's user-friendly `detail` from an HTTPError, else fallback.
302
+
303
+ The API maps LLM-provider hiccups (overloaded / rate-limited / timeout) to a
304
+ calm `detail` string, so the UI can show it verbatim.
305
+ """
306
+ try:
307
+ detail = (exc.response.json() or {}).get("detail", "")
308
+ except Exception:
309
+ detail = ""
310
+ if not detail:
311
+ code = getattr(getattr(exc, "response", None), "status_code", None)
312
+ if code == 503:
313
+ detail = ("Claude is briefly overloaded. Please wait a few seconds "
314
+ "and try again.")
315
+ elif code == 429:
316
+ detail = ("The AI is rate-limiting requests right now. Please wait a "
317
+ "moment and try again.")
318
+ return detail or fallback
319
+
320
+
321
  # ── Display helpers ──────────────────────────────────────────────────────────
322
 
323
  # Presidio placeholders look like <PERSON>, <EMAIL_ADDRESS>, <PHONE_NUMBER>.
 
768
 
769
  | Tab | What it does |
770
  |-----|-------------|
771
+ | πŸ’¬ **Chat** | Describe your complaint in plain language and optionally attach a receipt, bill, or screenshot. G.U.I.D.E. asks follow-up questions, reads any attached document automatically, and collects all the details needed for your letter. The πŸ”’ **β€œSee what we protected”** panel shows, side by side, exactly which personal details were removed before anything reached the AI. |
772
  | βœ… **Verify Entities** | Review and correct the key details G.U.I.D.E. extracted from your complaint. Once you click **Confirm & Generate Draft**, your letter is created. |
773
+ | πŸ“„ **Complaint Draft** | Your ready-to-send formal complaint letter. Copy it to your clipboard or download it as a **.txt** or a formatted **PDF**. |
774
  | πŸ›οΈ **Escalation Guide** | Tells you exactly which authority to contact β€” NCH, TRAI, RBI Ombudsman, IRDAI β€” with direct links to their complaint portals. |
775
+ | πŸ”’ **Privacy Audit** | A timestamped record of every time your data was transmitted or processed, with a verified *β€œ0 raw identifiers transmitted”* guarantee β€” so you can confirm your private details never left this server unprotected. |
776
 
777
  ---
778
 
779
  ## πŸ”’ Your Privacy
780
 
781
+ > **Your details stay private.** Before your complaint is processed, personal information β€” your name, phone number, Aadhaar number, PAN, and bank account details β€” is automatically removed on this server. The AI only ever sees placeholders like `<PERSON>` or `<PHONE_NUMBER>`, never your real data. The πŸ”’ badge in the Chat tab confirms when this has happened, the **β€œSee what we protected”** panel shows you precisely what was removed, and the **πŸ”’ Privacy Audit** tab keeps a full transmission log you can inspect at any time.
782
 
783
  ---
784
 
 
1113
  "⚠️ Cannot reach the G.U.I.D.E. backend at "
1114
  "http://localhost:8000. Please start the API server."
1115
  )
1116
+ except requests.exceptions.HTTPError as exc:
1117
+ # Backend maps LLM provider issues (overloaded / rate-limited /
1118
+ # timeout) to a friendly `detail` β€” show it as-is.
1119
+ return _err("⚠️ " + _http_error_detail(
1120
+ exc,
1121
+ "The assistant couldn't process that just now. "
1122
+ "Please try again in a moment.",
1123
+ ))
1124
  except Exception as exc:
1125
+ return _err(f"⚠️ Something went wrong: {exc}")
1126
 
1127
  reply = resp.get("reply", "")
1128
  pii_hit = resp.get("pii_redacted", False)
 
1241
  except requests.exceptions.ConnectionError:
1242
  return _err("⚠️ Cannot reach the backend.")
1243
  except requests.exceptions.HTTPError as exc:
1244
+ return _err("⚠️ " + _http_error_detail(
1245
+ exc,
1246
+ "Couldn't generate the draft just now. Please try again in a moment.",
1247
+ ))
 
 
1248
  except Exception as exc:
1249
  return _err(f"⚠️ Error: {exc}")
1250
 
 
1306
  try:
1307
  resp = _api_escalation_guide(sid)
1308
  except requests.exceptions.HTTPError as exc:
 
 
 
 
 
1309
  return _keep(
1310
+ "⚠️ " + _http_error_detail(
1311
+ exc,
1312
+ "Couldn't fetch the escalation guide just now.",
1313
+ ) + '\n\nYou can ask G.U.I.D.E. "What should I do next?" in the Chat tab.'
1314
  )
1315
  except Exception as exc:
1316
  return _keep(