Spaces:
Runtime error
Runtime error
| """ | |
| G.U.I.D.E. sanity suite β the fast, must-pass gate that runs before every commit. | |
| These tests deliberately avoid loading ML checkpoints, the spaCy model, the | |
| Anthropic API, or the network. They catch the classes of regression we have | |
| actually hit while iterating on the UI/API: | |
| * the Gradio app graph failing to build (output/intput-count or scope bugs), | |
| * the API request/response schemas breaking, | |
| * the three privacy/export features (redaction reveal, audit trail, PDF) | |
| silently breaking, | |
| * the privacy redactor contract (RedactionResult.spans) changing, | |
| * the friendly LLM-error mapping regressing, | |
| * and β per SPEC.md β the **How To Guide** tab drifting out of sync whenever a | |
| user-facing tab is added or renamed. | |
| Run with: python -m pytest tests/test_sanity.py -q | |
| This is wired into the git pre-commit hook (see SPEC.md). If any of these fail, | |
| the commit is blocked. | |
| """ | |
| from __future__ import annotations | |
| import importlib | |
| import re | |
| import sys | |
| import tempfile | |
| from pathlib import Path | |
| import pytest | |
| _ROOT = Path(__file__).resolve().parent.parent | |
| _UI = _ROOT / "ui" | |
| for _p in (str(_ROOT), str(_UI)): | |
| if _p not in sys.path: | |
| sys.path.insert(0, _p) | |
| def app_module(): | |
| """Import ui/app.py once for the whole module.""" | |
| return importlib.import_module("app") | |
| # --------------------------------------------------------------------------- | |
| # 1. The Gradio app graph must build (catches event/component wiring bugs). | |
| # --------------------------------------------------------------------------- | |
| def test_ui_app_builds(app_module): | |
| demo = app_module.build_app() | |
| assert demo is not None | |
| assert type(demo).__name__ == "Blocks" | |
| # --------------------------------------------------------------------------- | |
| # 2. API schemas import and validate. | |
| # --------------------------------------------------------------------------- | |
| def test_api_models_and_routes_import(): | |
| from src.api import models, routes # noqa: F401 | |
| msg = models.MessageResponse( | |
| reply="hi", pii_redacted=True, pii_types_found=["PERSON"], | |
| original_text="a", redacted_text="b", | |
| redactions=[models.RedactionSpanOut( | |
| entity_type="PERSON", original="a", placeholder="<PERSON>", | |
| start=0, end=1)], | |
| ) | |
| assert msg.redactions[0].placeholder == "<PERSON>" | |
| audit = models.AuditResponse(session_id="s", entries=[models.AuditEntry( | |
| timestamp="t", event="outbound_to_anthropic", description="d", | |
| transmitted_text="b", pii_types_found=["PERSON"], pii_count=1, | |
| leak_check="passed")]) | |
| assert audit.entries[0].leak_check == "passed" | |
| # --------------------------------------------------------------------------- | |
| # 3. Privacy redactor contract β RedactionResult must carry per-span reveal data. | |
| # --------------------------------------------------------------------------- | |
| def test_redactor_result_contract(): | |
| from src.privacy.redactor import RedactionResult, RedactionSpan | |
| r = RedactionResult(redacted_text="x") | |
| assert hasattr(r, "spans") and r.spans == [] | |
| span = RedactionSpan(entity_type="PERSON", original="Ravi", | |
| placeholder="<PERSON>", start=0, end=4) | |
| assert span.placeholder == "<PERSON>" | |
| # --------------------------------------------------------------------------- | |
| # 4. Feature helpers: redaction reveal, audit render, PDF export. | |
| # --------------------------------------------------------------------------- | |
| def test_redaction_reveal_html(app_module): | |
| resp = { | |
| "original_text": "I am Ravi, phone 9876543210.", | |
| "redacted_text": "I am <PERSON>, phone <PHONE_NUMBER>.", | |
| "redactions": [ | |
| {"entity_type": "PERSON", "original": "Ravi", | |
| "placeholder": "<PERSON>", "start": 5, "end": 9}, | |
| {"entity_type": "PHONE_NUMBER", "original": "9876543210", | |
| "placeholder": "<PHONE_NUMBER>", "start": 17, "end": 27}, | |
| ], | |
| } | |
| html = app_module._render_redaction_html(resp) | |
| assert "Ravi" in html and "<PERSON>" in html | |
| assert "2 personal identifier" in html | |
| # Nothing stripped β panel hidden. | |
| assert app_module._render_redaction_html({"redactions": []}) == "" | |
| def test_audit_render(app_module): | |
| assert "No activity yet" in app_module._render_audit_html([]) | |
| entries = [{ | |
| "timestamp": "2026-06-23T12:30:01+00:00", "event": "outbound_to_anthropic", | |
| "description": "Message sent to Claude API β 1 personal identifier stripped.", | |
| "transmitted_text": "I am <PERSON>", "pii_types_found": ["PERSON"], | |
| "pii_count": 1, "leak_check": "passed", | |
| }] | |
| out = app_module._render_audit_html(entries) | |
| assert "0 raw identifiers transmitted" in out | |
| def test_extract_draft_is_letter_only(app_module): | |
| """The Complaint Draft box must contain ONLY the letter (Subject: β¦ Contact:), | |
| with no preamble and no trailing tips / escalation-invite chatter.""" | |
| reply = ( | |
| "Here is your complaint letter:\n\n" | |
| "---\n" | |
| "Subject: Complaint regarding contaminated food product\n\n" | |
| "To,\nThe Grievance Officer\nABC Foods Ltd.\n\n" | |
| "Dear Sir/Madam,\n\n" | |
| "I am writing to complain ...\n\n" | |
| "Yours sincerely,\n<PERSON>\nDate: 23rd July 2025\n" | |
| "Contact: <EMAIL_ADDRESS> / <PHONE_NUMBER>\n" | |
| "---\n\n" | |
| "> π Tip: Attach all medical bills as Annexure A.\n\n" | |
| 'Whenever you\'re ready, just ask me to "generate the escalation guide".' | |
| ) | |
| draft = app_module._extract_draft(reply) | |
| assert draft.startswith("Subject:") | |
| assert draft.rstrip().endswith("Contact: <EMAIL_ADDRESS> / <PHONE_NUMBER>") | |
| assert "Here is your complaint letter" not in draft | |
| assert "Tip:" not in draft | |
| assert "escalation guide" not in draft | |
| def test_pdf_export(app_module): | |
| draft = ("Subject: Test complaint\n\nTo,\nThe Grievance Officer\n\n" | |
| "Dear Sir/Madam,\n\nThis is a test.\n\nYours sincerely,\n<PERSON>") | |
| out = tempfile.mktemp(suffix=".pdf") | |
| app_module._build_pdf(draft, out) | |
| data = Path(out).read_bytes() | |
| assert data[:5] == b"%PDF-" and len(data) > 800 | |
| # --------------------------------------------------------------------------- | |
| # 5. Friendly LLM-error mapping (overloaded / rate-limit / timeout). | |
| # --------------------------------------------------------------------------- | |
| def test_llm_error_mapping(): | |
| from src.api.routes import _llm_http_error | |
| assert _llm_http_error(Exception("Overloaded")).status_code == 503 | |
| assert _llm_http_error(Exception("rate limit exceeded")).status_code == 429 | |
| assert _llm_http_error(Exception("Request timed out")).status_code == 504 | |
| overloaded = _llm_http_error(Exception("Overloaded")) | |
| assert "overloaded" in overloaded.detail.lower() | |
| # --------------------------------------------------------------------------- | |
| # 6. SPEC rule: the "How To Guide" tab must mention every user-facing tab. | |
| # This fails the moment a tab is added/renamed without updating the guide. | |
| # --------------------------------------------------------------------------- | |
| def _plain(label: str) -> str: | |
| """Strip emoji/punctuation from a tab label β its plain name.""" | |
| return re.sub(r"[^A-Za-z ]", "", label).strip() | |
| def test_how_to_guide_lists_every_tab(): | |
| src = (_UI / "app.py").read_text(encoding="utf-8") | |
| labels = re.findall(r'gr\.Tab\("([^"]+)"\)', src) | |
| assert labels, "No gr.Tab(...) definitions found β did the UI structure change?" | |
| guide_start = src.index('gr.Tab("π How To Guide")') | |
| guide_end = src.index('gr.Tab("π¬ Chat")') | |
| guide_text = src[guide_start:guide_end] | |
| # Every tab except the guide itself and the technical About tab must be | |
| # described in the How To Guide. | |
| skip = {"How To Guide", "About"} | |
| missing = [ | |
| _plain(lbl) for lbl in labels | |
| if _plain(lbl) not in skip and _plain(lbl) not in guide_text | |
| ] | |
| assert not missing, ( | |
| f"The 'How To Guide' tab does not mention these tabs: {missing}. " | |
| "Per SPEC.md, update the How To Guide whenever you add or rename a tab." | |
| ) | |