File size: 30,807 Bytes
f8c1b4a
 
 
ffb4f53
 
f8c1b4a
 
c2cb46f
ffb4f53
f8c1b4a
 
c2cb46f
 
f8c1b4a
652e422
f8c1b4a
 
 
 
c2cb46f
f8c1b4a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ffb4f53
f8c1b4a
 
c2cb46f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ffb4f53
 
 
 
 
fcf8063
 
 
 
 
 
 
 
 
 
 
 
 
c2cb46f
ffb4f53
 
 
 
 
c2cb46f
ffb4f53
 
 
c2cb46f
65e1839
fcf8063
 
ffb4f53
 
 
 
fcf8063
 
ffb4f53
 
fcf8063
ffb4f53
 
 
 
 
 
fcf8063
ffb4f53
 
 
fcf8063
ffb4f53
 
fcf8063
ffb4f53
 
 
 
c2cb46f
ffb4f53
c2cb46f
ffb4f53
 
 
c2cb46f
65e1839
fcf8063
ffb4f53
 
 
 
 
fcf8063
 
ffb4f53
 
fcf8063
ffb4f53
 
 
fcf8063
 
 
ffb4f53
 
fcf8063
 
ffb4f53
 
fcf8063
ffb4f53
 
 
 
f8c1b4a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c2cb46f
f8c1b4a
 
 
c2cb46f
8629759
 
 
 
 
 
 
 
 
 
 
 
 
fcf8063
 
 
 
 
f8c1b4a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
652e422
f8c1b4a
 
 
 
 
 
 
 
 
652e422
f8c1b4a
 
 
 
652e422
f8c1b4a
 
 
 
652e422
f8c1b4a
 
 
 
652e422
f8c1b4a
 
 
 
652e422
f8c1b4a
 
 
 
 
 
 
652e422
f8c1b4a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ffb4f53
 
fcf8063
 
 
 
 
 
ffb4f53
 
fcf8063
 
 
ffb4f53
fcf8063
 
 
 
 
 
 
 
 
ffb4f53
 
fcf8063
 
 
 
 
 
 
 
 
 
ffb4f53
fcf8063
 
 
 
 
 
c2cb46f
 
 
 
 
 
 
65e1839
 
 
 
 
 
 
 
 
 
 
 
fcf8063
 
 
 
 
 
 
ffb4f53
 
 
fcf8063
 
ffb4f53
fcf8063
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ffb4f53
fcf8063
 
 
 
 
 
 
ffb4f53
 
fcf8063
 
 
ffb4f53
fcf8063
 
 
 
 
 
ffb4f53
fcf8063
 
ffb4f53
 
fcf8063
ffb4f53
 
fcf8063
ffb4f53
 
 
 
652e422
 
 
f8c1b4a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ffb4f53
f8c1b4a
ffb4f53
 
 
 
 
f8c1b4a
ffb4f53
 
 
 
 
f8c1b4a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ffb4f53
f8c1b4a
ffb4f53
 
f8c1b4a
 
 
 
 
 
 
 
 
ffb4f53
f8c1b4a
 
ffb4f53
f8c1b4a
 
ffb4f53
f8c1b4a
 
 
 
 
 
 
 
 
 
 
 
ffb4f53
f8c1b4a
 
 
 
ffb4f53
f8c1b4a
 
 
 
 
ffb4f53
 
f8c1b4a
 
c2cb46f
 
 
f8c1b4a
 
 
ffb4f53
f8c1b4a
ffb4f53
 
f8c1b4a
c2cb46f
 
