feat(cloud): Privacy & Telemetry tab in Settings + update_consent()
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).
This commit is contained in:
parent
3b9bd5f551
commit
f230588291
2 changed files with 141 additions and 1 deletions
|
|
@ -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.")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue