From 441e4ce4ef4e0517f0306375548d7b98246981b4 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Mon, 9 Mar 2026 22:14:22 -0700 Subject: [PATCH] feat(cloud): Privacy & Telemetry tab in Settings + update_consent() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit T11: Add CLOUD_MODE-gated Privacy tab to Settings with full telemetry consent UI — hard kill switch, anonymous usage toggle, de-identified content sharing toggle, and time-limited support access grant. All changes persist to telemetry_consent table via new update_consent() in telemetry.py. Tab and all DB calls are completely no-op in local mode (CLOUD_MODE=false). --- app/pages/2_Settings.py | 105 +++++++++++++++++++++++++++++++++++++++- app/telemetry.py | 37 ++++++++++++++ 2 files changed, 141 insertions(+), 1 deletion(-) diff --git a/app/pages/2_Settings.py b/app/pages/2_Settings.py index 0e0b100..e559f44 100644 --- a/app/pages/2_Settings.py +++ b/app/pages/2_Settings.py @@ -12,7 +12,7 @@ import yaml import os as _os from scripts.user_profile import UserProfile -from app.cloud_session import resolve_session, get_db_path +from app.cloud_session import resolve_session, get_db_path, CLOUD_MODE _USER_YAML = Path(__file__).parent.parent.parent / "config" / "user.yaml" _profile = UserProfile(_USER_YAML) if UserProfile.exists(_USER_YAML) else None @@ -65,10 +65,13 @@ _tab_names = [ "👤 My Profile", "📝 Resume Profile", "🔎 Search", "⚙️ System", "🎯 Fine-Tune", "🔑 License", "💾 Data" ] +if CLOUD_MODE: + _tab_names.append("🔒 Privacy") if _show_dev_tab: _tab_names.append("🛠️ Developer") _all_tabs = st.tabs(_tab_names) tab_profile, tab_resume, tab_search, tab_system, tab_finetune, tab_license, tab_data = _all_tabs[:7] +tab_privacy = _all_tabs[7] if CLOUD_MODE else None # ── Inline LLM generate buttons ─────────────────────────────────────────────── # Unlocked when user has a configured LLM backend (BYOK) OR a paid tier. @@ -1726,3 +1729,103 @@ if _show_dev_tab: st.caption("Label distribution:") for _lbl, _cnt in sorted(_label_counts.items(), key=lambda x: -x[1]): st.caption(f" `{_lbl}`: {_cnt}") + +# ── Privacy & Telemetry (cloud mode only) ───────────────────────────────────── +if CLOUD_MODE and tab_privacy is not None: + with tab_privacy: + from app.telemetry import get_consent as _get_consent, update_consent as _update_consent + + st.subheader("🔒 Privacy & Telemetry") + st.caption( + "You have full, unconditional control over what data leaves your session. " + "Changes take effect immediately." + ) + + _uid = st.session_state.get("user_id", "") + _consent = _get_consent(_uid) if _uid else { + "all_disabled": False, + "usage_events_enabled": True, + "content_sharing_enabled": False, + "support_access_enabled": False, + } + + with st.expander("📊 Usage & Telemetry", expanded=True): + st.markdown( + "CircuitForge is built by a tiny team. Anonymous usage data helps us fix the " + "parts of the job search that are broken. You can opt out at any time." + ) + + _all_off = st.toggle( + "🚫 Disable ALL telemetry", + value=bool(_consent.get("all_disabled", False)), + key="privacy_all_disabled", + help="Hard kill switch — overrides all options below. Nothing is written or transmitted.", + ) + if _all_off != _consent.get("all_disabled", False) and _uid: + _update_consent(_uid, all_disabled=_all_off) + st.rerun() + + st.divider() + + _disabled = _all_off # grey out individual toggles when master switch is on + + _usage_on = st.toggle( + "📈 Share anonymous usage statistics", + value=bool(_consent.get("usage_events_enabled", True)), + disabled=_disabled, + key="privacy_usage_events", + help="Feature usage, error rates, completion counts — no content, no PII.", + ) + if not _disabled and _usage_on != _consent.get("usage_events_enabled", True) and _uid: + _update_consent(_uid, usage_events_enabled=_usage_on) + st.rerun() + + _content_on = st.toggle( + "📝 Share de-identified content for model improvement", + value=bool(_consent.get("content_sharing_enabled", False)), + disabled=_disabled, + key="privacy_content_sharing", + help=( + "Opt-in: anonymised cover letters (PII stripped) may be used to improve " + "the CircuitForge fine-tuned model. Never shared with third parties." + ), + ) + if not _disabled and _content_on != _consent.get("content_sharing_enabled", False) and _uid: + _update_consent(_uid, content_sharing_enabled=_content_on) + st.rerun() + + st.divider() + with st.expander("🎫 Temporary Support Access", expanded=False): + st.caption( + "Grant CircuitForge support read-only access to your session for a specific " + "support ticket. Time-limited and revocable. You will be notified when access " + "expires or is used." + ) + from datetime import datetime as _dt, timedelta as _td + _hours = st.selectbox( + "Access duration", [4, 8, 24, 48, 72], + format_func=lambda h: f"{h} hours", + key="privacy_support_hours", + ) + _ticket = st.text_input("Support ticket reference (optional)", key="privacy_ticket_ref") + if st.button("Grant temporary support access", key="privacy_support_grant"): + if _uid: + try: + from app.telemetry import get_platform_conn as _get_pc + _pc = _get_pc() + _expires = _dt.utcnow() + _td(hours=_hours) + with _pc.cursor() as _cur: + _cur.execute( + "INSERT INTO support_access_grants " + "(user_id, expires_at, ticket_ref) VALUES (%s, %s, %s)", + (_uid, _expires, _ticket or None), + ) + _pc.commit() + st.success( + f"Support access granted until {_expires.strftime('%Y-%m-%d %H:%M')} UTC. " + "You can revoke it here at any time." + ) + except Exception as _e: + st.error(f"Could not save grant: {_e}") + else: + st.warning("Session not resolved — please reload the page.") diff --git a/app/telemetry.py b/app/telemetry.py index fb8a1f7..6125193 100644 --- a/app/telemetry.py +++ b/app/telemetry.py @@ -88,3 +88,40 @@ def log_usage_event( except Exception: # Telemetry must never crash the app pass + + +def update_consent(user_id: str, **fields) -> None: + """ + UPSERT telemetry consent for a user. + + Accepted keyword args (all optional, any subset may be provided): + all_disabled: bool + usage_events_enabled: bool + content_sharing_enabled: bool + support_access_enabled: bool + + Safe to call in cloud mode only — no-op in local mode. + Swallows all exceptions so the Settings UI is never broken by a DB hiccup. + """ + if not CLOUD_MODE: + return + allowed = {"all_disabled", "usage_events_enabled", "content_sharing_enabled", "support_access_enabled"} + cols = {k: v for k, v in fields.items() if k in allowed} + if not cols: + return + try: + conn = get_platform_conn() + col_names = ", ".join(cols) + placeholders = ", ".join(["%s"] * len(cols)) + set_clause = ", ".join(f"{k} = EXCLUDED.{k}" for k in cols) + col_vals = list(cols.values()) + with conn.cursor() as cur: + cur.execute( + f"INSERT INTO telemetry_consent (user_id, {col_names}) " + f"VALUES (%s, {placeholders}) " + f"ON CONFLICT (user_id) DO UPDATE SET {set_clause}, updated_at = NOW()", + [user_id] + col_vals, + ) + conn.commit() + except Exception: + pass