Spaces:
Runtime error
Runtime error
Commit Β·
0d8c22c
1
Parent(s): 4070852
UI improvements + specs.md update
Browse files- CLAUDE.md +7 -0
- scripts/hooks/pre-commit +23 -0
- scripts/install-hooks.sh +17 -0
- src/api/routes.py +47 -5
- tests/test_sanity.py +178 -0
- ui/app.py +69 -33
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
|
| 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
|
| 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
|
| 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
|
| 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
|
| 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 "<PERSON>" 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 |
-
|
| 211 |
-
const
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
try { localStorage.setItem('guide-theme', isDark ? 'dark' : 'light'); } catch (e) {}
|
| 216 |
-
}"""
|
| 217 |
|
| 218 |
_THEME_LOAD_JS = """() => {
|
| 219 |
-
|
|
|
|
| 220 |
let pref = 'light';
|
| 221 |
try { pref = localStorage.getItem('guide-theme') || 'light'; } catch (e) {}
|
| 222 |
-
const
|
| 223 |
-
|
| 224 |
-
//
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 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
|
| 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"β οΈ
|
| 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 |
-
|
| 1204 |
-
|
| 1205 |
-
|
| 1206 |
-
|
| 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 |
-
|
| 1277 |
-
|
|
|
|
|
|
|
| 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(
|