f8c1b4a
 
 
 
 
 
 
ffb4f53
f8c1b4a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ffb4f53
f8c1b4a
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
"""
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 <li> elements."""
    return "".join(f"<li>{html.escape(str(i))}</li>" 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"<div class='rd-node'><p>{html.escape(str(i))}</p></div>"
        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"<li>{html.escape(str(i))}</li>" 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"<img class='rd-hero-img' src='{image_uri}' alt=''>" if image_uri else ""
    return f"""
<div class="rd-stack">
  <div class="rd-card rd-fork">
    {img}
    <div class="rd-spec">βš— One possible path β€” modeled, not predicted</div>
    <div class="rd-badge">{arrow}</div>
    <div class="rd-eyebrow">{label}</div>
    <h2 class="rd-title">{html.escape(timeline.get('title', 'Alternate Timeline'))}</h2>
    <p class="rd-lead">{html.escape(timeline.get('divergence', ''))}</p>
  </div>
  <div class="rd-card">
    <div class="rd-eyebrow">⏳ Ripples through the years</div>
    <div class="rd-timeline">{_timeline_nodes(timeline.get('ripples', []))}</div>
  </div>
  <div class="rd-card">
    <div class="rd-eyebrow">πŸŒ— Who you might have become</div>
    <p>{html.escape(timeline.get('the_you', ''))}</p>
  </div>
  <div class="rd-card rd-return">
    <div class="rd-eyebrow">↩ Returning to your actual life</div>
    <p>{html.escape(ret.get('opening', ''))}</p>
    <div class="rd-sublabel">Strengths you actually built</div>
    <ul class="rd-checks">{_check_items(ret.get('strengths', []))}</ul>
    <p class="rd-lesson"><strong>Lesson:</strong> {html.escape(ret.get('lesson', ''))}</p>
  </div>
  <div class="rd-card rd-question">
    <div class="rd-eyebrow">πŸ’­ A question to sit with</div>
    <p class="rd-q">{html.escape(ret.get('reflection_question', ''))}</p>
  </div>
  <blockquote class="rd-quote">{html.escape(timeline.get('quote', ''))}</blockquote>
</div>
"""


def render_world_html(world: Dict, image_uri: str = "") -> str:
    """Render an alternate-world divergence as structured cards."""
    img = f"<img class='rd-hero-img' src='{image_uri}' alt=''>" if image_uri else ""
    return f"""
<div class="rd-stack">
  <div class="rd-card rd-fork">
    {img}
    <div class="rd-spec">βš— Speculative β€” causal chains are modeled, not known</div>
    <div class="rd-badge">🌍</div>
    <div class="rd-eyebrow">Year {html.escape(str(world.get('year_divergence', '')))} Β· Point of divergence</div>
    <h2 class="rd-title">{html.escape(world.get('title', 'Alternate World'))}</h2>
    <p class="rd-lead">{html.escape(world.get('divergence_point', ''))}</p>
  </div>
  <div class="rd-card">
    <div class="rd-eyebrow">⏳ Ripples</div>
    <div class="rd-timeline">{_timeline_nodes(world.get('ripples', []))}</div>
  </div>
  <div class="rd-card">
    <div class="rd-eyebrow">🌐 How this world evolved</div>
    <p>{html.escape(world.get('world_outcome', ''))}</p>
  </div>
  <div class="rd-grid">
    <div class="rd-card rd-stat"><span class="rd-ico">πŸ’°</span><div class="rd-sublabel">Economy</div><p>{html.escape(world.get('economy', ''))}</p></div>
    <div class="rd-card rd-stat"><span class="rd-ico">🎭</span><div class="rd-sublabel">Culture</div><p>{html.escape(world.get('culture', ''))}</p></div>
    <div class="rd-card rd-stat"><span class="rd-ico">βš™οΈ</span><div class="rd-sublabel">Technology</div><p>{html.escape(world.get('technology', ''))}</p></div>
  </div>
  <div class="rd-grid-2">
    <div class="rd-card rd-winners"><span class="rd-ico">πŸ“ˆ</span><div class="rd-sublabel">Who thrived</div><ul class="rd-list">{_list_items(world.get('winners', []))}</ul></div>
    <div class="rd-card rd-losers"><span class="rd-ico">πŸ“‰</span><div class="rd-sublabel">Who struggled</div><ul class="rd-list">{_list_items(world.get('losers', []))}</ul></div>
  </div>
  <div class="rd-card rd-question">
    <div class="rd-eyebrow">🧭 What it teaches us</div>
    <p class="rd-q">{html.escape(world.get('reflection', ''))}</p>
  </div>
</div>
"""


# ============================================================================
# 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("<div class='hero-title'>Explore the Life You Didn't Live</div>")
            gr.HTML("<div class='hero-subtitle'>A compassionate reflection on one choice that changed everything.</div>")

            # 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("<div class='hero-title'>The Museum of Worlds That Never Happened</div>")
            gr.HTML("<div class='hero-subtitle'>What if one decision shaped the entire world?</div>")

            # 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 = "<div class='rd-card'>Please share your moment above and click <strong>Reflect</strong> first.</div>"
        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 "<div class='rd-card'>Describe a divergence point in at least 20 characters.</div>"

    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"))),
    )