"""
Reality Divergence + Elsewhere
AI-native platform for exploring counterfactual personal and historical timelines.
Uses Qwen2.5-7B-Instruct (via the Hugging Face Inference API) for reasoning, and
renders results as structured, legible HTML cards.
"""
import base64
import html
import json
import os
from io import BytesIO
from typing import Dict, Optional
import gradio as gr
import config
import prompts
from model_client import infer_text, infer_json
from image_client import get_image_client
# ============================================================================
# Fallback Data (for offline/demo mode)
# ============================================================================
FALLBACK_ANALYSIS = {
"emotion_score": 6,
"regret_score": 7,
"self_blame_score": 3,
"category": "Career",
"risk_level": "low",
"risk_markers": []
}
FALLBACK_MIRROR = (
"You're carrying a moment where one choice—perhaps a risk declined or accepted—"
"changed the shape of what came next. A part of you may still be wondering who you'd have become "
"if that moment had unfolded differently. That curiosity deserves a careful frame."
)
FALLBACK_TIMELINE = {
"title": "The Path Not Taken",
"divergence": "At the moment you made your choice, everything branched.",
"ripples": [
"Your confidence would have grown in a different direction.",
"New relationships might have formed, others would have stayed distant.",
"Your skills would have developed along an alternate path.",
"The people around you would have seen a different version of you.",
"Your understanding of yourself would be fundamentally different."
],
"the_you": (
"You might have become more outwardly bold or more introspective, depending on the path. "
"But you would have carried different lessons, different wounds, different strengths."
),
"return": {
"opening": "In the life you actually lived, you learned something you could not have learned elsewhere.",
"strengths": [
"You learned to make choices under incomplete information.",
"You carried responsibility even when the answer was unclear.",
"You kept enough curiosity to revisit this moment with honesty."
],
"lesson": "A path can be meaningful without being the only path that could have mattered.",
"reflection_question": "What did your actual timeline make possible that the alternate one might have cost?"
},
"quote": "Another life may have opened doors, but this one taught you how to stand."
}
FALLBACK_WORLD_DIVERGENCE = {
"title": "Apple Never Launches the iPhone",
"divergence_point": "2007: Apple decides to focus on computers instead of mobile",
"year_divergence": 2007,
"ripples": [
"Business phones and BlackBerrys dominate mobile computing.",
"Apps arrive later and through different distribution channels.",
"Mobile photography remains less central to daily life.",
"Touchscreen adoption slows without the iPhone's refinement.",
"Tech culture evolves around portable computing differently."
],
"world_outcome": (
"A world where mobile computing exists but remains fragmented. "
"Productivity-focused devices outnumber entertainment devices. "
"The social media landscape never reaches its current scale."
),
"economy": "Business hardware companies thrive. Software consolidation never happens.",
"culture": "Always-connected culture develops slower. Social media remains desktop-first.",
"technology": "Cloud services evolve differently. AR/VR developments diverge.",
"winners": ["Microsoft", "BlackBerry", "Desktop computing"],
"losers": ["App-based startups", "Mobile creators", "Social media explosion"],
"reflection": "One device shaped a culture. What else in our world hinges on a single innovation?"
}
# ============================================================================
# Utility Functions
# ============================================================================
def safe_int(val, default=0, min_val=0, max_val=10):
"""Safely convert and clamp integer values."""
try:
return max(min_val, min(max_val, int(val)))
except (ValueError, TypeError):
return default
def safe_str(val, default=""):
"""Safely convert to string."""
try:
return str(val).strip() if val else default
except Exception:
return default
def safe_list(val, default=None):
"""Safely extract list values."""
try:
if isinstance(val, list):
return [safe_str(v) for v in val]
return default or []
except Exception:
return default or []
def merge_analysis_with_fallback(parsed: Optional[Dict]) -> Dict:
"""Merge parsed analysis with fallback; one-way upgrade (risk only escalates)."""
if not parsed:
return FALLBACK_ANALYSIS
fallback = FALLBACK_ANALYSIS.copy()
# Merge, preferring parsed values where valid
result = {
"emotion_score": safe_int(parsed.get("emotion_score"), fallback["emotion_score"]),
"regret_score": safe_int(parsed.get("regret_score"), fallback["regret_score"]),
"self_blame_score": safe_int(parsed.get("self_blame_score"), fallback["self_blame_score"]),
"category": safe_str(parsed.get("category"), fallback["category"]),
"risk_level": "high" if (parsed.get("risk_level") == "high" or fallback["risk_level"] == "high") else safe_str(parsed.get("risk_level"), fallback["risk_level"]),
"risk_markers": safe_list(parsed.get("risk_markers"), fallback["risk_markers"])
}
return result
# ============================================================================
# Safety & Analysis
# ============================================================================
def analyze_story(story: str) -> Dict:
"""Analyze personal story for emotion, regret, and risk."""
if not story or len(story.strip()) < config.MIN_STORY_LENGTH:
return FALLBACK_ANALYSIS
# Try model analysis
parsed = infer_json(prompts.ANALYSIS_PROMPT, story, temperature=0.1, max_tokens=1024)
return merge_analysis_with_fallback(parsed)
def generate_mirror(story: str, analysis: Dict) -> str:
"""Generate compassionate mirror reflection."""
prompt = f"Story:\n{story}\n\nAnalysis:\n{json.dumps(analysis)}"
mirror = infer_text(prompts.MIRROR_PROMPT, prompt, temperature=0.55, max_tokens=150)
return mirror if mirror else FALLBACK_MIRROR
def generate_timeline(story: str, analysis: Dict, direction: str) -> Dict:
"""Generate counterfactual timeline (Upward/Downward)."""
prompt = f"""Story:\n{story}\n\nAnalysis:\n{json.dumps(analysis)}\n\nDirection: {direction}"""
parsed = infer_json(prompts.TIMELINE_PROMPT, prompt, temperature=0.7, max_tokens=1024)
if not parsed:
return FALLBACK_TIMELINE
# Validate structure
return {
"title": safe_str(parsed.get("title"), FALLBACK_TIMELINE["title"]),
"divergence": safe_str(parsed.get("divergence"), FALLBACK_TIMELINE["divergence"]),
"ripples": safe_list(parsed.get("ripples"), FALLBACK_TIMELINE["ripples"]),
"the_you": safe_str(parsed.get("the_you"), FALLBACK_TIMELINE["the_you"]),
"return": {
"opening": safe_str(parsed.get("return", {}).get("opening"), FALLBACK_TIMELINE["return"]["opening"]),
"strengths": safe_list(parsed.get("return", {}).get("strengths"), FALLBACK_TIMELINE["return"]["strengths"]),
"lesson": safe_str(parsed.get("return", {}).get("lesson"), FALLBACK_TIMELINE["return"]["lesson"]),
"reflection_question": safe_str(parsed.get("return", {}).get("reflection_question"), FALLBACK_TIMELINE["return"]["reflection_question"])
},
"quote": safe_str(parsed.get("quote"), FALLBACK_TIMELINE["quote"])
}
def generate_world_divergence(scenario: str) -> Dict:
"""Generate world/historical divergence."""
prompt = f"Divergence scenario: {scenario}"
parsed = infer_json(prompts.WORLD_DIVERGENCE_PROMPT, prompt, temperature=0.7, max_tokens=1024)
if not parsed:
return FALLBACK_WORLD_DIVERGENCE
return {
"title": safe_str(parsed.get("title"), FALLBACK_WORLD_DIVERGENCE["title"]),
"divergence_point": safe_str(parsed.get("divergence_point"), FALLBACK_WORLD_DIVERGENCE["divergence_point"]),
"year_divergence": safe_int(parsed.get("year_divergence"), 2000, 1800, 2050),
"ripples": safe_list(parsed.get("ripples"), FALLBACK_WORLD_DIVERGENCE["ripples"]),
"world_outcome": safe_str(parsed.get("world_outcome"), FALLBACK_WORLD_DIVERGENCE["world_outcome"]),
"economy": safe_str(parsed.get("economy"), FALLBACK_WORLD_DIVERGENCE["economy"]),
"culture": safe_str(parsed.get("culture"), FALLBACK_WORLD_DIVERGENCE["culture"]),
"technology": safe_str(parsed.get("technology"), FALLBACK_WORLD_DIVERGENCE["technology"]),
"winners": safe_list(parsed.get("winners"), FALLBACK_WORLD_DIVERGENCE["winners"]),
"losers": safe_list(parsed.get("losers"), FALLBACK_WORLD_DIVERGENCE["losers"]),
"reflection": safe_str(parsed.get("reflection"), FALLBACK_WORLD_DIVERGENCE["reflection"])
}
# ============================================================================
# HTML Card Rendering
# ============================================================================
# Strongly suppress any lettering — diffusion models render text as gibberish.
_NO_TEXT = "no text, no words, no letters, no typography, no captions, no signage"
def scene_data_uri(prompt: str) -> str:
"""Generate a text-free atmospheric image and return it as a base64 data URI.
Returns "" on any failure so the cards still render without an image.
"""
if not config.ENABLE_IMAGE_GEN:
return ""
try:
img = get_image_client().generate(prompt)
if img is None:
return ""
buf = BytesIO()
img.convert("RGB").save(buf, format="JPEG", quality=85)
b64 = base64.b64encode(buf.getvalue()).decode("ascii")
return f"data:image/jpeg;base64,{b64}"
except Exception as e:
print(f"[warn]scene image failed: {e}")
return ""
def timeline_scene_prompt(timeline: Dict, direction: str) -> str:
"""Build a text-free image prompt for a personal timeline."""
mood = "warm golden hopeful luminous sunrise" if direction.lower() == "upward" else "moody cool somber dramatic dusk"
title = timeline.get("title", "an alternate life")
return (
f"Atmospheric cinematic symbolic landscape representing the theme '{title}', "
f"{mood}, dreamlike concept art, soft volumetric light, painterly, {_NO_TEXT}"
)
def world_scene_prompt(world: Dict) -> str:
"""Build a text-free image prompt for an alternate world."""
title = world.get("title", "an alternate world")
return (
f"Epic cinematic establishing shot of an alternate world: '{title}', "
f"atmospheric concept art, dramatic lighting, highly detailed, {_NO_TEXT}"
)
def _list_items(items) -> str:
"""Render a list of strings as escaped
elements."""
return "".join(f"{html.escape(str(i))}" for i in items if str(i).strip())
def _timeline_nodes(items) -> str:
"""Render a list of strings as glowing timeline-spine nodes."""
return "".join(
f""
for i in items if str(i).strip()
)
def _check_items(items) -> str:
"""Render a list of strings as checkmark list items."""
return "".join(f"{html.escape(str(i))}" for i in items if str(i).strip())
def render_timeline_html(timeline: Dict, direction: str, image_uri: str = "") -> str:
"""Render a personal counterfactual timeline as structured cards."""
is_up = direction.lower() == "upward"
arrow = "↑" if is_up else "↓"
label = "The path where it went better" if is_up else "The path where it went harder"
ret = timeline.get("return", {}) or {}
img = f"
" if image_uri else ""
return f"""
{img}
⚗ One possible path — modeled, not predicted
{arrow}
{label}
{html.escape(timeline.get('title', 'Alternate Timeline'))}
{html.escape(timeline.get('divergence', ''))}
⏳ Ripples through the years
{_timeline_nodes(timeline.get('ripples', []))}
🌗 Who you might have become
{html.escape(timeline.get('the_you', ''))}
↩ Returning to your actual life
{html.escape(ret.get('opening', ''))}
Strengths you actually built
{_check_items(ret.get('strengths', []))}
Lesson: {html.escape(ret.get('lesson', ''))}
💭 A question to sit with
{html.escape(ret.get('reflection_question', ''))}
{html.escape(timeline.get('quote', ''))}
"""
def render_world_html(world: Dict, image_uri: str = "") -> str:
"""Render an alternate-world divergence as structured cards."""
img = f"
" if image_uri else ""
return f"""
{img}
⚗ Speculative — causal chains are modeled, not known
🌍
Year {html.escape(str(world.get('year_divergence', '')))} · Point of divergence
{html.escape(world.get('title', 'Alternate World'))}
{html.escape(world.get('divergence_point', ''))}
⏳ Ripples
{_timeline_nodes(world.get('ripples', []))}
🌐 How this world evolved
{html.escape(world.get('world_outcome', ''))}
💰Economy
{html.escape(world.get('economy', ''))}
🎭Culture
{html.escape(world.get('culture', ''))}
⚙️Technology
{html.escape(world.get('technology', ''))}
📈Who thrived
{_list_items(world.get('winners', []))}
📉Who struggled
{_list_items(world.get('losers', []))}
🧭 What it teaches us
{html.escape(world.get('reflection', ''))}
"""
# ============================================================================
# CSS Styling (SVG-inspired dark theme)
# ============================================================================
CSS = """
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap');
:root {
--bg-dark: #050816;
--bg-mid: #08111F;
--bg-light: #130A24;
--accent-blue: #38BDF8;
--accent-purple: #8B5CF6;
--text-primary: #F8FAFC;
--text-secondary: #94A3B8;
--border: #334155;
}
body, .gradio-container {
background: linear-gradient(135deg, var(--bg-dark), var(--bg-mid), var(--bg-light));
color: var(--text-primary);
font-family: Inter, system-ui, sans-serif;
}
.gradio-container {
max-width: 1400px;
margin: auto;
}
/* Headers */
.hero-title {
display: inline-block;
font-size: 3.5rem;
font-weight: 800;
letter-spacing: -1px;
margin-top: 0.5rem;
color: #7DD3FC; /* solid, always-visible fallback */
}
/* Only apply the transparent gradient-text effect where it is actually supported,
and force our background to win over Gradio's theme CSS. */
@supports ((-webkit-background-clip: text) or (background-clip: text)) {
.hero-title {
background: linear-gradient(110deg, var(--accent-blue), var(--accent-purple), #F0ABFC, var(--accent-blue)) !important;
background-size: 250% auto !important;
-webkit-background-clip: text !important;
background-clip: text !important;
-webkit-text-fill-color: transparent !important;
animation: rdHero 7s ease infinite;
}
}
@keyframes rdHero {
0% { background-position: 0% center; }
50% { background-position: 100% center; }
100% { background-position: 0% center; }
}
.hero-subtitle {
font-size: 1.25rem;
color: var(--text-secondary);
margin-top: 1rem;
}
/* Cards */
.timeline-card, .artifact-container, .world-card {
background: linear-gradient(180deg, rgba(17, 24, 39, 0.5), rgba(11, 16, 32, 0.3));
border: 1px solid var(--border);
border-radius: 16px;
padding: 24px;
backdrop-filter: blur(8px);
}
.timeline-card:hover {
border-color: var(--accent-blue);
box-shadow: 0 0 20px rgba(56, 189, 248, 0.2);
}
/* Buttons */
.gr-button {
background: var(--accent-blue) !important;
color: white !important;
border: 0 !important;
border-radius: 12px !important;
font-weight: 700 !important;
transition: all 0.3s ease;
}
.gr-button:hover {
background: var(--accent-purple) !important;
box-shadow: 0 0 20px rgba(139, 92, 246, 0.4);
}
/* Tabs */
.gr-tabs-nav {
border-bottom: 2px solid var(--border);
}
.gr-tabs-nav .gr-tab-nav-item {
color: var(--text-secondary);
font-weight: 600;
}
.gr-tabs-nav .gr-tab-nav-item.gr-tab-nav-item-selected {
color: var(--accent-blue);
border-bottom: 3px solid var(--accent-blue);
}
/* Layout helpers */
.parallel-layout {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2rem;
margin-top: 2rem;
}
.timeline-column, .image-column {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.artifact-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
margin-top: 1rem;
}
.artifact-item {
border-radius: 12px;
overflow: hidden;
border: 1px solid var(--border);
}
.artifact-item img {
width: 100%;
height: auto;
display: block;
}
/* Mobile */
@media (max-width: 1024px) {
.parallel-layout {
grid-template-columns: 1fr;
}
.hero-title {
font-size: 2rem;
}
.artifact-grid {
grid-template-columns: 1fr;
}
}
/* Structured result cards (timeline / world) */
@keyframes rdFadeUp {
from { opacity: 0; transform: translateY(14px); }
to { opacity: 1; transform: translateY(0); }
}
.rd-stack { display: flex; flex-direction: column; gap: 1.1rem; margin-top: 0.5rem; }
.rd-card {
position: relative;
overflow: hidden;
background: linear-gradient(180deg, rgba(17, 24, 39, 0.65), rgba(11, 16, 32, 0.4));
border: 1px solid var(--border);
border-radius: 18px;
padding: 22px 26px;
animation: rdFadeUp 0.5s ease both;
transition: border-color .25s ease, box-shadow .25s ease, transform .25s ease;
}
.rd-card:hover {
border-color: rgba(56, 189, 248, 0.55);
box-shadow: 0 10px 34px rgba(5, 8, 22, 0.6), 0 0 24px rgba(56, 189, 248, 0.12);
transform: translateY(-2px);
}
.rd-fork {
border: 1px solid rgba(56, 189, 248, 0.35);
background:
radial-gradient(1100px 200px at 0% 0%, rgba(56, 189, 248, 0.13), transparent 60%),
linear-gradient(180deg, rgba(17, 24, 39, 0.72), rgba(11, 16, 32, 0.45));
}
.rd-fork::before, .rd-return::before {
content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 4px;
background: linear-gradient(180deg, var(--accent-blue), var(--accent-purple));
}
.rd-question {
border: 1px solid rgba(139, 92, 246, 0.5);
background:
radial-gradient(800px 130px at 100% 0%, rgba(139, 92, 246, 0.18), transparent 70%),
rgba(139, 92, 246, 0.06);
}
.rd-hero-img {
display: block; width: 100%; height: 220px; object-fit: cover;
border-radius: 14px; margin: -2px 0 16px;
border: 1px solid var(--border);
box-shadow: 0 8px 30px rgba(5, 8, 22, 0.55);
}
.rd-spec {
display: inline-flex; align-items: center; gap: 0.4rem;
font-size: 0.72rem; font-weight: 700; letter-spacing: 0.04em;
text-transform: uppercase;
color: #FBBF24;
background: rgba(251, 191, 36, 0.12);
border: 1px solid rgba(251, 191, 36, 0.4);
border-radius: 999px;
padding: 4px 12px;
margin-bottom: 0.9rem;
}
.rd-badge {
display: inline-flex; align-items: center; justify-content: center;
width: 46px; height: 46px; border-radius: 50%;
font-size: 1.4rem; font-weight: 800; color: #fff;
background: linear-gradient(135deg, var(--accent-blue), var(--accent-purple));
box-shadow: 0 0 20px rgba(56, 189, 248, 0.5);
margin-bottom: 0.7rem;
}
.rd-eyebrow {
font-size: 0.78rem; font-weight: 700; letter-spacing: 0.08em;
text-transform: uppercase; color: var(--accent-blue); margin-bottom: 0.55rem;
}
.rd-sublabel { font-size: 0.85rem; font-weight: 700; color: var(--text-secondary); margin: 0.9rem 0 0.4rem; letter-spacing: .03em; }
.rd-title { font-size: 1.7rem; font-weight: 800; margin: 0.1rem 0 0.5rem; color: var(--text-primary); letter-spacing: -0.5px; }
.rd-lead { color: var(--text-secondary); font-size: 1.08rem; line-height: 1.55; }
.rd-card p { color: var(--text-primary); line-height: 1.65; margin: 0.25rem 0; }
/* Timeline spine */
.rd-timeline { position: relative; margin: 0.3rem 0 0; padding-left: 30px; }
.rd-timeline::before {
content: ""; position: absolute; left: 7px; top: 6px; bottom: 6px; width: 2px;
background: linear-gradient(180deg, var(--accent-blue), var(--accent-purple));
opacity: 0.55;
}
.rd-node { position: relative; padding-bottom: 1rem; }
.rd-node:last-child { padding-bottom: 0; }
.rd-node::before {
content: ""; position: absolute; left: -30px; top: 4px;
width: 14px; height: 14px; border-radius: 50%;
background: var(--accent-blue); border: 3px solid var(--bg-dark);
box-shadow: 0 0 12px rgba(56, 189, 248, 0.75);
}
.rd-node p { margin: 0; color: var(--text-primary); line-height: 1.6; }
/* Strengths with check marks */
.rd-checks { list-style: none; margin: 0.25rem 0 0; padding: 0; }
.rd-checks li { position: relative; padding-left: 1.7rem; margin-bottom: 0.45rem; color: var(--text-primary); line-height: 1.55; }
.rd-checks li::before { content: "✓"; position: absolute; left: 0; color: #34D399; font-weight: 800; }
.rd-list { margin: 0.25rem 0 0; padding-left: 1.2rem; }
.rd-list li { color: var(--text-primary); line-height: 1.6; margin-bottom: 0.35rem; }
.rd-lesson { margin-top: 0.8rem; color: var(--text-secondary); }
.rd-q { font-size: 1.15rem; font-style: italic; color: var(--text-primary); line-height: 1.6; }
.rd-quote {
position: relative; margin: 0.3rem 0 0; padding: 0.5rem 0 0.5rem 2.6rem;
font-size: 1.28rem; font-style: italic; color: var(--text-primary); line-height: 1.5;
}
.rd-quote::before {
content: "\201C"; position: absolute; left: 0; top: 0;
font-size: 3.4rem; line-height: 1; color: var(--accent-purple); font-family: Georgia, serif;
}
/* Stat + win/lose grids */
.rd-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem; }
.rd-grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
.rd-stat { border-top: 3px solid var(--accent-blue); }
.rd-winners { border-left: 3px solid #34D399; }
.rd-losers { border-left: 3px solid #F87171; }
.rd-ico { display: block; font-size: 1.6rem; margin-bottom: 0.35rem; }
@media (max-width: 1024px) {
.rd-grid, .rd-grid-2 { grid-template-columns: 1fr; }
}
"""
# ============================================================================
# Gradio UI
# ============================================================================
with gr.Blocks(css=CSS, title="Reality Divergence") as demo:
# State variables
story_state = gr.State("")
analysis_state = gr.State(None)
timeline_state = gr.State(None)
direction_state = gr.State("Upward")
with gr.Tabs():
# ========== ELSEWHERE TAB ==========
with gr.TabItem("🪞 Elsewhere (Your Timeline)", id="elsewhere"):
# Hero
gr.HTML("Explore the Life You Didn't Live
")
gr.HTML("A compassionate reflection on one choice that changed everything.
")
# Input
with gr.Group():
gr.Markdown("#### Your Moment")
story_input = gr.Textbox(
label="Tell me about a decision, event, or conversation that changed the shape of what came next.",
placeholder="Share what happened, what you chose, and what you wonder about...",
lines=8,
max_lines=12
)
submit_btn = gr.Button("Reflect", variant="primary", size="lg")
# Output
with gr.Group():
gr.Markdown("#### Your Reflection")
mirror_output = gr.Markdown(FALLBACK_MIRROR, elem_classes=["timeline-card"])
# Path selection
gr.Markdown("#### Choose a Path")
with gr.Row():
upward_btn = gr.Button("↑ What if it went better?", variant="primary")
downward_btn = gr.Button("↓ What if it went worse?")
# Full timeline result, rendered as cards (hidden until a path is chosen)
timeline_result = gr.HTML(visible=False)
return_button = gr.Button("↩ Return to Your Actual Life", visible=False, variant="primary")
# ========== REALITY DIVERGENCE TAB ==========
with gr.TabItem("🌍 Reality Divergence (The World)", id="reality"):
# Hero
gr.HTML("The Museum of Worlds That Never Happened
")
gr.HTML("What if one decision shaped the entire world?
")
# Input
with gr.Group():
gr.Markdown("#### Divergence Point")
world_input = gr.Textbox(
label="Describe a historical or future decision that would reshape civilization.",
placeholder="e.g., 'What if the Renaissance never happened?' or 'What if AI was never invented?'",
lines=6
)
world_submit = gr.Button("Generate World", variant="primary", size="lg")
# Output: structured world cards
with gr.Group():
gr.Markdown("#### The Alternate World")
world_output = gr.HTML("")
# ============================================================================
# Event Handlers
# ============================================================================
def handle_submit_story(story: str):
"""Process personal story submission.
Returns: (mirror_md, story_state, analysis_state)
"""
if not story or len(story.strip()) < config.MIN_STORY_LENGTH:
return "Please share at least 100 characters.", "", None
if len(story) > config.MAX_STORY_LENGTH:
return "Please keep under 5000 characters.", "", None
# Analyze
analysis = analyze_story(story)
if analysis.get("risk_level") == "high":
safety_msg = (
"### ⚠ Safety Notice\n\n"
"This memory touches on deep pain. Elsewhere is not designed for trauma or crisis reflection.\n\n"
"**If you're in danger, reach out:**\n"
"- US: 988 (Suicide & Crisis Lifeline)\n"
"- Or contact local emergency services."
)
return safety_msg, "", None
# Mirror
mirror = generate_mirror(story, analysis)
return mirror, story, analysis
def handle_choose_path(direction: str, story: str, analysis: Dict):
"""Generate timeline for chosen path."""
if not story or len(story.strip()) < config.MIN_STORY_LENGTH:
msg = "Please share your moment above and click Reflect first.
"
return gr.update(value=msg, visible=True), gr.update(visible=False)
timeline = generate_timeline(story, analysis or FALLBACK_ANALYSIS, direction)
image_uri = scene_data_uri(timeline_scene_prompt(timeline, direction))
html_out = render_timeline_html(timeline, direction, image_uri)
return gr.update(value=html_out, visible=True), gr.update(visible=True)
def handle_world_divergence(scenario: str):
"""Generate alternate world, rendered as structured cards."""
if not scenario or len(scenario.strip()) < 20:
return "Describe a divergence point in at least 20 characters.
"
world = generate_world_divergence(scenario)
image_uri = scene_data_uri(world_scene_prompt(world))
return render_world_html(world, image_uri)
# Wire events (re-enter the Blocks context so handlers attach correctly)
with demo:
submit_btn.click(
fn=handle_submit_story,
inputs=[story_input],
outputs=[mirror_output, story_state, analysis_state]
)
upward_btn.click(
fn=lambda story, analysis: handle_choose_path("Upward", story, analysis),
inputs=[story_state, analysis_state],
outputs=[timeline_result, return_button]
)
downward_btn.click(
fn=lambda story, analysis: handle_choose_path("Downward", story, analysis),
inputs=[story_state, analysis_state],
outputs=[timeline_result, return_button]
)
return_button.click(
fn=lambda: (gr.update(visible=False), gr.update(visible=False)),
outputs=[timeline_result, return_button]
)
world_submit.click(
fn=handle_world_divergence,
inputs=[world_input],
outputs=[world_output]
)
if __name__ == "__main__":
demo.launch(
server_name=os.getenv("GRADIO_SERVER_NAME", "0.0.0.0"),
server_port=int(os.getenv("PORT", os.getenv("GRADIO_SERVER_PORT", "7860"))),
)