From 7f46d7fadfbfd1d6bdb4097d80ffb1e5891c0ddb Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Tue, 3 Mar 2026 11:43:35 -0800 Subject: [PATCH] =?UTF-8?q?feat:=20feedback=5Fapi=20=E2=80=94=20mask=5Fpii?= =?UTF-8?q?=20+=20collect=5Fcontext?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/feedback_api.py | 63 ++++++++++++++++++++++++++++++++++++++ tests/test_feedback_api.py | 53 ++++++++++++++++++++++++++++++++ 2 files changed, 116 insertions(+) create mode 100644 scripts/feedback_api.py create mode 100644 tests/test_feedback_api.py diff --git a/scripts/feedback_api.py b/scripts/feedback_api.py new file mode 100644 index 0000000..6b9db8f --- /dev/null +++ b/scripts/feedback_api.py @@ -0,0 +1,63 @@ +""" +Feedback API — pure Python backend, no Streamlit imports. +Called directly from app/feedback.py now; wrappable in a FastAPI route later. +""" +from __future__ import annotations + +import os +import platform +import re +import subprocess +from datetime import datetime, timezone +from pathlib import Path + +import requests +import yaml + +_ROOT = Path(__file__).parent.parent +_EMAIL_RE = re.compile(r"[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}") +_PHONE_RE = re.compile(r"(\+?1[\s\-.]?)?\(?\d{3}\)?[\s\-.]?\d{3}[\s\-.]?\d{4}") + + +def mask_pii(text: str) -> str: + """Redact email addresses and phone numbers from text.""" + text = _EMAIL_RE.sub("[email redacted]", text) + text = _PHONE_RE.sub("[phone redacted]", text) + return text + + +def collect_context(page: str) -> dict: + """Collect app context: page, version, tier, LLM backend, OS, timestamp.""" + # App version from git + try: + version = subprocess.check_output( + ["git", "describe", "--tags", "--always"], + cwd=_ROOT, text=True, timeout=5, + ).strip() + except Exception: + version = "dev" + + # Tier from user.yaml + tier = "unknown" + try: + user = yaml.safe_load((_ROOT / "config" / "user.yaml").read_text()) or {} + tier = user.get("tier", "unknown") + except Exception: + pass + + # LLM backend from llm.yaml + llm_backend = "unknown" + try: + llm = yaml.safe_load((_ROOT / "config" / "llm.yaml").read_text()) or {} + llm_backend = llm.get("provider", "unknown") + except Exception: + pass + + return { + "page": page, + "version": version, + "tier": tier, + "llm_backend": llm_backend, + "os": platform.platform(), + "timestamp": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"), + } diff --git a/tests/test_feedback_api.py b/tests/test_feedback_api.py new file mode 100644 index 0000000..eb3c313 --- /dev/null +++ b/tests/test_feedback_api.py @@ -0,0 +1,53 @@ +"""Tests for the feedback API backend.""" +import pytest +from unittest.mock import patch, MagicMock +from pathlib import Path + + +# ── mask_pii ────────────────────────────────────────────────────────────────── + +def test_mask_pii_email(): + from scripts.feedback_api import mask_pii + assert mask_pii("contact foo@bar.com please") == "contact [email redacted] please" + + +def test_mask_pii_phone_dashes(): + from scripts.feedback_api import mask_pii + assert mask_pii("call 555-123-4567 now") == "call [phone redacted] now" + + +def test_mask_pii_phone_parens(): + from scripts.feedback_api import mask_pii + assert mask_pii("(555) 867-5309") == "[phone redacted]" + + +def test_mask_pii_clean_text(): + from scripts.feedback_api import mask_pii + assert mask_pii("no sensitive data here") == "no sensitive data here" + + +def test_mask_pii_multiple_emails(): + from scripts.feedback_api import mask_pii + result = mask_pii("a@b.com and c@d.com") + assert result == "[email redacted] and [email redacted]" + + +# ── collect_context ─────────────────────────────────────────────────────────── + +def test_collect_context_required_keys(): + from scripts.feedback_api import collect_context + ctx = collect_context("Home") + for key in ("page", "version", "tier", "llm_backend", "os", "timestamp"): + assert key in ctx, f"missing key: {key}" + + +def test_collect_context_page_value(): + from scripts.feedback_api import collect_context + ctx = collect_context("MyPage") + assert ctx["page"] == "MyPage" + + +def test_collect_context_timestamp_is_utc(): + from scripts.feedback_api import collect_context + ctx = collect_context("X") + assert ctx["timestamp"].endswith("Z")