Spaces:
Sleeping
Sleeping
| """SuccessApp β Gradio web app. | |
| Architecture: | |
| Browser -> Gradio (this app) -> Google AI Studio API (Gemma 4) -> response | |
| Run locally: | |
| pip install -r requirements.txt | |
| export GOOGLE_API_KEY=... # or set in .env | |
| python app.py | |
| Or deploy to Hugging Face Spaces (free CPU tier β we don't host model weights). | |
| """ | |
| import os | |
| import tempfile | |
| from datetime import date, datetime | |
| import json | |
| import io | |
| from typing import Optional | |
| import gradio as gr | |
| import matplotlib.pyplot as plt | |
| import networkx as nx | |
| from PIL import Image | |
| # --- Workaround: gradio_client 1.3.x crashes on non-dict JSON schemas (e.g. bool True | |
| # for additionalProperties) when generating auto API docs. We patch the recursive | |
| # converter itself so anything non-dict β and any exception inside β returns "Any" | |
| # instead of propagating an APIInfoParseError up through the request handler. | |
| try: | |
| import gradio_client.utils as _gru | |
| _orig_jspt = _gru._json_schema_to_python_type | |
| def _safe_jspt(schema, defs=None): | |
| if not isinstance(schema, dict): | |
| return "Any" | |
| try: | |
| return _orig_jspt(schema, defs) | |
| except Exception: | |
| return "Any" | |
| _gru._json_schema_to_python_type = _safe_jspt | |
| _orig_get_type = _gru.get_type | |
| def _safe_get_type(schema): | |
| if not isinstance(schema, dict): | |
| return "Any" | |
| try: | |
| return _orig_get_type(schema) | |
| except Exception: | |
| return "Any" | |
| _gru.get_type = _safe_get_type | |
| except Exception: | |
| pass | |
| # Load .env if present (local dev convenience) | |
| try: | |
| from dotenv import load_dotenv | |
| load_dotenv() | |
| except ImportError: | |
| pass | |
| import gemma_client | |
| # ---------- State ---------- | |
| # Per-session state is held in Gradio's State component (passed in/out of handlers). | |
| # Schema: | |
| # { | |
| # "chat_history": [(user, bot), ...], # for gr.Chatbot display | |
| # "raw_history": [str, str, ...], # flat for planner context | |
| # "goal_graphs": [dict, ...], | |
| # "journal_entries": [dict, ...], | |
| # "reminders": [dict, ...], | |
| # "crisis_event": dict | None, # active crisis info for the crisis tab | |
| # } | |
| def fresh_state() -> dict: | |
| return { | |
| # Gradio 5 messages format: [{"role": "user"|"assistant", "content": str}, ...] | |
| "chat_history": [], | |
| "raw_history": [], | |
| "goal_graphs": [], | |
| "journal_entries": [], | |
| "reminders": [], | |
| "crisis_event": None, | |
| # mood_log: one entry per substantive triage turn, drives the Trends tab even | |
| # when the user hasn't explicitly saved a journal entry. | |
| # Each item: {"ts": "2026-05-16", "mood": int 1-10, "category": str, "severity": str, "signals": [..]} | |
| "mood_log": [], | |
| } | |
| # Map severity_signal to an approximate mood score (1=very low, 10=excellent). | |
| # Lets the Trends tab show a curve from chat turns alone. | |
| _SEVERITY_TO_MOOD = { | |
| "low": 8, # user reports low severity issues -> generally fine | |
| "moderate": 5, | |
| "high": 3, | |
| "crisis": 1, | |
| } | |
| # ---------- Tool implementations (pure Python β same contract as the Colab tools) ---------- | |
| def _tool_create_goal_graph(state: dict, args: dict): | |
| # Server-side dedup: if a similar goal already exists, suppress this duplicate. | |
| # The planner sometimes re-emits a goal across consecutive turns; we don't want | |
| # the user to see a redundant chip or have multiple cards for the same aspiration. | |
| new_title = (args.get("goal") or "").strip().lower() | |
| if new_title: | |
| new_words = set(w for w in new_title.split() if len(w) > 2) | |
| for idx, existing in enumerate(state["goal_graphs"]): | |
| existing_title = (existing.get("goal") or "").strip().lower() | |
| existing_words = set(w for w in existing_title.split() if len(w) > 2) | |
| if not new_words or not existing_words: | |
| continue | |
| overlap = len(new_words & existing_words) / max(len(new_words), len(existing_words)) | |
| if overlap >= 0.5: | |
| return {"status": "duplicate_suppressed", "graph_id": idx} | |
| state["goal_graphs"].append(args) | |
| return {"status": "saved", "graph_id": len(state["goal_graphs"]) - 1} | |
| def _tool_save_journal_entry(state: dict, args: dict): | |
| args["date"] = date.today().isoformat() # always override (model hallucinates dates) | |
| state["journal_entries"].append(args) | |
| return {"status": "saved", "entry_id": len(state["journal_entries"]) - 1} | |
| def _tool_schedule_reminder(state: dict, args: dict): | |
| state["reminders"].append(args) | |
| return {"status": "scheduled", "reminder_id": len(state["reminders"]) - 1} | |
| def _tool_show_crisis_resources(state: dict, args: dict): | |
| state["crisis_event"] = { | |
| "category": args.get("category", "other"), | |
| "region_hint": args.get("region_hint", "unknown"), | |
| } | |
| return {"status": "shown"} | |
| TOOL_FNS = { | |
| "create_goal_graph": _tool_create_goal_graph, | |
| "save_journal_entry": _tool_save_journal_entry, | |
| "schedule_reminder": _tool_schedule_reminder, | |
| "show_crisis_resources": _tool_show_crisis_resources, | |
| } | |
| # ---------- Goal graph renderer ---------- | |
| def _render_goal_graph(graph: dict): | |
| import textwrap | |
| G = nx.DiGraph() | |
| for n in graph.get("nodes", []): | |
| wrapped = "\n".join(textwrap.wrap(n["task"], width=18)) or n["task"] | |
| G.add_node(n["id"], label=f"{wrapped}\n({n.get('duration_days', '?')}d)") | |
| for e in graph.get("edges", []): | |
| if len(e) == 2: | |
| G.add_edge(e[0], e[1]) | |
| # kamada_kawai gives more readable layouts for small DAGs than spring | |
| try: | |
| pos = nx.kamada_kawai_layout(G) | |
| except Exception: | |
| pos = nx.spring_layout(G, seed=42, k=3.0) | |
| fig, ax = plt.subplots(figsize=(12, 7)) | |
| nx.draw_networkx_edges( | |
| G, pos, ax=ax, edge_color="#888", arrows=True, arrowsize=14, | |
| connectionstyle="arc3,rad=0.08", min_target_margin=24, | |
| ) | |
| nx.draw_networkx_nodes( | |
| G, pos, ax=ax, node_color="#7BCDF3", node_size=4400, | |
| edgecolors="#4A6FA5", linewidths=1.5, | |
| ) | |
| nx.draw_networkx_labels( | |
| G, pos, labels={n: G.nodes[n]["label"] for n in G.nodes}, | |
| font_size=8, ax=ax, | |
| ) | |
| ax.set_title(f"{graph.get('goal', 'Goal')} β {graph.get('horizon_days', '?')} days", | |
| fontsize=12, pad=12) | |
| ax.axis("off") | |
| # padding so edge labels aren't clipped | |
| if pos: | |
| xs, ys = zip(*pos.values()) | |
| pad = 0.3 | |
| ax.set_xlim(min(xs) - pad, max(xs) + pad) | |
| ax.set_ylim(min(ys) - pad, max(ys) + pad) | |
| fig.tight_layout() | |
| return fig | |
| # ---------- Main chat handler ---------- | |
| CRISIS_MESSAGE = ( | |
| "What you just shared sounds heavy. A real human can help right now.\n\n" | |
| "β’ **US β 988 Suicide & Crisis Lifeline** β call or text **988**\n" | |
| "β’ **UK β Samaritans** β **116 123**\n" | |
| "β’ **India β iCall** β **+91 9152987821**\n" | |
| "β’ **Global directory** β https://www.iasp.info/resources/Crisis_Centres/\n\n" | |
| "While you decide what to do β try this breathing: in through your nose for 4, hold 4, out for 6. Three times.\n\n" | |
| "I'm here whenever you're ready to keep talking." | |
| ) | |
| def chat_step(user_msg: str, state: dict): | |
| try: | |
| return _chat_step_inner(user_msg, state) | |
| except Exception as e: | |
| # Never let an exception paint the Chatbot red. Return a graceful in-chat | |
| # message and the previous state. Print to stderr for the developer. | |
| import traceback, sys | |
| traceback.print_exc(file=sys.stderr) | |
| state = _ensure_state(state) | |
| err_msg = ( | |
| "I hit a snag processing that. The team's been notified. " | |
| f"(error: {type(e).__name__})" | |
| ) | |
| if user_msg and user_msg.strip(): | |
| state["chat_history"].append({"role": "user", "content": user_msg}) | |
| state["chat_history"].append({"role": "assistant", "content": err_msg}) | |
| return state["chat_history"], state, "", "_(error suppressed)_" | |
| def _chat_step_inner(user_msg: str, state: dict): | |
| state = _ensure_state(state) | |
| if not user_msg or not user_msg.strip(): | |
| return state["chat_history"], state, "", "" | |
| # 1) Triage | |
| triage = gemma_client.triage(user_msg, history=state["raw_history"][-10:] or None) | |
| # 2) Crisis short-circuit β even before the planner. | |
| if triage.get("crisis_flag"): | |
| state["crisis_event"] = {"category": triage.get("likely_category", "other"), | |
| "region_hint": "unknown"} | |
| bot_reply = triage.get("acknowledgment", "I hear you.").strip() + "\n\n" + CRISIS_MESSAGE | |
| state["chat_history"].append({"role": "user", "content": user_msg}) | |
| state["chat_history"].append({"role": "assistant", "content": bot_reply}) | |
| state["raw_history"].append(f"USER: {user_msg}") | |
| state["raw_history"].append(f"BOT: {bot_reply}") | |
| return state["chat_history"], state, "", _format_meta( | |
| triage, [], planner_raw={"reasoning": "Crisis flag set β planner SKIPPED. Crisis resources shown.", "tool_calls": [{"name": "show_crisis_resources"}]}, | |
| history_len=len(state["raw_history"]), | |
| ) | |
| # 3) Normal path β acknowledgment + follow-up | |
| bot_reply = triage.get("acknowledgment", "").strip() | |
| follow = triage.get("follow_up_question") | |
| if follow: | |
| bot_reply = f"{bot_reply}\n\n*{follow}*" | |
| state["chat_history"].append({"role": "user", "content": user_msg}) | |
| state["chat_history"].append({"role": "assistant", "content": bot_reply}) | |
| state["raw_history"].append(f"USER: {user_msg}") | |
| state["raw_history"].append(f"BOT: {bot_reply}") | |
| # 3b) Record a mood-log entry from this turn. Drives the Trends tab even when | |
| # the user hasn't explicitly saved a journal entry yet. | |
| if triage.get("severity_signal") in _SEVERITY_TO_MOOD: | |
| state["mood_log"].append({ | |
| "ts": date.today().isoformat(), | |
| "mood": _SEVERITY_TO_MOOD[triage["severity_signal"]], | |
| "category": triage.get("likely_category", "unclear"), | |
| "severity": triage["severity_signal"], | |
| "signals": triage.get("detected_signals", []), | |
| }) | |
| # 4) Planner β only on non-crisis turns | |
| convo = "\n".join(state["raw_history"][-12:]) | |
| planner = gemma_client.plan(triage, convo) | |
| calls = planner.get("tool_calls", []) or [] | |
| # 5) Execute tools (defensively) | |
| executed = [] | |
| for call in calls: | |
| name = call.get("name") | |
| args = call.get("arguments", {}) if isinstance(call.get("arguments"), dict) else {} | |
| fn = TOOL_FNS.get(name) | |
| if not fn: | |
| continue | |
| result = fn(state, args) | |
| executed.append({"name": name, "args": args, "result": result}) | |
| # 6) Surface a brief confirmation in the chat for tools that actually changed state. | |
| # Suppress chips for duplicate-suppressed goals so the chat doesn't get spammy. | |
| if executed: | |
| chips = [] | |
| for ex in executed: | |
| n = ex["name"] | |
| status = (ex.get("result") or {}).get("status", "saved") | |
| if status == "duplicate_suppressed": | |
| continue # don't tell the user "saved" if we silently deduped | |
| if n == "create_goal_graph": | |
| chips.append(f"π Goal saved: *{ex['args'].get('goal','(unnamed)')}*") | |
| elif n == "save_journal_entry": | |
| chips.append("π Journal entry saved") | |
| elif n == "schedule_reminder": | |
| chips.append(f"β° Reminder set: *{ex['args'].get('title','(unnamed)')}*") | |
| if chips: | |
| tail = "\n\n_" + " Β· ".join(chips) + "_" | |
| # Last item in messages format is the assistant message | |
| state["chat_history"][-1]["content"] += tail | |
| return state["chat_history"], state, "", _format_meta( | |
| triage, executed, planner_raw=planner, history_len=len(state["raw_history"]), | |
| ) | |
| def _format_meta(triage: dict, executed: list, planner_raw: Optional[dict] = None, | |
| history_len: int = 0) -> str: | |
| """Inspector panel content β useful for the demo recording AND for debugging | |
| when the planner decides not to act.""" | |
| lines = [ | |
| "**π§ Triage (Gemma 4 β pass 1)**", | |
| f"- category: `{triage.get('likely_category')}`", | |
| f"- severity: `{triage.get('severity_signal')}`", | |
| f"- crisis_flag: `{triage.get('crisis_flag')}`", | |
| f"- goal_hint: `{triage.get('goal_hint')}`", | |
| f"- signals: `{triage.get('detected_signals')}`", | |
| "", | |
| "**π Planner (Gemma 4 β pass 2)**", | |
| ] | |
| if planner_raw is not None: | |
| reasoning = planner_raw.get("reasoning", "(no reasoning returned)") | |
| lines.append(f"- reasoning: _{reasoning}_") | |
| calls = planner_raw.get("tool_calls", []) | |
| if calls: | |
| call_names = ", ".join("`" + str(c.get("name")) + "`" for c in calls) | |
| lines.append(f"- tools called: {call_names}") | |
| else: | |
| lines.append("- tools called: _(none β planner decided no action needed)_") | |
| if executed: | |
| lines.append(f"- executed: β {len(executed)} tool(s) ran") | |
| lines.append("") | |
| lines.append(f"**π§Ύ Memory**: model sees the last {history_len} message(s) of this conversation each turn.") | |
| return "\n".join(lines) | |
| # ---------- Goals/Journal/Photo handlers ---------- | |
| def render_latest_goal(state: dict): | |
| state = _ensure_state(state) | |
| if not state["goal_graphs"]: | |
| return None, "_No goals yet. Mention one in the chat and commit to it._" | |
| g = state["goal_graphs"][-1] | |
| fig = _render_goal_graph(g) | |
| md = f"### {g.get('goal')}\n\n{g.get('horizon_days', '?')} days, {len(g.get('nodes', []))} milestones." | |
| return fig, md | |
| def _ensure_state(state): | |
| """gr.BrowserState may return None on first load. Coerce to fresh state. | |
| Also fills any missing keys if an older schema was previously stored.""" | |
| if not isinstance(state, dict): | |
| return fresh_state() | |
| default = fresh_state() | |
| for k, v in default.items(): | |
| state.setdefault(k, v) | |
| return state | |
| def render_trends(state: dict): | |
| """Mood-over-time line chart + summary stats. Drives the Trends tab. | |
| Pulls data from BOTH explicit journal_entries AND the silent per-turn mood_log, | |
| so the chart populates after just 2 chat messages.""" | |
| state = _ensure_state(state) | |
| # Combine sources into a single timeline of (timestamp_idx, mood, theme_list, source) | |
| points = [] | |
| for j in state.get("journal_entries", []): | |
| if j.get("mood_score") is not None: | |
| points.append((j.get("date", ""), int(j["mood_score"]), j.get("key_themes", []), "journal")) | |
| for m in state.get("mood_log", []): | |
| if m.get("mood") is not None: | |
| points.append((m.get("ts", ""), int(m["mood"]), m.get("signals", []), "chat")) | |
| if len(points) < 2: | |
| return None, ( | |
| "_Have at least 2 chat turns or upload 2 photo entries β Trends populates automatically. " | |
| "Mood is estimated per turn from the triage signals (low / moderate / high / crisis)._" | |
| ) | |
| # Keep insertion order β points already in chronological order by appending. | |
| # When two share the same date string, keep both (intra-day curve still meaningful). | |
| moods = [p[1] for p in points] | |
| labels = [p[0] for p in points] | |
| journal_idx = [i for i, p in enumerate(points) if p[3] == "journal"] | |
| chat_idx = [i for i, p in enumerate(points) if p[3] == "chat"] | |
| fig, ax = plt.subplots(figsize=(11, 4.3)) | |
| ax.plot(range(len(moods)), moods, color="#4A6FA5", linewidth=2, alpha=0.6, zorder=1) | |
| if chat_idx: | |
| ax.scatter(chat_idx, [moods[i] for i in chat_idx], | |
| color="#7BCDF3", s=70, edgecolors="#4A6FA5", linewidths=1.2, | |
| zorder=2, label="chat turn (est.)") | |
| if journal_idx: | |
| ax.scatter(journal_idx, [moods[i] for i in journal_idx], | |
| color="#E67E22", s=90, edgecolors="#A04A00", linewidths=1.2, | |
| marker="D", zorder=3, label="journal entry") | |
| ax.set_xticks(range(len(labels))) | |
| ax.set_xticklabels(labels, rotation=30, ha="right", fontsize=8) | |
| ax.set_ylim(0.5, 10.5) | |
| ax.set_ylabel("Mood (1-10)") | |
| ax.set_title(f"Mood across {len(points)} data points") | |
| ax.grid(alpha=0.25) | |
| if journal_idx and chat_idx: | |
| ax.legend(loc="lower right", fontsize=8) | |
| fig.tight_layout() | |
| avg = sum(moods) / len(moods) | |
| lo, hi = min(moods), max(moods) | |
| trend = "improving π" if moods[-1] > moods[0] else ("declining π" if moods[-1] < moods[0] else "flat β‘οΈ") | |
| from collections import Counter | |
| theme_counts = Counter(t for p in points for t in p[2] if isinstance(t, str)) | |
| top_themes = ", ".join(f"{t} ({n})" for t, n in theme_counts.most_common(6)) | |
| summary = ( | |
| f"**{len(points)} data points** ({len(journal_idx)} journal Β· {len(chat_idx)} chat) " | |
| f"Β· **avg mood** {avg:.1f}/10 Β· **range** {lo}-{hi} Β· **trend:** {trend}\n\n" | |
| f"**Recurring themes / signals:** {top_themes or '_none yet_'}" | |
| ) | |
| return fig, summary | |
| def render_journal(state: dict): | |
| state = _ensure_state(state) | |
| if not state["journal_entries"]: | |
| return "_No journal entries yet. Have a conversation and wrap up β or upload a photo._" | |
| lines = [] | |
| for entry in reversed(state["journal_entries"][-10:]): | |
| date_str = entry.get("date", "?") | |
| mood = entry.get("mood_score", "?") | |
| themes = ", ".join(entry.get("key_themes", [])) | |
| summary = entry.get("summary", "") or entry.get("goal_progress_notes", "") | |
| lines.append(f"**{date_str}** Β· mood {mood}/10\n\n{summary}\n\n_themes: {themes}_\n\n---") | |
| return "\n".join(lines) | |
| def upload_photo(image: Optional[Image.Image], caption: str, state: dict): | |
| state = _ensure_state(state) | |
| if image is None: | |
| return state, render_journal(state), "_Upload an image first._" | |
| parsed = gemma_client.photo_journal(image, caption or "") | |
| entry = { | |
| "date": date.today().isoformat(), | |
| "mood_score": parsed.get("mood_score", 5), | |
| "key_themes": parsed.get("key_themes", []), | |
| "wins": [], | |
| "concerns": [], | |
| "goal_progress_notes": parsed.get("connected_goal_hint") or "", | |
| "summary": parsed.get("summary", ""), | |
| "source": "photo", | |
| } | |
| state["journal_entries"].append(entry) | |
| detected = parsed.get("detected_text", "") | |
| info_md = ( | |
| f"**Summary:** {parsed.get('summary', '')}\n\n" | |
| f"**Mood:** {parsed.get('mood_score', '?')}/10\n\n" | |
| f"**Themes:** {', '.join(parsed.get('key_themes', []))}\n\n" | |
| + (f"**Detected text:** *{detected}*" if detected else "") | |
| ) | |
| return state, render_journal(state), info_md | |
| def reset_session(state: dict): | |
| new_state = fresh_state() | |
| return ( | |
| new_state, # state | |
| [], # chatbot | |
| "", # msg | |
| "", # meta | |
| None, # goal_plot | |
| "_No goals yet._", # goal_summary | |
| "_No journal entries yet._", # journal_view | |
| "", # photo_info | |
| None, # trends_plot | |
| "", # trends_summary | |
| None, # export_file β clear stale download card | |
| "_No import yet._", # import_status β clear stale status | |
| ) | |
| # ---------- Export / Import β the user owns and can move their data ---------- | |
| EXPORT_SCHEMA_VERSION = 1 | |
| def export_state(state: dict): | |
| """Write the user's state to a temp JSON file and return its path so | |
| Gradio's gr.File component triggers a browser download. | |
| Includes ONLY what's already in localStorage β no system prompts, no API key, | |
| no schema metadata beyond a version stamp.""" | |
| state = _ensure_state(state) | |
| payload = { | |
| "schema_version": EXPORT_SCHEMA_VERSION, | |
| "exported_at": datetime.utcnow().isoformat() + "Z", | |
| "chat_history": state.get("chat_history", []), | |
| "raw_history": state.get("raw_history", []), | |
| "goal_graphs": state.get("goal_graphs", []), | |
| "journal_entries": state.get("journal_entries", []), | |
| "mood_log": state.get("mood_log", []), | |
| "reminders": state.get("reminders", []), | |
| } | |
| fname = f"successapp-export-{date.today().isoformat()}.json" | |
| path = os.path.join(tempfile.gettempdir(), fname) | |
| with open(path, "w", encoding="utf-8") as f: | |
| json.dump(payload, f, indent=2, ensure_ascii=False) | |
| return path | |
| def import_state(file_obj, current_state: dict): | |
| """Replace the in-browser state with the contents of an uploaded export. | |
| Defensive: validates structure, falls through to a status message on any | |
| error rather than silently corrupting the user's data.""" | |
| if file_obj is None: | |
| return current_state, "_No file selected. Click the upload box and pick a successapp-export-*.json file._" | |
| try: | |
| path = file_obj if isinstance(file_obj, str) else file_obj.name | |
| with open(path, "r", encoding="utf-8") as f: | |
| imported = json.load(f) | |
| except json.JSONDecodeError as e: | |
| return current_state, f"β Couldn't parse the file as JSON: {e.msg} (line {e.lineno})." | |
| except Exception as e: | |
| return current_state, f"β Couldn't read the file: {type(e).__name__}." | |
| if not isinstance(imported, dict): | |
| return current_state, "β That file isn't a SuccessApp export β top level must be a JSON object." | |
| # Coerce into our state shape, filling any missing keys from defaults. | |
| # If the export was from a future schema version, we try anyway β _ensure_state | |
| # only ADDS missing keys, never drops unrecognized ones, so forward-compat is okay. | |
| new_state = _ensure_state(imported) | |
| status = ( | |
| f"β Imported {len(new_state.get('chat_history', [])) // 2} chat turns, " | |
| f"{len(new_state.get('goal_graphs', []))} goal(s), " | |
| f"{len(new_state.get('journal_entries', []))} journal entr" | |
| f"{'y' if len(new_state.get('journal_entries', [])) == 1 else 'ies'}, " | |
| f"{len(new_state.get('mood_log', []))} mood data point(s)." | |
| ) | |
| if "exported_at" in imported: | |
| status += f"\n\n_Exported at: {imported['exported_at']}_" | |
| return new_state, status | |
| def _chatbot_from_state(state: dict): | |
| """Helper used in .then() chains to re-render the chatbot widget from | |
| the freshly-imported state's chat_history.""" | |
| state = _ensure_state(state) | |
| return state.get("chat_history", []) | |
| # ---------- UI ---------- | |
| INTRO = """ | |
| # SuccessApp β a private wellbeing companion | |
| A safety-first AI listener powered by **Gemma 4**. Designed to gently triage how you're feeling, scaffold long-term goals, and journal your sessions β with a hard-coded crisis pathway that routes to real human help the moment it's needed. | |
| > **Privacy:** Your data is stored **only in your browser's local storage** β never on our servers, never on anyone else's machine. **You can export it to a JSON file** (download to your laptop), re-import it on any other device, or wipe it with one button. The model call to Gemma 4 sends just the current message β your stored journal and goals are not transmitted. | |
| > | |
| > **Not a clinical tool.** This is not a substitute for a therapist. In any emergency, please contact a real professional. | |
| β **Safety evaluation:** 35 adversarial test cases Β· 100% crisis recall (10/10) Β· 0 medical-advice leaks | |
| """ | |
| DISCLAIMER = ( | |
| "Success is an AI companion, not a clinician. " | |
| "It does not diagnose or prescribe. In any emergency, please contact a real professional." | |
| ) | |
| # Example prompts the user can click to seed the chat β useful for the demo video. | |
| EXAMPLE_PROMPTS = [ | |
| "I've been overwhelmed at work and can't sleep.", | |
| "I want to break into product management but I'm stuck.", | |
| "My best friend ghosted me and I don't know what I did.", | |
| "I had a decent day β cooked a real meal for the first time in a while.", | |
| ] | |
| def build_ui(): | |
| with gr.Blocks(theme=gr.themes.Soft(primary_hue="blue"), title="SuccessApp") as demo: | |
| # BrowserState persists the dict in the user's localStorage under this key. | |
| # Same browser, same key -> data survives tab close, refresh, even laptop reboot. | |
| # Different visitors get their own isolated localStorage automatically. | |
| # If we evolve the schema, bump the version suffix to force a clean slate. | |
| state = gr.BrowserState(fresh_state(), storage_key="successapp_v1") | |
| gr.Markdown(INTRO) | |
| with gr.Tabs() as tabs: | |
| # ----- Talk tab ----- | |
| with gr.Tab("π¬ Talk"): | |
| chatbot = gr.Chatbot( | |
| value=[], | |
| label="Success", | |
| height=440, | |
| show_label=False, | |
| type="messages", | |
| ) | |
| with gr.Row(): | |
| msg = gr.Textbox(placeholder="How are you feeling?", scale=8, container=False, | |
| show_label=False, lines=2) | |
| send_btn = gr.Button("Send", scale=1, variant="primary") | |
| gr.Examples( | |
| examples=EXAMPLE_PROMPTS, | |
| inputs=msg, | |
| label="Try one of these", | |
| ) | |
| with gr.Accordion("π What the model decided", open=True): | |
| meta = gr.Markdown("_Send a message to see Gemma 4's triage + planner decisions._") | |
| with gr.Row(): | |
| reset_btn = gr.Button("Forget everything (clear local storage)", variant="secondary") | |
| # ----- Goals tab ----- | |
| with gr.Tab("π― Goals"): | |
| goal_summary = gr.Markdown("_No goals yet. Mention one in the chat and commit to it._") | |
| goal_plot = gr.Plot(label="Goal graph") | |
| refresh_goal_btn = gr.Button("Refresh") | |
| # ----- Trends tab ----- | |
| with gr.Tab("π Trends"): | |
| gr.Markdown( | |
| "**How you're tracking over time.** Mood from each journal entry, plotted in order. " | |
| "Recurring themes surface patterns between life events and how you feel." | |
| ) | |
| trends_summary = gr.Markdown("") | |
| trends_plot = gr.Plot(label="Mood over time") | |
| refresh_trends_btn = gr.Button("Refresh") | |
| # ----- Journal tab ----- | |
| with gr.Tab("π Journal"): | |
| with gr.Row(): | |
| with gr.Column(): | |
| photo_in = gr.Image(label="Snap or upload a photo", type="pil") | |
| cap_in = gr.Textbox(label="Caption (optional)", placeholder="What is this image about for you?", lines=2) | |
| upload_btn = gr.Button("Journal this photo", variant="primary") | |
| photo_info = gr.Markdown("") | |
| with gr.Column(): | |
| journal_view = gr.Markdown("_No journal entries yet. Have a conversation and wrap up β or upload a photo._") | |
| refresh_journal_btn = gr.Button("Refresh") | |
| # ----- About tab ----- | |
| with gr.Tab("βΉοΈ About"): | |
| # --- π¦ Your data block (export / import / forget) --- | |
| gr.Markdown("### π¦ Your data β fully portable, fully yours") | |
| gr.Markdown( | |
| "Everything SuccessApp knows about you lives in your browser. " | |
| "**Export** it to back it up or move to another device. " | |
| "**Import** it to restore on a new browser. **Forget** it to start fresh. " | |
| "The exported file is plain JSON β open it in any text editor to see exactly what's stored." | |
| ) | |
| with gr.Row(): | |
| with gr.Column(): | |
| export_btn = gr.Button("π₯ Export everything to JSON", variant="primary") | |
| export_file = gr.File(label="Download will appear here", interactive=False) | |
| with gr.Column(): | |
| import_file = gr.File( | |
| label="Upload a previous successapp-export-*.json", | |
| file_types=[".json"], | |
| type="filepath", | |
| ) | |
| import_status = gr.Markdown("_No import yet._") | |
| with gr.Column(): | |
| gr.Markdown(" ") # vertical alignment | |
| forget_btn = gr.Button("π Forget everything", variant="stop") | |
| forget_help = gr.Markdown( | |
| "_Wipes localStorage. Same as the button on the Talk tab._", | |
| elem_classes=["secondary-text"], | |
| ) | |
| gr.Markdown("---") | |
| gr.Markdown(""" | |
| ### Architecture | |
| Every message you send goes through **two Gemma 4 calls**: | |
| 1. **Triage** β Returns a strict JSON object describing what you said: category, severity, detected signals, an optional goal hint, and a `crisis_flag`. Five hard rules detect crisis content (suicidal ideation, self-harm, active violence, validation-seeking for harmful acts, psychosis). | |
| 2. **Planner** β Reads the triage JSON and a short conversation summary, then decides which app actions to take: create a goal graph, save a journal entry, schedule a reminder, or surface crisis resources. The planner is **fenced** β on any crisis turn, *only* `show_crisis_resources` can fire. Nothing else. | |
| **Multimodal journaling** uses the same Gemma 4 model to read photos β a handwritten note, a meal, a workout β and write a structured journal entry grounded in what's actually in the image. | |
| ### Safety evaluation | |
| Tested against 35 cases including: | |
| | Category | Cases | Pass rate | | |
| |---------|-------|-----------| | |
| | Crisis (suicidal ideation, self-harm, abuse, validation-seeking, psychosis) | 10 | **100%** | | |
| | Adversarial (medication asks, jailbreaks, role-swap, joke-disguised crisis) | 8 | **100%** | | |
| | Normal / borderline | 17 | **100%** | | |
| Eval harness and full test cases are open-source in the project's GitHub repo. | |
| ### Persistence and privacy | |
| Your conversations, goal graphs, journal entries, and mood trends are stored **only in your browser's localStorage** (key: `successapp_v1`). You can: | |
| - **Export** the whole dataset to JSON (the button above) β back it up or move it to another device | |
| - **Import** an export on any other browser to restore your history | |
| - **Inspect** the data in DevTools β Application β Local Storage | |
| - **Delete** it instantly with the "Forget everything" button | |
| The only thing that leaves your browser is the current chat message (or photo) sent to **Google AI Studio's Gemma 4 API** for inference. Your stored goal graphs, journal history, and mood trends are **never transmitted** β they live with you. | |
| Different visitors of this page get fully isolated localStorage. There is no shared database. | |
| ### Built for the Gemma 4 Good Hackathon | |
| - Health track | |
| - Source: https://github.com/<your-handle>/successapp | |
| - All prompts, schemas, and eval cases are versioned and auditable | |
| ### Safety | |
| """ + DISCLAIMER) | |
| # --- wiring --- | |
| send_btn.click(chat_step, [msg, state], [chatbot, state, msg, meta]) | |
| msg.submit(chat_step, [msg, state], [chatbot, state, msg, meta]) | |
| refresh_goal_btn.click(render_latest_goal, [state], [goal_plot, goal_summary]) | |
| refresh_trends_btn.click(render_trends, [state], [trends_plot, trends_summary]) | |
| refresh_journal_btn.click(render_journal, [state], [journal_view]) | |
| upload_btn.click(upload_photo, [photo_in, cap_in, state], | |
| [state, journal_view, photo_info]) | |
| reset_btn.click( | |
| reset_session, | |
| [state], | |
| [state, chatbot, msg, meta, goal_plot, goal_summary, | |
| journal_view, photo_info, trends_plot, trends_summary, | |
| export_file, import_status], | |
| ) | |
| # The Forget button on the About tab uses the same handler with the same outputs. | |
| forget_btn.click( | |
| reset_session, | |
| [state], | |
| [state, chatbot, msg, meta, goal_plot, goal_summary, | |
| journal_view, photo_info, trends_plot, trends_summary, | |
| export_file, import_status], | |
| ) | |
| # Export: produces a JSON file path; gr.File handles the browser download. | |
| export_btn.click(export_state, [state], [export_file]) | |
| # Import: parses the file, replaces state, then chains refreshes for every | |
| # downstream view so the user immediately sees their restored data. | |
| import_file.upload( | |
| import_state, [import_file, state], [state, import_status] | |
| ).then( | |
| _chatbot_from_state, [state], [chatbot] | |
| ).then( | |
| render_latest_goal, [state], [goal_plot, goal_summary] | |
| ).then( | |
| render_journal, [state], [journal_view] | |
| ).then( | |
| render_trends, [state], [trends_plot, trends_summary] | |
| ) | |
| # Note: we intentionally do NOT auto-render Goals/Journal/Trends on every | |
| # state.change or demo.load β BrowserState rehydrates asynchronously and | |
| # firing render fns with a half-initialized state was causing Gradio to | |
| # show "Error" badges on the components. The user clicks Refresh on each | |
| # tab to render β explicit and predictable. | |
| return demo | |
| if __name__ == "__main__": | |
| print(f"Using model: {gemma_client.model_name()}") | |
| demo = build_ui() | |
| # On HF Spaces we bind 0.0.0.0 so the reverse proxy can reach us. | |
| # Locally on Windows we use 127.0.0.1 to avoid Gradio's "localhost not accessible" error. | |
| on_hf_spaces = bool(os.environ.get("SPACE_ID")) | |
| server_name = "0.0.0.0" if on_hf_spaces else "127.0.0.1" | |
| demo.launch(share=False, server_name=server_name, server_port=7860, show_api=False) | |