JG1310 commited on
Commit
b04d6d2
·
verified ·
1 Parent(s): 976faaa

Arcana v1: themed concept-tarot generator + progressive reader (3 demo decks)

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitattributes +71 -0
  2. .gitignore +8 -0
  3. README.md +77 -5
  4. app.py +394 -0
  5. arcana/__init__.py +0 -0
  6. arcana/archetypes.py +60 -0
  7. arcana/build.py +142 -0
  8. arcana/compositor.py +81 -0
  9. arcana/designer.py +170 -0
  10. arcana/frame.py +79 -0
  11. arcana/imagegen.py +106 -0
  12. arcana/llm.py +115 -0
  13. arcana/loremaster.py +111 -0
  14. arcana/prompts.py +137 -0
  15. arcana/reader.py +143 -0
  16. arcana/styles.py +66 -0
  17. assets/fonts/Cinzel.ttf +3 -0
  18. assets/fonts/EBGaramond.ttf +3 -0
  19. decks/breakfast_foods-playful-1780954994/00_the_banana.png +3 -0
  20. decks/breakfast_foods-playful-1780954994/01_the_whisk.png +3 -0
  21. decks/breakfast_foods-playful-1780954994/02_the_covered_dish.png +3 -0
  22. decks/breakfast_foods-playful-1780954994/03_the_stack_of_pancakes.png +3 -0
  23. decks/breakfast_foods-playful-1780954994/04_black_coffee.png +3 -0
  24. decks/breakfast_foods-playful-1780954994/05_the_continental_breakfast.png +3 -0
  25. decks/breakfast_foods-playful-1780954994/06_bacon_and_eggs.png +3 -0
  26. decks/breakfast_foods-playful-1780954994/07_the_toaster.png +3 -0
  27. decks/breakfast_foods-playful-1780954994/08_oatmeal.png +3 -0
  28. decks/breakfast_foods-playful-1780954994/09_the_lone_boiled_egg.png +3 -0
  29. decks/breakfast_foods-playful-1780954994/10_the_cereal_box_prize.png +3 -0
  30. decks/breakfast_foods-playful-1780954994/11_the_diner_check.png +3 -0
  31. decks/breakfast_foods-playful-1780954994/12_french_toast_soaking.png +3 -0
  32. decks/breakfast_foods-playful-1780954994/13_the_burnt_toast.png +3 -0
  33. decks/breakfast_foods-playful-1780954994/14_the_smoothie.png +3 -0
  34. decks/breakfast_foods-playful-1780954994/15_the_sugary_cereal.png +3 -0
  35. decks/breakfast_foods-playful-1780954994/16_the_dropped_tray.png +3 -0
  36. decks/breakfast_foods-playful-1780954994/17_fresh_squeezed_orange_juice.png +3 -0
  37. decks/breakfast_foods-playful-1780954994/18_the_mystery_omelette.png +3 -0
  38. decks/breakfast_foods-playful-1780954994/19_the_sunny_side_up_egg.png +3 -0
  39. decks/breakfast_foods-playful-1780954994/20_order_s_up.png +3 -0
  40. decks/breakfast_foods-playful-1780954994/21_the_full_english.png +3 -0
  41. decks/breakfast_foods-playful-1780954994/back.png +3 -0
  42. decks/breakfast_foods-playful-1780954994/deck.json +274 -0
  43. decks/lord_of_the_rings-bundled/00_frodo_baggins.png +3 -0
  44. decks/lord_of_the_rings-bundled/01_gandalf_the_grey.png +3 -0
  45. decks/lord_of_the_rings-bundled/02_the_mirror_of_galadriel.png +3 -0
  46. decks/lord_of_the_rings-bundled/03_lothlórien.png +3 -0
  47. decks/lord_of_the_rings-bundled/04_théoden_king.png +3 -0
  48. decks/lord_of_the_rings-bundled/05_the_council_of_elrond.png +3 -0
  49. decks/lord_of_the_rings-bundled/06_aragorn_and_arwen.png +3 -0
  50. decks/lord_of_the_rings-bundled/07_the_battle_of_pelennor_f.png +3 -0
.gitattributes CHANGED
@@ -33,3 +33,74 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ assets/fonts/Cinzel.ttf filter=lfs diff=lfs merge=lfs -text
37
+ assets/fonts/EBGaramond.ttf filter=lfs diff=lfs merge=lfs -text
38
+ decks/breakfast_foods-playful-1780954994/00_the_banana.png filter=lfs diff=lfs merge=lfs -text
39
+ decks/breakfast_foods-playful-1780954994/01_the_whisk.png filter=lfs diff=lfs merge=lfs -text
40
+ decks/breakfast_foods-playful-1780954994/02_the_covered_dish.png filter=lfs diff=lfs merge=lfs -text
41
+ decks/breakfast_foods-playful-1780954994/03_the_stack_of_pancakes.png filter=lfs diff=lfs merge=lfs -text
42
+ decks/breakfast_foods-playful-1780954994/04_black_coffee.png filter=lfs diff=lfs merge=lfs -text
43
+ decks/breakfast_foods-playful-1780954994/05_the_continental_breakfast.png filter=lfs diff=lfs merge=lfs -text
44
+ decks/breakfast_foods-playful-1780954994/06_bacon_and_eggs.png filter=lfs diff=lfs merge=lfs -text
45
+ decks/breakfast_foods-playful-1780954994/07_the_toaster.png filter=lfs diff=lfs merge=lfs -text
46
+ decks/breakfast_foods-playful-1780954994/08_oatmeal.png filter=lfs diff=lfs merge=lfs -text
47
+ decks/breakfast_foods-playful-1780954994/09_the_lone_boiled_egg.png filter=lfs diff=lfs merge=lfs -text
48
+ decks/breakfast_foods-playful-1780954994/10_the_cereal_box_prize.png filter=lfs diff=lfs merge=lfs -text
49
+ decks/breakfast_foods-playful-1780954994/11_the_diner_check.png filter=lfs diff=lfs merge=lfs -text
50
+ decks/breakfast_foods-playful-1780954994/12_french_toast_soaking.png filter=lfs diff=lfs merge=lfs -text
51
+ decks/breakfast_foods-playful-1780954994/13_the_burnt_toast.png filter=lfs diff=lfs merge=lfs -text
52
+ decks/breakfast_foods-playful-1780954994/14_the_smoothie.png filter=lfs diff=lfs merge=lfs -text
53
+ decks/breakfast_foods-playful-1780954994/15_the_sugary_cereal.png filter=lfs diff=lfs merge=lfs -text
54
+ decks/breakfast_foods-playful-1780954994/16_the_dropped_tray.png filter=lfs diff=lfs merge=lfs -text
55
+ decks/breakfast_foods-playful-1780954994/17_fresh_squeezed_orange_juice.png filter=lfs diff=lfs merge=lfs -text
56
+ decks/breakfast_foods-playful-1780954994/18_the_mystery_omelette.png filter=lfs diff=lfs merge=lfs -text
57
+ decks/breakfast_foods-playful-1780954994/19_the_sunny_side_up_egg.png filter=lfs diff=lfs merge=lfs -text
58
+ decks/breakfast_foods-playful-1780954994/20_order_s_up.png filter=lfs diff=lfs merge=lfs -text
59
+ decks/breakfast_foods-playful-1780954994/21_the_full_english.png filter=lfs diff=lfs merge=lfs -text
60
+ decks/breakfast_foods-playful-1780954994/back.png filter=lfs diff=lfs merge=lfs -text
61
+ decks/lord_of_the_rings-bundled/00_frodo_baggins.png filter=lfs diff=lfs merge=lfs -text
62
+ decks/lord_of_the_rings-bundled/01_gandalf_the_grey.png filter=lfs diff=lfs merge=lfs -text
63
+ decks/lord_of_the_rings-bundled/02_the_mirror_of_galadriel.png filter=lfs diff=lfs merge=lfs -text
64
+ decks/lord_of_the_rings-bundled/03_lothlórien.png filter=lfs diff=lfs merge=lfs -text
65
+ decks/lord_of_the_rings-bundled/04_théoden_king.png filter=lfs diff=lfs merge=lfs -text
66
+ decks/lord_of_the_rings-bundled/05_the_council_of_elrond.png filter=lfs diff=lfs merge=lfs -text
67
+ decks/lord_of_the_rings-bundled/06_aragorn_and_arwen.png filter=lfs diff=lfs merge=lfs -text
68
+ decks/lord_of_the_rings-bundled/07_the_battle_of_pelennor_f.png filter=lfs diff=lfs merge=lfs -text
69
+ decks/lord_of_the_rings-bundled/08_gollum.png filter=lfs diff=lfs merge=lfs -text
70
+ decks/lord_of_the_rings-bundled/09_faramir_in_ithilien.png filter=lfs diff=lfs merge=lfs -text
71
+ decks/lord_of_the_rings-bundled/10_the_one_ring.png filter=lfs diff=lfs merge=lfs -text
72
+ decks/lord_of_the_rings-bundled/11_the_judgment_of_isildur.png filter=lfs diff=lfs merge=lfs -text
73
+ decks/lord_of_the_rings-bundled/12_gandalf_falls_in_moria.png filter=lfs diff=lfs merge=lfs -text
74
+ decks/lord_of_the_rings-bundled/13_sauron_s_defeat.png filter=lfs diff=lfs merge=lfs -text
75
+ decks/lord_of_the_rings-bundled/14_the_shire_after_the_war.png filter=lfs diff=lfs merge=lfs -text
76
+ decks/lord_of_the_rings-bundled/15_the_eye_of_sauron.png filter=lfs diff=lfs merge=lfs -text
77
+ decks/lord_of_the_rings-bundled/16_the_fall_of_barad_dûr.png filter=lfs diff=lfs merge=lfs -text
78
+ decks/lord_of_the_rings-bundled/17_the_light_of_eärendil.png filter=lfs diff=lfs merge=lfs -text
79
+ decks/lord_of_the_rings-bundled/18_the_misty_mountains.png filter=lfs diff=lfs merge=lfs -text
80
+ decks/lord_of_the_rings-bundled/19_the_reckoning_at_the_end.png filter=lfs diff=lfs merge=lfs -text
81
+ decks/lord_of_the_rings-bundled/20_the_scouring_of_the_shir.png filter=lfs diff=lfs merge=lfs -text
82
+ decks/lord_of_the_rings-bundled/21_the_return_of_the_king.png filter=lfs diff=lfs merge=lfs -text
83
+ decks/lord_of_the_rings-bundled/back.png filter=lfs diff=lfs merge=lfs -text
84
+ decks/thermodynamics-rider-waite-smith-1780952458/00_activation_energy.png filter=lfs diff=lfs merge=lfs -text
85
+ decks/thermodynamics-rider-waite-smith-1780952458/01_work.png filter=lfs diff=lfs merge=lfs -text
86
+ decks/thermodynamics-rider-waite-smith-1780952458/02_internal_energy.png filter=lfs diff=lfs merge=lfs -text
87
+ decks/thermodynamics-rider-waite-smith-1780952458/03_heat_reservoir.png filter=lfs diff=lfs merge=lfs -text
88
+ decks/thermodynamics-rider-waite-smith-1780952458/04_the_first_law.png filter=lfs diff=lfs merge=lfs -text
89
+ decks/thermodynamics-rider-waite-smith-1780952458/05_standard_state__stp.png filter=lfs diff=lfs merge=lfs -text
90
+ decks/thermodynamics-rider-waite-smith-1780952458/06_thermal_contact.png filter=lfs diff=lfs merge=lfs -text
91
+ decks/thermodynamics-rider-waite-smith-1780952458/07_the_heat_engine.png filter=lfs diff=lfs merge=lfs -text
92
+ decks/thermodynamics-rider-waite-smith-1780952458/08_heat_capacity.png filter=lfs diff=lfs merge=lfs -text
93
+ decks/thermodynamics-rider-waite-smith-1780952458/09_absolute_zero.png filter=lfs diff=lfs merge=lfs -text
94
+ decks/thermodynamics-rider-waite-smith-1780952458/10_the_carnot_cycle.png filter=lfs diff=lfs merge=lfs -text
95
+ decks/thermodynamics-rider-waite-smith-1780952458/11_the_second_law_s_toll.png filter=lfs diff=lfs merge=lfs -text
96
+ decks/thermodynamics-rider-waite-smith-1780952458/12_metastability.png filter=lfs diff=lfs merge=lfs -text
97
+ decks/thermodynamics-rider-waite-smith-1780952458/13_entropy.png filter=lfs diff=lfs merge=lfs -text
98
+ decks/thermodynamics-rider-waite-smith-1780952458/14_thermal_equilibrium.png filter=lfs diff=lfs merge=lfs -text
99
+ decks/thermodynamics-rider-waite-smith-1780952458/15_bound_energy.png filter=lfs diff=lfs merge=lfs -text
100
+ decks/thermodynamics-rider-waite-smith-1780952458/16_the_critical_point.png filter=lfs diff=lfs merge=lfs -text
101
+ decks/thermodynamics-rider-waite-smith-1780952458/17_free_energy.png filter=lfs diff=lfs merge=lfs -text
102
+ decks/thermodynamics-rider-waite-smith-1780952458/18_thermal_fluctuations.png filter=lfs diff=lfs merge=lfs -text
103
+ decks/thermodynamics-rider-waite-smith-1780952458/19_blackbody_radiation.png filter=lfs diff=lfs merge=lfs -text
104
+ decks/thermodynamics-rider-waite-smith-1780952458/20_the_third_law.png filter=lfs diff=lfs merge=lfs -text
105
+ decks/thermodynamics-rider-waite-smith-1780952458/21_the_universe.png filter=lfs diff=lfs merge=lfs -text
106
+ decks/thermodynamics-rider-waite-smith-1780952458/back.png filter=lfs diff=lfs merge=lfs -text
.gitignore ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.pyc
3
+ *.log
4
+ .DS_Store
5
+ # local-only scratch — not part of the Space
6
+ phase0_out/
7
+ cards/
8
+ /tmp/
README.md CHANGED
@@ -1,13 +1,85 @@
1
  ---
2
  title: Arcana
3
- emoji: 💻
4
  colorFrom: purple
5
- colorTo: purple
6
  sdk: gradio
7
- sdk_version: 6.17.3
8
- python_version: '3.13'
9
  app_file: app.py
10
  pinned: false
 
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
  title: Arcana
3
+ emoji: 🔮
4
  colorFrom: purple
5
+ colorTo: indigo
6
  sdk: gradio
7
+ sdk_version: 6.14.0
 
8
  app_file: app.py
9
  pinned: false
10
+ license: mit
11
  ---
12
 
13
+ # 🔮 Arcana a themed concept-tarot generator & reader
14
+
15
+ Name any **domain or theme** — *thermodynamics*, *Lord of the Rings*, *breakfast
16
+ foods*, *the French Revolution* — and Arcana invents a whole tarot deck for it,
17
+ paints the 22 cards, and reads your fortune in them.
18
+
19
+ Built for the **HF / Gradio Build Small Hackathon** — track *Adventure in
20
+ Thousand Token Wood*.
21
+
22
+ ## How it works — invent the meanings, then reason over them
23
+
24
+ The load-bearing AI move is **not** the art (plenty of tools draw tarot cards).
25
+ It's the **semantic invention** — deciding what each domain concept *means
26
+ archetypally* — and then the **progressive reasoning over a spread** of those
27
+ concepts in combination. Both are language tasks, so a small ≤32B text model is
28
+ the right core.
29
+
30
+ ```
31
+ theme + visual style
32
+
33
+
34
+ Deck Designer (Qwen-32B) maps the 22 Major Arcana → in-theme concepts,
35
+ │ writes upright/reversed meanings + art prompts
36
+
37
+ Loremaster (Qwen-32B) rewrites every meaning to be native to its concept
38
+
39
+ ├─► open image model (FLUX.1-schnell) → 22 central arts + one deck-back
40
+ │ │
41
+ │ ▼
42
+ │ Compositor (Pillow) art + reusable gold frame + crisp title/numeral
43
+
44
+ Reader / Oracle (Qwen-32B) draws a spread and turns the cards one at a time —
45
+ a partial read as each lands, the synthesis held to the end
46
+ ```
47
+
48
+ For example, mapping *thermodynamics*: **Entropy → Death**, **Absolute Zero →
49
+ The Hermit**, **The Critical Point → The Tower**. The fit has to feel
50
+ *clever-but-right*, never arbitrary — that quality is the whole point.
51
+
52
+ ## Everything is open and ≤32B
53
+
54
+ No closed/frontier models anywhere. The language model is **Qwen3-32B**
55
+ (configurable; Qwen2.5-32B-class) and the image model is **FLUX.1-schnell**
56
+ (~12B, open) — both served through an OpenAI-compatible inference endpoint, so
57
+ the Space itself stays a lean CPU box. The same models run locally on a GPU via
58
+ the `local` backend (`LLM_BACKEND` / `IMAGE_BACKEND`).
59
+
60
+ ## Using it
61
+
62
+ - **Tarot Reading → Generate New Deck** — type a theme, pick a visual style
63
+ (Rider-Waite-Smith, Thoth, Marseille, Playful, or your own *Custom* look),
64
+ conjure the deck, then save it or go straight into a reading.
65
+ - **Tarot Reading → Read from Existing Deck** / **View Deck** — open a bundled or
66
+ saved deck. Three demo decks ship with the Space.
67
+ - A reading turns the cards face-up one at a time with a partial interpretation
68
+ each, then weaves them into one synthesis.
69
+
70
+ ## Configuration
71
+
72
+ | env var | default | meaning |
73
+ |---|---|---|
74
+ | `LLM_BACKEND` | `endpoint` | `endpoint` (hosted open ≤32B) or `local` (GPU) |
75
+ | `LLM_PROVIDER` | `hf` | `hf` (HF router) or `deepinfra` |
76
+ | `QWEN_MODEL` | `Qwen/Qwen3-32B` | the ≤32B reasoning model |
77
+ | `IMAGE_MODEL` | `black-forest-labs/FLUX-1-schnell` | the open ≤32B image model |
78
+ | `HF_TOKEN` / `DEEPINFRA_TOKEN` | — | provider keys (Space **secrets**) |
79
+
80
+ Set provider keys as **Space secrets**; they are never hardcoded.
81
+
82
+ ## Assets & licensing
83
+
84
+ Card art is generated by an open model. Title/numeral type is set in
85
+ [Cinzel](https://fonts.google.com/specimen/Cinzel) (OFL). MIT licensed.
app.py ADDED
@@ -0,0 +1,394 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Arcana — themed concept-tarot generator & reader (SPEC §13, §14).
2
+
3
+ Gradio + FastAPI app (Globe's mounting pattern). Landing → Tarot Reading / View
4
+ Deck; generate a deck in a chosen visual style, then Save & Finish or go straight
5
+ into a reading; the reading is theatre — cards start face-down (the deck-back) and
6
+ flip one at a time, a per-card partial fading in as each lands, the full synthesis
7
+ held until the end.
8
+
9
+ Everything runs on open ≤32B models via an inference endpoint; the Space is a lean
10
+ CPU box (no torch/diffusers), consistent with Globe.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import html
15
+ import os
16
+ import time
17
+
18
+ import gradio as gr
19
+ from fastapi import FastAPI
20
+ from fastapi.staticfiles import StaticFiles
21
+
22
+ from arcana.build import (DECKS_DIR, build_deck, list_decks, load_deck, save_deck)
23
+ from arcana.reader import (SPREADS, card_partial, draw_spread, final_synthesis)
24
+ from arcana.styles import CUSTOM_PLACEHOLDER, STYLE_CHOICES
25
+
26
+ ROOT = os.path.dirname(os.path.abspath(__file__))
27
+ DEFAULT_THEME = "thermodynamics"
28
+
29
+ WELCOME = ("Welcome, traveler. Every world has its hidden archetypes. "
30
+ "Name one, and I shall draw out its fortune.")
31
+ EXAMPLE_THEMES = ["the French Revolution", "breakfast foods", "Greek mythology"]
32
+ SPREAD_CHOICES = [("Single card", "single"), ("Past · Present · Future", "three")]
33
+
34
+ PAGE_ORDER = ["landing", "taro", "generate", "pick", "reading", "view"]
35
+
36
+
37
+ def _abs(art_path: str | None):
38
+ return os.path.join(ROOT, art_path) if art_path else None
39
+
40
+
41
+ def url_for(art_path: str | None) -> str:
42
+ return ("/" + art_path) if art_path else ""
43
+
44
+
45
+ def gallery_items(deck: dict | None):
46
+ if not deck:
47
+ return []
48
+ return [(_abs(c["art_path"]), c["concept"]) for c in deck["cards"] if c.get("art_path")]
49
+
50
+
51
+ def deck_label(d: dict) -> str:
52
+ return f"{d['theme']} · {d.get('visual_style','?')}"
53
+
54
+
55
+ def deck_choices():
56
+ return [(deck_label(d), d["deck_id"]) for d in list_decks()]
57
+
58
+
59
+ def show(target: str):
60
+ return [gr.update(visible=(p == target)) for p in PAGE_ORDER]
61
+
62
+
63
+ # ----------------------------------------------------------------- reading theatre
64
+ def _card_html(d: dict | None, state: str, anim: bool) -> str:
65
+ """state: 'up' | 'down'. anim=True plays the flip on this render."""
66
+ back = url_for(d.get("back_url")) if d else ""
67
+ front = url_for(d.get("art_path")) if d else ""
68
+ inner_cls = "flip-inner " + ("up" if state == "up" else "down") + (" anim" if anim else "")
69
+ if state == "up":
70
+ rev = d["orientation"] == "reversed"
71
+ cap = (f"<figcaption><span class='pos'>{html.escape(d['position'])}</span>"
72
+ f"<span class='concept'>{html.escape(d['concept'])}</span>"
73
+ f"<span class='orient {'rev' if rev else 'upr'}'>{d['orientation']}</span></figcaption>")
74
+ front_inner = f"<img src='{html.escape(front)}' class='{'reversed' if rev else ''}' alt=''>"
75
+ else:
76
+ cap = "<figcaption><span class='pos'>&nbsp;</span><span class='concept'>&nbsp;</span><span class='orient'>&nbsp;</span></figcaption>"
77
+ front_inner = f"<img src='{html.escape(front)}' alt=''>"
78
+ return (f"<figure class='flip'><div class='{inner_cls}'>"
79
+ f"<div class='face back'><img src='{html.escape(back)}' alt=''></div>"
80
+ f"<div class='face front'>{front_inner}</div></div>{cap}</figure>")
81
+
82
+
83
+ def _spread_html(drawn, back_url, revealed: int, anim_idx: int | None) -> str:
84
+ cells = []
85
+ for i, d in enumerate(drawn):
86
+ d = {**d, "back_url": back_url}
87
+ state = "up" if i < revealed else "down"
88
+ cells.append(_card_html(d, state, anim=(i == anim_idx)))
89
+ return f"<div class='spread'>{''.join(cells)}</div>"
90
+
91
+
92
+ def _facedown_html(n: int, back_url: str) -> str:
93
+ cells = [_card_html({"back_url": back_url, "art_path": None}, "down", False) for _ in range(n)]
94
+ return f"<div class='spread'>{''.join(cells)}</div>"
95
+
96
+
97
+ def _reading_md(drawn, partials, synthesis: str | None) -> str:
98
+ out = []
99
+ for j, p in enumerate(partials):
100
+ d = drawn[j]
101
+ out.append(f"**{d['position']} — {d['concept']}** *({d['orientation']})*\n\n{p}")
102
+ if synthesis:
103
+ out.append("\n---\n\n### ✦ The cards together\n\n" + synthesis)
104
+ return "\n\n".join(out)
105
+
106
+
107
+ def do_reading(deck, question, spread, reversals):
108
+ """Generator: face-down → flip each card with its partial → held synthesis."""
109
+ if not deck:
110
+ yield "<div class='spread-empty'>Conjure or open a deck first.</div>", ""
111
+ return
112
+ back_url = deck.get("back_path")
113
+ drawn = draw_spread(deck, spread=spread, reversals=reversals)
114
+
115
+ yield _facedown_html(len(drawn), back_url), "_Focus on your question…_"
116
+ time.sleep(0.9)
117
+
118
+ partials = []
119
+ for i in range(len(drawn)):
120
+ # flip card i (animation plays), partial still arriving
121
+ yield (_spread_html(drawn, back_url, revealed=i + 1, anim_idx=i),
122
+ _reading_md(drawn, partials, None) + "\n\n_…the card turns…_")
123
+ time.sleep(0.5)
124
+ try:
125
+ p = card_partial(deck, question, drawn, i)
126
+ except Exception as e:
127
+ p = f"*(the card is hard to read — {type(e).__name__})*"
128
+ partials.append(p)
129
+ # settle card i face-up (no anim), show its partial
130
+ yield (_spread_html(drawn, back_url, revealed=i + 1, anim_idx=None),
131
+ _reading_md(drawn, partials, None))
132
+ time.sleep(0.3)
133
+
134
+ yield (_spread_html(drawn, back_url, revealed=len(drawn), anim_idx=None),
135
+ _reading_md(drawn, partials, None) + "\n\n_…the oracle gathers the threads…_")
136
+ try:
137
+ synth = final_synthesis(deck, question, drawn, partials)
138
+ except Exception as e:
139
+ synth = f"*(the synthesis slips away — {type(e).__name__})*"
140
+ yield (_spread_html(drawn, back_url, revealed=len(drawn), anim_idx=None),
141
+ _reading_md(drawn, partials, synth))
142
+
143
+
144
+ # ----------------------------------------------------------------- generate
145
+ def do_conjure(theme, style, custom, progress=gr.Progress()):
146
+ theme = (theme or "").strip()
147
+ if not theme:
148
+ yield (gr.update(), None, "Name a theme first.", gr.update(visible=False))
149
+ return
150
+ def cb(msg, frac):
151
+ progress(min(frac, 0.99), desc=msg)
152
+ deck = build_deck(theme, visual_style=style, custom_style=custom, progress=cb)
153
+ yield (gallery_items(deck), deck,
154
+ f"**{deck['theme']}** — 22 cards in the *{deck['visual_style']}* style. "
155
+ "Save it, or begin your reading.",
156
+ gr.update(visible=True))
157
+
158
+
159
+ def toggle_custom(style):
160
+ return gr.update(visible=(style == "custom"))
161
+
162
+
163
+ # ----------------------------------------------------------------- nav helpers
164
+ def open_picked(deck_id, mode):
165
+ deck = load_deck(deck_id) if deck_id else None
166
+ if not deck:
167
+ return (gr.update(), gr.update(), *show("pick"))
168
+ if mode == "view":
169
+ return (deck, gallery_items(deck), *show("view"))
170
+ return (deck, gr.update(), *show("reading"))
171
+
172
+
173
+ def save_current(deck):
174
+ if deck:
175
+ save_deck(deck)
176
+ return "✦ Deck saved."
177
+ return "Nothing to save yet."
178
+
179
+
180
+ # ----------------------------------------------------------------- css
181
+ CSS = """
182
+ :root { --gold:#d8b15a; --ink:#0b0716; --parch:#f3e6c4; }
183
+ .gradio-container { background:
184
+ radial-gradient(1200px 760px at 50% -6%, #241a3e 0%, #130d24 52%, #080510 100%) !important;
185
+ color: var(--parch) !important;
186
+ /* dark-theme Gradio's component fills so nothing reads as a white tile */
187
+ --block-background-fill: rgba(18,12,34,.55) !important;
188
+ --block-border-color: rgba(216,177,90,.22) !important;
189
+ --background-fill-primary: transparent !important;
190
+ --background-fill-secondary: rgba(18,12,34,.4) !important;
191
+ --input-background-fill: rgba(10,7,20,.62) !important;
192
+ --input-border-color: rgba(216,177,90,.25) !important;
193
+ --border-color-primary: rgba(216,177,90,.2) !important;
194
+ --body-text-color: var(--parch) !important;
195
+ --block-label-text-color: #c9bb90 !important;
196
+ --body-text-color-subdued: #b6a982 !important; }
197
+ #deck, #preview { background:transparent !important; }
198
+ #deck .thumbnail-item, #preview .thumbnail-item, .thumbnail-item, .grid-wrap { background:transparent !important; }
199
+ .gradio-container::before { content:""; position:fixed; inset:0; pointer-events:none; z-index:0;
200
+ background-image:
201
+ radial-gradient(1px 1px at 20% 30%, #fff 50%, transparent),
202
+ radial-gradient(1px 1px at 70% 20%, #fff 50%, transparent),
203
+ radial-gradient(1px 1px at 40% 70%, #ffe9b0 50%, transparent),
204
+ radial-gradient(1px 1px at 85% 60%, #fff 50%, transparent),
205
+ radial-gradient(1px 1px at 55% 45%, #fff 50%, transparent),
206
+ radial-gradient(1px 1px at 10% 80%, #ffe9b0 50%, transparent),
207
+ radial-gradient(1px 1px at 90% 85%, #fff 50%, transparent);
208
+ opacity:.5; }
209
+ .gradio-container > * { position:relative; z-index:1; }
210
+ .serif, #welcome, .arc-h { font-family: "Iowan Old Style", "Palatino Linotype", ui-serif, Georgia, serif !important; }
211
+ #brand { text-align:center; letter-spacing:8px; font-size:2.6rem; margin:10px 0 0; color:#f6ecd0;
212
+ text-shadow:0 2px 30px rgba(216,177,90,.55); font-family:ui-serif,Georgia,serif; }
213
+ #welcome { text-align:center; font-size:1.25rem; font-style:italic; color:#e7dab2;
214
+ max-width:680px; margin:14px auto 22px; line-height:1.6; }
215
+ .arc-h { text-align:center; color:#f1e4be !important; letter-spacing:2px; }
216
+ .gradio-container h1,.gradio-container h2,.gradio-container h3,.gradio-container h4 { color:#f1e4be !important;
217
+ font-family:ui-serif,Georgia,serif !important; }
218
+ button.primary, .big-btn { background:linear-gradient(135deg,#9a7b2e,#d8b15a) !important;
219
+ color:#1a1208 !important; border:0 !important; font-weight:700 !important; letter-spacing:1px; }
220
+ .gradio-container .secondary { border:1px solid rgba(216,177,90,.4) !important; color:#e7d9af !important;
221
+ background:rgba(216,177,90,.08) !important; }
222
+ .status { text-align:center; font-style:italic; color:#d8cba2; min-height:1.3em; }
223
+ #chips { flex-wrap:wrap !important; gap:6px !important; justify-content:center; }
224
+ #chips .secondary { border-radius:999px !important; font-size:.82rem !important; padding:4px 14px !important; }
225
+ #deck img, #preview img { border-radius:8px; box-shadow:0 6px 20px rgba(0,0,0,.55); }
226
+ #deck .grid-wrap, #preview .grid-wrap, #deck .gallery, #preview .gallery { background:transparent !important; }
227
+ #reading { background:rgba(16,10,30,.62); border:1px solid rgba(216,177,90,.3); border-radius:14px;
228
+ padding:18px 22px; font-family:ui-serif,Georgia,serif; font-size:1.04rem; line-height:1.68; color:#efe4c4; min-height:60px; }
229
+ #reading h3 { color:#d8b15a !important; }
230
+ /* flip spread */
231
+ .spread { display:flex; gap:24px; justify-content:center; flex-wrap:wrap; padding:18px 0 6px; }
232
+ .flip { width:200px; margin:0; perspective:1200px; }
233
+ .flip-inner { position:relative; width:200px; height:306px; transform-style:preserve-3d; }
234
+ .flip-inner.up { transform:rotateY(0deg); }
235
+ .flip-inner.down { transform:rotateY(180deg); }
236
+ .flip-inner.anim { animation:flipIn .85s cubic-bezier(.4,.1,.2,1) both; }
237
+ @keyframes flipIn { from { transform:rotateY(180deg); } to { transform:rotateY(0deg); } }
238
+ .face { position:absolute; inset:0; backface-visibility:hidden; border-radius:12px; overflow:hidden;
239
+ box-shadow:0 12px 34px rgba(0,0,0,.6); }
240
+ .face img { width:100%; height:100%; object-fit:cover; display:block; }
241
+ .face.front img.reversed { transform:rotate(180deg); }
242
+ .face.back { transform:rotateY(180deg); border:2px solid var(--gold); }
243
+ figcaption { text-align:center; margin-top:10px; display:flex; flex-direction:column; gap:1px;
244
+ font-family:ui-serif,Georgia,serif; }
245
+ figcaption .pos { color:var(--gold); font-size:.72rem; letter-spacing:2px; text-transform:uppercase; }
246
+ figcaption .concept { color:#f3e6c4; font-size:1rem; }
247
+ figcaption .orient { font-size:.72rem; font-style:italic; }
248
+ figcaption .orient.rev { color:#d98a78; } figcaption .orient.upr { color:#9ec99a; }
249
+ .spread-empty { text-align:center; color:#b6a982; font-style:italic; padding:34px; }
250
+ footer { display:none !important; }
251
+ """
252
+
253
+
254
+ # ----------------------------------------------------------------- ui
255
+ def build_demo() -> gr.Blocks:
256
+ with gr.Blocks(title="Arcana") as demo:
257
+ deck_state = gr.State(None)
258
+ pick_mode = gr.State("read")
259
+
260
+ gr.HTML("<div id='brand'>✦ ARCANA ✦</div>")
261
+
262
+ # ---------- landing
263
+ with gr.Column(visible=True) as pg_landing:
264
+ gr.HTML(f"<div id='welcome'>{WELCOME}</div>")
265
+ with gr.Row():
266
+ gr.Column(scale=1, min_width=10)
267
+ btn_taro = gr.Button("✦ Tarot Reading", elem_classes=["big-btn"], scale=2)
268
+ btn_viewnav = gr.Button("◈ View Deck", scale=2)
269
+ gr.Column(scale=1, min_width=10)
270
+
271
+ # ---------- tarot submenu
272
+ with gr.Column(visible=True) as pg_taro:
273
+ gr.Markdown("### Tarot Reading", elem_classes=["arc-h"])
274
+ with gr.Row():
275
+ btn_gen_new = gr.Button("✦ Generate New Deck", elem_classes=["big-btn"], scale=2)
276
+ btn_read_existing = gr.Button("◈ Read from Existing Deck", scale=2)
277
+ taro_back = gr.Button("↩ Back", scale=1, elem_classes=["secondary"])
278
+
279
+ # ---------- generate
280
+ with gr.Column(visible=True) as pg_generate:
281
+ gr.Markdown("### Conjure a Deck", elem_classes=["arc-h"])
282
+ theme = gr.Textbox(placeholder="thermodynamics · the French Revolution · breakfast foods…",
283
+ show_label=False, value=DEFAULT_THEME, max_lines=1)
284
+ with gr.Row(elem_id="chips"):
285
+ chips = [gr.Button(t, size="sm", elem_classes=["secondary"]) for t in EXAMPLE_THEMES]
286
+ with gr.Row():
287
+ style = gr.Dropdown(STYLE_CHOICES, value="rider-waite-smith", label="Visual style", scale=3)
288
+ custom = gr.Textbox(label="Custom style", placeholder=CUSTOM_PLACEHOLDER,
289
+ visible=False, scale=4, max_lines=1)
290
+ conjure = gr.Button("✦ Conjure the deck", elem_classes=["big-btn"])
291
+ gen_status = gr.Markdown("", elem_classes=["status"])
292
+ preview = gr.Gallery(value=[], columns=6, height=420, object_fit="contain",
293
+ show_label=False, elem_id="preview", preview=False)
294
+ with gr.Row(visible=False) as gen_fork:
295
+ btn_save_finish = gr.Button("✦ Save & Finish", scale=2)
296
+ btn_begin_reading = gr.Button("◈ Begin the Reading", elem_classes=["big-btn"], scale=2)
297
+ gen_back = gr.Button("↩ Back", scale=1, elem_classes=["secondary"])
298
+
299
+ # ---------- pick (read-from-existing OR view)
300
+ with gr.Column(visible=True) as pg_pick:
301
+ pick_title = gr.Markdown("### Choose a Deck", elem_classes=["arc-h"])
302
+ pick_dd = gr.Dropdown(choices=deck_choices(), label="Saved & bundled decks")
303
+ with gr.Row():
304
+ pick_open = gr.Button("✦ Open", elem_classes=["big-btn"], scale=2)
305
+ pick_refresh = gr.Button("⟳ Refresh", scale=1, elem_classes=["secondary"])
306
+ pick_back = gr.Button("↩ Back", scale=1, elem_classes=["secondary"])
307
+
308
+ # ---------- reading room
309
+ with gr.Column(visible=True) as pg_reading:
310
+ reading_head = gr.Markdown("### The Reading Room", elem_classes=["arc-h"])
311
+ with gr.Row():
312
+ question = gr.Textbox(label="Your question", scale=6, max_lines=2,
313
+ placeholder="Will my side-project ever ship?")
314
+ spread = gr.Dropdown(SPREAD_CHOICES, value="three", label="Spread", scale=2)
315
+ reversals = gr.Checkbox(value=True, label="Allow reversals", scale=1)
316
+ draw = gr.Button("✦ Draw the cards", elem_classes=["big-btn"])
317
+ spread_view = gr.HTML("<div class='spread-empty'>Pose your question, then draw.</div>")
318
+ reading = gr.Markdown("", elem_id="reading")
319
+ with gr.Row():
320
+ save_in_read = gr.Button("✦ Save this deck", scale=1, elem_classes=["secondary"])
321
+ read_back = gr.Button("↩ Back to start", scale=1, elem_classes=["secondary"])
322
+ save_note = gr.Markdown("", elem_classes=["status"])
323
+
324
+ # ---------- view deck
325
+ with gr.Column(visible=True) as pg_view:
326
+ view_head = gr.Markdown("### The Deck", elem_classes=["arc-h"])
327
+ view_gallery = gr.Gallery(value=[], columns=6, height=640, object_fit="contain",
328
+ show_label=False, elem_id="deck", preview=False,
329
+ allow_preview=True)
330
+ view_back = gr.Button("↩ Back to start", scale=1, elem_classes=["secondary"])
331
+
332
+ pages = [pg_landing, pg_taro, pg_generate, pg_pick, pg_reading, pg_view]
333
+
334
+ # ---------- wiring: navigation
335
+ btn_taro.click(lambda: show("taro"), None, pages)
336
+ taro_back.click(lambda: show("landing"), None, pages)
337
+ gen_back.click(lambda: show("taro"), None, pages)
338
+ pick_back.click(lambda: show("landing"), None, pages)
339
+ read_back.click(lambda: show("landing"), None, pages)
340
+ view_back.click(lambda: show("landing"), None, pages)
341
+
342
+ btn_gen_new.click(lambda: show("generate"), None, pages)
343
+ btn_read_existing.click(lambda: "read", None, pick_mode).then(
344
+ lambda: gr.update(choices=deck_choices()), None, pick_dd).then(
345
+ lambda: show("pick"), None, pages)
346
+ btn_viewnav.click(lambda: "view", None, pick_mode).then(
347
+ lambda: gr.update(choices=deck_choices()), None, pick_dd).then(
348
+ lambda: show("pick"), None, pages)
349
+
350
+ # style picker + examples
351
+ style.change(toggle_custom, style, custom)
352
+ for chip, t in zip(chips, EXAMPLE_THEMES):
353
+ chip.click(lambda t=t: t, None, theme)
354
+
355
+ # generate
356
+ conjure.click(do_conjure, [theme, style, custom],
357
+ [preview, deck_state, gen_status, gen_fork])
358
+ btn_save_finish.click(save_current, deck_state, gen_status).then(
359
+ lambda: show("landing"), None, pages)
360
+ btn_begin_reading.click(lambda: show("reading"), None, pages)
361
+
362
+ # pick → open
363
+ pick_refresh.click(lambda: gr.update(choices=deck_choices()), None, pick_dd)
364
+ pick_open.click(open_picked, [pick_dd, pick_mode],
365
+ [deck_state, view_gallery, *pages])
366
+
367
+ # reading
368
+ draw.click(do_reading, [deck_state, question, spread, reversals],
369
+ [spread_view, reading])
370
+ save_in_read.click(save_current, deck_state, save_note)
371
+
372
+ # pages are created visible=True so gallery components mount cleanly
373
+ # (a Gradio Gallery built inside a visible=False column never mounts);
374
+ # on load, collapse to just the landing page.
375
+ demo.load(lambda: show("landing"), None, pages)
376
+
377
+ return demo
378
+
379
+
380
+ def build_app() -> FastAPI:
381
+ app = FastAPI()
382
+ os.makedirs(DECKS_DIR, exist_ok=True)
383
+ app.mount("/decks", StaticFiles(directory=DECKS_DIR), name="decks")
384
+ demo = build_demo()
385
+ demo.queue(default_concurrency_limit=4)
386
+ return gr.mount_gradio_app(app, demo, path="/", css=CSS,
387
+ theme=gr.themes.Soft(), ssr_mode=False)
388
+
389
+
390
+ app = build_app()
391
+
392
+ if __name__ == "__main__":
393
+ import uvicorn
394
+ uvicorn.run(app, host="0.0.0.0", port=int(os.environ.get("PORT", 7860)))
arcana/__init__.py ADDED
File without changes
arcana/archetypes.py ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The 22 Major Arcana — the fixed scaffold every deck maps onto.
2
+
3
+ Agent 1 (the Deck Designer, SPEC §4) does NOT free-invent 22 cards. It assigns
4
+ one in-theme concept to each of these fixed archetypes, justified by *shared
5
+ meaning*. That guarantees exactly 22 cards, inherits a coherent meaning
6
+ structure, and forces the "clever-but-right" fit that the whole toy lives on.
7
+
8
+ `roman` is rendered onto the card frame (SPEC §8/§9); `meaning` is the canonical
9
+ gloss that grounds each mapping and goes verbatim into the system prompt.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ from dataclasses import dataclass
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class Arcana:
18
+ number: int # 0-21
19
+ roman: str # "0", "I", ... "XXI" — rendered on the frame
20
+ name: str # canonical archetype name
21
+ meaning: str # canonical upright gloss that grounds the mapping
22
+
23
+
24
+ MAJOR_ARCANA: tuple[Arcana, ...] = (
25
+ Arcana(0, "0", "The Fool", "new beginnings, innocence, leap of faith"),
26
+ Arcana(1, "I", "The Magician", "manifestation, willpower, channeling potential"),
27
+ Arcana(2, "II", "The High Priestess", "intuition, hidden knowledge, the unseen"),
28
+ Arcana(3, "III", "The Empress", "abundance, fertility, creation, nurturing"),
29
+ Arcana(4, "IV", "The Emperor", "authority, structure, control, stability"),
30
+ Arcana(5, "V", "The Hierophant", "tradition, institutions, shared belief, convention"),
31
+ Arcana(6, "VI", "The Lovers", "union, choice, alignment of values"),
32
+ Arcana(7, "VII", "The Chariot", "directed force, willpower, triumph through control"),
33
+ Arcana(8, "VIII", "Strength", "inner strength, courage, gentleness over force"),
34
+ Arcana(9, "IX", "The Hermit", "introspection, solitude, the inward search"),
35
+ Arcana(10, "X", "Wheel of Fortune", "cycles, change, fate, turning points"),
36
+ Arcana(11, "XI", "Justice", "fairness, cause and effect, truth, accountability"),
37
+ Arcana(12, "XII", "The Hanged Man", "surrender, suspension, a new perspective"),
38
+ Arcana(13, "XIII", "Death", "endings, irreversible transformation, transition"),
39
+ Arcana(14, "XIV", "Temperance", "balance, synthesis, moderation"),
40
+ Arcana(15, "XV", "The Devil", "bondage, constraint, materialism, the shadow"),
41
+ Arcana(16, "XVI", "The Tower", "sudden upheaval, collapse of false structures"),
42
+ Arcana(17, "XVII", "The Star", "hope, renewal, inspiration"),
43
+ Arcana(18, "XVIII", "The Moon", "illusion, ambiguity, the unconscious, anxiety"),
44
+ Arcana(19, "XIX", "The Sun", "joy, vitality, clarity, success"),
45
+ Arcana(20, "XX", "Judgement", "reckoning, awakening, rebirth, a calling"),
46
+ Arcana(21, "XXI", "The World", "completion, integration, wholeness"),
47
+ )
48
+
49
+ assert len(MAJOR_ARCANA) == 22
50
+ assert [a.number for a in MAJOR_ARCANA] == list(range(22))
51
+
52
+ ROMAN_BY_NUMBER: dict[int, str] = {a.number: a.roman for a in MAJOR_ARCANA}
53
+ NAME_BY_NUMBER: dict[int, str] = {a.number: a.name for a in MAJOR_ARCANA}
54
+
55
+
56
+ def reference_block() -> str:
57
+ """The 22 archetypes + canonical meanings, for the system prompt (SPEC §4)."""
58
+ return "\n".join(
59
+ f"{a.roman:<5} {a.name:<18} — {a.meaning}" for a in MAJOR_ARCANA
60
+ )
arcana/build.py ADDED
@@ -0,0 +1,142 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """End-to-end deck build + persistence (SPEC §11, §12).
2
+
3
+ theme + chosen visual style -> mappings (Agent 1) -> concept-native meanings
4
+ (Loremaster) -> 22 central arts + ONE deck-back (open ≤32B image model) ->
5
+ composited card PNGs. A fixed seed family makes the 22 cards cohere.
6
+
7
+ Persistence model (§12): a freshly built deck lives in session memory; its image
8
+ files are written to decks/<deck_id>/ so they can be composited and served, but
9
+ the deck is only "saved" (and thus listed by View Deck / Read from Existing) once
10
+ save_deck() writes its deck.json. Bundled demo decks ship with a deck.json.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import os
16
+ import time
17
+ from typing import Callable
18
+
19
+ from .archetypes import ROMAN_BY_NUMBER
20
+ from .compositor import compose_card
21
+ from .designer import design_deck
22
+ from .imagegen import get_imagegen
23
+ from .llm import get_llm
24
+ from .loremaster import refine_deck
25
+ from .styles import resolve_style
26
+
27
+ ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
28
+ DECKS_DIR = os.path.join(ROOT, "decks")
29
+
30
+ Progress = Callable[[str, float], None] # (message, fraction 0..1)
31
+
32
+
33
+ def slugify(s: str) -> str:
34
+ return "".join(ch if ch.isalnum() else "_" for ch in s.lower()).strip("_")[:40]
35
+
36
+
37
+ def deck_dir(deck_id: str) -> str:
38
+ return os.path.join(DECKS_DIR, deck_id)
39
+
40
+
41
+ def _seed_base(theme: str, style_id: str) -> int:
42
+ # deterministic per (theme, style) so a deck's art is reproducible (§7)
43
+ return abs(hash((theme.lower().strip(), style_id))) % 1_000_000
44
+
45
+
46
+ def _back_prompt(style_suffix: str) -> str:
47
+ return ("a tarot card back design, ornate symmetrical emblematic mandala, "
48
+ "abstract motif, no figures, no scene, " + style_suffix)
49
+
50
+
51
+ # ------------------------------------------------------------------ build
52
+ def build_deck(theme: str, visual_style: str = "rider-waite-smith",
53
+ custom_style: str | None = None,
54
+ progress: Progress | None = None) -> dict:
55
+ """Design + paint a full deck. Writes image files under decks/<deck_id>/ and
56
+ returns the in-memory deck dict (NOT yet saved — call save_deck to persist)."""
57
+ def emit(msg, frac):
58
+ if progress:
59
+ progress(msg, frac)
60
+
61
+ theme = (theme or "").strip()
62
+ style_id, style_suffix = resolve_style(visual_style, custom_style)
63
+ deck_id = f"{slugify(theme)}-{style_id}-{int(time.time())}"
64
+ out_dir = deck_dir(deck_id)
65
+ os.makedirs(out_dir, exist_ok=True)
66
+ seed_base = _seed_base(theme, style_id)
67
+
68
+ llm = get_llm()
69
+ emit("Consulting the deck designer…", 0.02)
70
+ deck = design_deck(theme, llm=llm)
71
+ emit("The loremaster reinterprets the cards…", 0.05)
72
+ try:
73
+ deck = refine_deck(deck, llm=llm)
74
+ except Exception:
75
+ pass # keep the designer's draft meanings if refinement hiccups
76
+
77
+ deck.update({
78
+ "deck_id": deck_id,
79
+ "theme": theme,
80
+ "visual_style": style_id,
81
+ "style_suffix": style_suffix,
82
+ "seed_base": seed_base,
83
+ })
84
+
85
+ ig = get_imagegen(deck_style=style_suffix)
86
+
87
+ emit("Pressing the deck-back…", 0.08)
88
+ back_path = os.path.join(out_dir, "back.png")
89
+ try:
90
+ back = ig.generate(_back_prompt(style_suffix), seed=seed_base - 1)
91
+ back.save(back_path)
92
+ deck["back_path"] = os.path.relpath(back_path, ROOT)
93
+ except Exception:
94
+ deck["back_path"] = None
95
+
96
+ n = len(deck["cards"])
97
+ for i, c in enumerate(deck["cards"]):
98
+ num = c["arcana_number"]
99
+ emit(f"Painting {c['concept']}…", 0.10 + 0.88 * i / n)
100
+ seed = seed_base + num
101
+ art = ig.generate(c["art_prompt"], seed=seed)
102
+ card = compose_card(art, c["concept"], ROMAN_BY_NUMBER[num])
103
+ fname = f"{num:02d}_{slugify(c['concept'])}.png"
104
+ path = os.path.join(out_dir, fname)
105
+ card.save(path)
106
+ c["seed"] = seed
107
+ c["art_path"] = os.path.relpath(path, ROOT)
108
+
109
+ emit("The deck is ready.", 1.0)
110
+ return deck
111
+
112
+
113
+ # ------------------------------------------------------------------ persistence
114
+ def save_deck(deck: dict) -> str:
115
+ """Write deck.json into the deck's dir, registering it as a saved deck (§12)."""
116
+ out_dir = deck_dir(deck["deck_id"])
117
+ os.makedirs(out_dir, exist_ok=True)
118
+ path = os.path.join(out_dir, "deck.json")
119
+ with open(path, "w") as f:
120
+ json.dump(deck, f, indent=2, ensure_ascii=False)
121
+ return path
122
+
123
+
124
+ def load_deck(deck_id: str) -> dict | None:
125
+ path = os.path.join(deck_dir(deck_id), "deck.json")
126
+ return json.load(open(path)) if os.path.exists(path) else None
127
+
128
+
129
+ def list_decks() -> list[dict]:
130
+ """All saved/bundled decks (those with a deck.json), newest first."""
131
+ if not os.path.isdir(DECKS_DIR):
132
+ return []
133
+ decks = []
134
+ for name in os.listdir(DECKS_DIR):
135
+ path = os.path.join(DECKS_DIR, name, "deck.json")
136
+ if os.path.exists(path):
137
+ try:
138
+ decks.append(json.load(open(path)))
139
+ except Exception:
140
+ pass
141
+ decks.sort(key=lambda d: d.get("deck_id", ""), reverse=True)
142
+ return decks
arcana/compositor.py ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Compositing pipeline (SPEC §9).
2
+
3
+ Per card: central art → fill the card → overlay the reusable frame template →
4
+ render the concept name + roman numeral as crisp text (never baked into the
5
+ diffusion art). Deterministic, fast, Pillow-only.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from PIL import Image, ImageDraw
10
+
11
+ from .frame import (CARD_H, CARD_W, CART_PLATE, GOLD, NUM_PLATE, PARCH,
12
+ font, frame_overlay)
13
+
14
+
15
+ def _cover(art: Image.Image, w: int, h: int) -> Image.Image:
16
+ """Resize-to-cover then centre-crop so art fills WxH without distortion."""
17
+ aw, ah = art.size
18
+ scale = max(w / aw, h / ah)
19
+ art = art.resize((max(1, round(aw * scale)), max(1, round(ah * scale))), Image.LANCZOS)
20
+ aw, ah = art.size
21
+ left, top = (aw - w) // 2, (ah - h) // 2
22
+ return art.crop((left, top, left + w, top + h))
23
+
24
+
25
+ def _fit_lines(draw, text, fnt_face, box, max_lines=2, start=40, min_size=20):
26
+ """Largest Cinzel size (and wrap) that fits `text` in `box`. Returns
27
+ (font, [lines])."""
28
+ bw = box[2] - box[0] - 24
29
+ bh = box[3] - box[1] - 16
30
+ words = text.upper().split()
31
+ for size in range(start, min_size - 1, -2):
32
+ fnt = font(size, weight=600, face=fnt_face)
33
+ # greedy wrap into <= max_lines
34
+ lines, cur = [], ""
35
+ for word in words:
36
+ trial = (cur + " " + word).strip()
37
+ if draw.textlength(trial, font=fnt) <= bw or not cur:
38
+ cur = trial
39
+ else:
40
+ lines.append(cur); cur = word
41
+ if cur:
42
+ lines.append(cur)
43
+ if len(lines) > max_lines:
44
+ continue
45
+ line_h = (fnt.getbbox("Ag")[3] - fnt.getbbox("Ag")[1]) + 6
46
+ if all(draw.textlength(ln, font=fnt) <= bw for ln in lines) and \
47
+ line_h * len(lines) <= bh:
48
+ return fnt, lines
49
+ fnt = font(min_size, weight=600, face=fnt_face)
50
+ return fnt, [text.upper()[:40]]
51
+
52
+
53
+ def _draw_centered(draw, lines, fnt, box, fill):
54
+ line_h = (fnt.getbbox("Ag")[3] - fnt.getbbox("Ag")[1]) + 6
55
+ total = line_h * len(lines)
56
+ cy = (box[1] + box[3]) / 2 - total / 2
57
+ for ln in lines:
58
+ tw = draw.textlength(ln, font=fnt)
59
+ draw.text(((box[0] + box[2]) / 2 - tw / 2, cy), ln, font=fnt, fill=fill)
60
+ cy += line_h
61
+
62
+
63
+ def compose_card(art: Image.Image, concept: str, roman: str,
64
+ size=(CARD_W, CARD_H)) -> Image.Image:
65
+ """Build the finished upright card PNG. Reversal is a 180° rotation applied
66
+ at draw time (§9), not here."""
67
+ w, h = size
68
+ card = _cover(art.convert("RGB"), w, h)
69
+ card.paste(frame_overlay(w, h), (0, 0), frame_overlay(w, h))
70
+ draw = ImageDraw.Draw(card)
71
+
72
+ # roman numeral in the top plate
73
+ nf = font(40, weight=700)
74
+ nw = draw.textlength(roman, font=nf)
75
+ ny = (NUM_PLATE[1] + NUM_PLATE[3]) / 2 - (nf.getbbox("X")[3] - nf.getbbox("X")[1]) / 2 - nf.getbbox("X")[1]
76
+ draw.text(((NUM_PLATE[0] + NUM_PLATE[2]) / 2 - nw / 2, ny), roman, font=nf, fill=GOLD)
77
+
78
+ # concept name in the bottom cartouche (auto-fit, up to 2 lines)
79
+ cf, lines = _fit_lines(draw, concept, "Cinzel", CART_PLATE, max_lines=2, start=44)
80
+ _draw_centered(draw, lines, cf, CART_PLATE, PARCH)
81
+ return card
arcana/designer.py ADDED
@@ -0,0 +1,170 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Agent 1 — Deck Designer (SPEC §4 + validation §10).
2
+
3
+ theme string -> deck dict of exactly 22 archetype-mapped cards.
4
+
5
+ Model output is treated as hostile: JSON is extracted defensively (fence-strip /
6
+ balanced-block), validated against the 22-archetype contract, and re-prompted
7
+ ONCE on a parse or structural failure. Build-time callers should fail loudly;
8
+ runtime callers can catch DeckError and degrade.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import re
14
+
15
+ from .archetypes import MAJOR_ARCANA, NAME_BY_NUMBER
16
+ from .llm import LLM, get_llm
17
+ from .prompts import designer_system_prompt, designer_user_prompt
18
+
19
+ _CARD_FIELDS = ("concept", "justification", "upright_meaning",
20
+ "reversed_meaning", "art_prompt")
21
+
22
+
23
+ class DeckError(ValueError):
24
+ """The model's output could not be coerced into a valid 22-card deck."""
25
+
26
+
27
+ # ------------------------------------------------------------------ json parse
28
+ def extract_json(text: str) -> dict:
29
+ """Pull the first JSON object out of a model reply, tolerating fences and
30
+ stray prose. Strips a Qwen <think> block if one leaks through."""
31
+ if not text or not text.strip():
32
+ raise DeckError("empty model reply")
33
+ text = re.sub(r"<think>.*?</think>", "", text, flags=re.DOTALL).strip()
34
+ # strip ```json ... ``` fences if present
35
+ fence = re.search(r"```(?:json)?\s*(.*?)```", text, flags=re.DOTALL)
36
+ if fence:
37
+ text = fence.group(1).strip()
38
+ try:
39
+ return json.loads(text)
40
+ except json.JSONDecodeError:
41
+ pass
42
+ # fall back to the first balanced {...} block
43
+ start = text.find("{")
44
+ if start == -1:
45
+ raise DeckError("no JSON object found in reply")
46
+ depth, in_str, esc = 0, False, False
47
+ for i in range(start, len(text)):
48
+ c = text[i]
49
+ if in_str:
50
+ if esc:
51
+ esc = False
52
+ elif c == "\\":
53
+ esc = True
54
+ elif c == '"':
55
+ in_str = False
56
+ elif c == '"':
57
+ in_str = True
58
+ elif c == "{":
59
+ depth += 1
60
+ elif c == "}":
61
+ depth -= 1
62
+ if depth == 0:
63
+ try:
64
+ return json.loads(text[start:i + 1])
65
+ except json.JSONDecodeError as e:
66
+ raise DeckError(f"malformed JSON block: {e}") from e
67
+ raise DeckError("unbalanced JSON object in reply")
68
+
69
+
70
+ # ------------------------------------------------------------------ validation
71
+ def validate_deck(data: dict, theme: str) -> dict:
72
+ """Enforce the §10 contract: exactly 22 cards covering arcana 0-21, no dupes,
73
+ non-empty meanings and art_prompt. Returns a normalized deck dict. Raises
74
+ DeckError on any structural problem (caller re-prompts once)."""
75
+ if not isinstance(data, dict):
76
+ raise DeckError("top-level JSON is not an object")
77
+ cards = data.get("cards")
78
+ if not isinstance(cards, list):
79
+ raise DeckError("'cards' is missing or not a list")
80
+ if len(cards) != 22:
81
+ raise DeckError(f"expected 22 cards, got {len(cards)}")
82
+
83
+ by_number: dict[int, dict] = {}
84
+ for idx, c in enumerate(cards):
85
+ if not isinstance(c, dict):
86
+ raise DeckError(f"card {idx} is not an object")
87
+ try:
88
+ n = int(c.get("arcana_number"))
89
+ except (TypeError, ValueError):
90
+ raise DeckError(f"card {idx} has a non-integer arcana_number")
91
+ if not 0 <= n <= 21:
92
+ raise DeckError(f"card {idx} arcana_number {n} out of range 0-21")
93
+ if n in by_number:
94
+ raise DeckError(f"duplicate arcana_number {n}")
95
+ for f in _CARD_FIELDS:
96
+ v = c.get(f)
97
+ if not isinstance(v, str) or not v.strip():
98
+ raise DeckError(f"card {n} ({NAME_BY_NUMBER[n]}) has empty '{f}'")
99
+ by_number[n] = c
100
+
101
+ missing = [a.number for a in MAJOR_ARCANA if a.number not in by_number]
102
+ if missing:
103
+ raise DeckError(f"missing arcana numbers: {missing}")
104
+
105
+ # normalize: canonical names/order, trimmed strings, keep concept dupes visible
106
+ norm_cards = []
107
+ seen_concepts: dict[str, int] = {}
108
+ for a in MAJOR_ARCANA:
109
+ c = by_number[a.number]
110
+ concept = c["concept"].strip()
111
+ key = concept.lower()
112
+ if key in seen_concepts:
113
+ raise DeckError(
114
+ f"concept {concept!r} reused for arcana {a.number} and "
115
+ f"{seen_concepts[key]}"
116
+ )
117
+ seen_concepts[key] = a.number
118
+ norm_cards.append({
119
+ "arcana_number": a.number,
120
+ "arcana_name": a.name,
121
+ "concept": concept,
122
+ "justification": c["justification"].strip(),
123
+ "upright_meaning": c["upright_meaning"].strip(),
124
+ "reversed_meaning": c["reversed_meaning"].strip(),
125
+ "art_prompt": c["art_prompt"].strip(),
126
+ "art_path": None,
127
+ })
128
+
129
+ style = data.get("style_suffix")
130
+ if not isinstance(style, str) or not style.strip():
131
+ style = "ornate tarot illustration, cohesive palette, symbolic, no text, no border"
132
+ return {
133
+ "theme": (data.get("theme") or theme).strip(),
134
+ "style_suffix": style.strip(),
135
+ "cards": norm_cards,
136
+ }
137
+
138
+
139
+ # ------------------------------------------------------------------ generate
140
+ def design_deck(theme: str, llm: LLM | None = None) -> dict:
141
+ """Run Agent 1 for a theme and return a validated deck. Re-prompts ONCE on a
142
+ parse/structural failure (SPEC §4, §10). Raises DeckError if both attempts
143
+ fail — build-time callers should let this surface."""
144
+ theme = (theme or "").strip()
145
+ if not theme:
146
+ raise DeckError("empty theme")
147
+ llm = llm or get_llm()
148
+ system = designer_system_prompt()
149
+ user = designer_user_prompt(theme)
150
+
151
+ first_err = None
152
+ raw = llm.complete(system, user, json_mode=True)
153
+ try:
154
+ return validate_deck(extract_json(raw), theme)
155
+ except DeckError as e:
156
+ first_err = e
157
+
158
+ # one repair attempt, told exactly what was wrong
159
+ repair = (
160
+ f"{user}\n\nYour previous reply was rejected: {first_err}. "
161
+ "Return ONLY the corrected strict JSON object with exactly 22 cards "
162
+ "covering arcana 0 through 21, each with non-empty concept, "
163
+ "justification, upright_meaning, reversed_meaning and art_prompt. "
164
+ "No prose, no code fences."
165
+ )
166
+ raw2 = llm.complete(system, repair, json_mode=True)
167
+ try:
168
+ return validate_deck(extract_json(raw2), theme)
169
+ except DeckError as e:
170
+ raise DeckError(f"deck invalid after repair (first: {first_err}) -> {e}") from e
arcana/frame.py ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Decorative card frame — built ONCE and reused for all 22 cards (SPEC §8).
2
+
3
+ Approach (A): an authored vector-ish frame overlay rendered with Pillow at a
4
+ fixed card size — an RGBA layer with a transparent centre (the art shows
5
+ through) plus an ornate gold border, a top plate for the roman numeral, and a
6
+ bottom cartouche for the concept name. Per-card text is drawn at composite time
7
+ (§9); it is NEVER baked into the generated art. The overlay is cached so the
8
+ "build the frame one time" requirement holds.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import functools
13
+ import os
14
+
15
+ from PIL import Image, ImageDraw, ImageFont
16
+
17
+ ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
18
+ FONT_DIR = os.path.join(ROOT, "assets", "fonts")
19
+
20
+ CARD_W, CARD_H = 720, 1100
21
+
22
+ # occult palette
23
+ GOLD = (200, 162, 74)
24
+ GOLD_SOFT = (150, 122, 60)
25
+ INK = (18, 11, 30) # deep purple-black
26
+ PARCH = (243, 230, 196) # parchment text
27
+
28
+ BORDER = 26 # outer dark band thickness
29
+ NUM_PLATE = (CARD_W // 2 - 70, 30, CARD_W // 2 + 70, 104) # roman numeral
30
+ CART_PLATE = (54, CARD_H - 188, CARD_W - 54, CARD_H - 60) # concept title
31
+
32
+
33
+ @functools.lru_cache(maxsize=8)
34
+ def font(size: int, weight: int = 400, face: str = "Cinzel") -> ImageFont.FreeTypeFont:
35
+ f = ImageFont.truetype(os.path.join(FONT_DIR, f"{face}.ttf"), size)
36
+ try: # variable fonts: pick a weight on the wght axis
37
+ f.set_variation_by_axes([weight])
38
+ except Exception:
39
+ pass
40
+ return f
41
+
42
+
43
+ def _rounded(draw: ImageDraw.ImageDraw, box, radius, fill=None, outline=None, width=2):
44
+ draw.rounded_rectangle(box, radius=radius, fill=fill, outline=outline, width=width)
45
+
46
+
47
+ @functools.lru_cache(maxsize=4)
48
+ def frame_overlay(w: int = CARD_W, h: int = CARD_H) -> Image.Image:
49
+ """The reusable frame as an RGBA overlay with a transparent art window."""
50
+ ov = Image.new("RGBA", (w, h), (0, 0, 0, 0))
51
+ d = ImageDraw.Draw(ov)
52
+
53
+ # dark vignette band hugging the edges (frames the full-bleed art)
54
+ d.rectangle([0, 0, w, h], outline=None)
55
+ band = Image.new("RGBA", (w, h), (0, 0, 0, 0))
56
+ bd = ImageDraw.Draw(band)
57
+ bd.rectangle([0, 0, w, h], fill=(*INK, 235))
58
+ bd.rectangle([BORDER, BORDER, w - BORDER, h - BORDER], fill=(0, 0, 0, 0))
59
+ ov.alpha_composite(band)
60
+
61
+ # double gold rule just inside the band
62
+ d.rectangle([BORDER - 6, BORDER - 6, w - BORDER + 6, h - BORDER + 6],
63
+ outline=(*GOLD, 255), width=3)
64
+ d.rectangle([BORDER + 4, BORDER + 4, w - BORDER - 4, h - BORDER - 4],
65
+ outline=(*GOLD_SOFT, 220), width=1)
66
+
67
+ # corner flourishes — small gold diamonds
68
+ for cx, cy in [(BORDER, BORDER), (w - BORDER, BORDER),
69
+ (BORDER, h - BORDER), (w - BORDER, h - BORDER)]:
70
+ d.polygon([(cx, cy - 10), (cx + 10, cy), (cx, cy + 10), (cx - 10, cy)],
71
+ fill=(*GOLD, 255))
72
+
73
+ # top numeral plate + bottom title cartouche (filled, gold-edged)
74
+ _rounded(d, NUM_PLATE, 10, fill=(*INK, 245), outline=(*GOLD, 255), width=2)
75
+ _rounded(d, CART_PLATE, 14, fill=(*INK, 245), outline=(*GOLD, 255), width=2)
76
+ # inner hairline on the cartouche
77
+ _rounded(d, (CART_PLATE[0] + 6, CART_PLATE[1] + 6, CART_PLATE[2] - 6, CART_PLATE[3] - 6),
78
+ 10, outline=(*GOLD_SOFT, 200), width=1)
79
+ return ov
arcana/imagegen.py ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Swappable image generator (SPEC §7).
2
+
3
+ The app programs against the ``ImageGen`` protocol. Consistent with the sibling
4
+ Globe project (which calls its ≤32B model through an inference endpoint and
5
+ keeps the Space a lean CPU box), the default backend calls an OPEN ≤32B image
6
+ model — FLUX.1-schnell (~12B) — through the same DeepInfra endpoint we use for
7
+ the LLM. No closed API, no local GPU, no torch/diffusers in the deploy.
8
+
9
+ Generates the CENTRAL ILLUSTRATION ONLY (no frame, no border, no text — those
10
+ are added in the compositor, §8/§9). A fixed style suffix + a fixed seed family
11
+ give the 22 cards one cohesive look.
12
+
13
+ LocalImageGen is a stub for a GPU box running diffusers locally (earns the
14
+ local-first bonus, §14); left unimplemented so v1 doesn't block on GPU access.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import base64
19
+ import io
20
+ import os
21
+ from typing import Protocol, runtime_checkable
22
+
23
+ from PIL import Image
24
+
25
+ # Open ≤32B image models on DeepInfra's OpenAI-compatible images endpoint.
26
+ # FLUX.1-schnell ~12B (best quality / few steps); sdxl-turbo ~3.5B (lighter).
27
+ DEFAULT_IMAGE_MODEL = "black-forest-labs/FLUX-1-schnell"
28
+ DEFAULT_IMAGE_BASE_URL = "https://api.deepinfra.com/v1/openai"
29
+
30
+ # Appended to every art_prompt so all 22 cards share one look (style cohesion
31
+ # comes from this + a fixed seed family, not from per-card prompts). The
32
+ # per-deck style_suffix from Agent 1 is layered in front of this at call time.
33
+ GLOBAL_STYLE = ("ornate symbolic tarot card illustration, centered subject, "
34
+ "cohesive color palette, painterly, no text, no words, no border, "
35
+ "no frame, full-bleed artwork")
36
+
37
+
38
+ @runtime_checkable
39
+ class ImageGen(Protocol):
40
+ def generate(self, prompt: str, seed: int | None = None) -> Image.Image:
41
+ """Return a central-illustration PIL image for the prompt."""
42
+ ...
43
+
44
+
45
+ def _style(prompt: str, deck_style: str | None) -> str:
46
+ parts = [prompt.strip().rstrip(".")]
47
+ if deck_style:
48
+ parts.append(deck_style.strip().rstrip("."))
49
+ parts.append(GLOBAL_STYLE)
50
+ return ", ".join(p for p in parts if p)
51
+
52
+
53
+ class EndpointImageGen:
54
+ """Open ≤32B image model behind an OpenAI-compatible images endpoint."""
55
+
56
+ def __init__(self, model: str | None = None, base_url: str | None = None,
57
+ api_key: str | None = None, deck_style: str | None = None,
58
+ size: str = "1024x1024"):
59
+ from openai import OpenAI # lazy
60
+
61
+ self.model = model or os.environ.get("IMAGE_MODEL", DEFAULT_IMAGE_MODEL)
62
+ self.base_url = base_url or os.environ.get("IMAGE_BASE_URL", DEFAULT_IMAGE_BASE_URL)
63
+ self.deck_style = deck_style
64
+ self.size = os.environ.get("IMAGE_SIZE", size)
65
+ key = (api_key or os.environ.get("IMAGE_API_KEY")
66
+ or os.environ.get("DEEPINFRA_TOKEN") or os.environ.get("DEEPINFRA_API_KEY"))
67
+ if not key:
68
+ raise RuntimeError("No image API key. Set IMAGE_API_KEY or DEEPINFRA_TOKEN.")
69
+ self.client = OpenAI(base_url=self.base_url, api_key=key, timeout=120.0)
70
+
71
+ def generate(self, prompt: str, seed: int | None = None) -> Image.Image:
72
+ kwargs = dict(model=self.model, prompt=_style(prompt, self.deck_style),
73
+ n=1, size=self.size)
74
+ if seed is not None: # DeepInfra accepts seed as an extra body field
75
+ kwargs["extra_body"] = {"seed": int(seed)}
76
+ resp = self.client.images.generate(**kwargs)
77
+ datum = resp.data[0]
78
+ b64 = getattr(datum, "b64_json", None)
79
+ if b64:
80
+ raw = base64.b64decode(b64)
81
+ else:
82
+ url = getattr(datum, "url", None)
83
+ if not url:
84
+ raise RuntimeError("image response had neither b64_json nor url")
85
+ if url.startswith("data:"): # data:image/png;base64,XXXX
86
+ raw = base64.b64decode(url.split(",", 1)[1])
87
+ else:
88
+ import requests
89
+ r = requests.get(url, timeout=120); r.raise_for_status(); raw = r.content
90
+ return Image.open(io.BytesIO(raw)).convert("RGB")
91
+
92
+
93
+ class LocalImageGen:
94
+ """STRETCH stub — diffusers SDXL/FLUX on a local GPU (SPEC §7, §14)."""
95
+
96
+ def generate(self, prompt: str, seed: int | None = None) -> Image.Image:
97
+ raise NotImplementedError(
98
+ "local image backend needs a GPU + diffusers; use the endpoint backend."
99
+ )
100
+
101
+
102
+ def get_imagegen(deck_style: str | None = None) -> ImageGen:
103
+ backend = os.environ.get("IMAGE_BACKEND", "endpoint").lower()
104
+ if backend == "local":
105
+ return LocalImageGen()
106
+ return EndpointImageGen(deck_style=deck_style)
arcana/llm.py ADDED
@@ -0,0 +1,115 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Swappable LLM adapter (SPEC §6).
2
+
3
+ The app programs against the ``LLM`` protocol and never knows which backend
4
+ serves it. Both agents (Designer §4, Reader §5) are just different prompts
5
+ through ``complete()``.
6
+
7
+ Backends, selected by ``LLM_BACKEND``:
8
+ endpoint (default here) — an OpenAI-compatible endpoint serving the *same
9
+ open ≤32B model*. We have no local GPU (CPU-only torch), so dev runs
10
+ Qwen3-32B through the HF Inference Providers router / DeepInfra.
11
+ This is still an OPEN ≤32B model — never a closed frontier model.
12
+ local — Qwen-32B via transformers/vLLM/llama.cpp on a GPU box. Same
13
+ interface; wire when GPU is available (earns local-first/llama.cpp
14
+ bonus, SPEC §14). Left as a thin stub so v1 doesn't block on it.
15
+
16
+ The model string is configurable via ``QWEN_MODEL``. SPEC §2 names
17
+ Qwen2.5-32B-Instruct with Qwen3-32B an acceptable swap; Qwen2.5-32B is not
18
+ currently hosted on our providers, so the default is Qwen3-32B.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import os
23
+ from typing import Protocol, runtime_checkable
24
+
25
+
26
+ @runtime_checkable
27
+ class LLM(Protocol):
28
+ def complete(self, system: str, user: str, json_mode: bool = False) -> str:
29
+ """Return the model's text completion for a system+user prompt."""
30
+ ...
31
+
32
+
33
+ # Qwen3-32B is a "thinking" model: by default it emits a <think> trace that
34
+ # overruns max_tokens before any JSON. /no_think gives a direct answer.
35
+ DEFAULT_MODEL = "Qwen/Qwen3-32B"
36
+
37
+ # provider presets: (base_url, env vars to try for the key, in order)
38
+ _PROVIDERS = {
39
+ "hf": ("https://router.huggingface.co/v1", ("HF_TOKEN", "HUGGINGFACEHUB_API_TOKEN")),
40
+ "deepinfra": ("https://api.deepinfra.com/v1/openai", ("DEEPINFRA_TOKEN", "DEEPINFRA_API_KEY")),
41
+ }
42
+
43
+
44
+ class EndpointLLM:
45
+ """Open ≤32B model behind an OpenAI-compatible endpoint."""
46
+
47
+ def __init__(self, model: str | None = None, provider: str | None = None,
48
+ base_url: str | None = None, api_key: str | None = None,
49
+ temperature: float = 0.7, max_tokens: int = 6000):
50
+ from openai import OpenAI # lazy: the local backend needs no openai
51
+
52
+ self.model = model or os.environ.get("QWEN_MODEL", DEFAULT_MODEL)
53
+ self.temperature = float(os.environ.get("QWEN_TEMPERATURE", temperature))
54
+ self.max_tokens = int(os.environ.get("QWEN_MAX_TOKENS", max_tokens))
55
+
56
+ provider = (provider or os.environ.get("LLM_PROVIDER", "hf")).lower()
57
+ preset_url, key_envs = _PROVIDERS.get(provider, _PROVIDERS["hf"])
58
+ self.base_url = base_url or os.environ.get("LLM_BASE_URL", preset_url)
59
+ key = api_key or os.environ.get("LLM_API_KEY")
60
+ for env in key_envs:
61
+ key = key or os.environ.get(env)
62
+ if not key:
63
+ raise RuntimeError(
64
+ f"No API key for provider {provider!r}. Set LLM_API_KEY or one of "
65
+ f"{key_envs} (Space secret for deploy)."
66
+ )
67
+ self.client = OpenAI(base_url=self.base_url, api_key=key, timeout=120.0)
68
+
69
+ def _is_thinking_qwen(self) -> bool:
70
+ m = self.model.lower()
71
+ return "qwen3" in m and "instruct" not in m
72
+
73
+ def complete(self, system: str, user: str, json_mode: bool = False) -> str:
74
+ if self._is_thinking_qwen():
75
+ user = user.rstrip() + "\n/no_think"
76
+ kwargs = dict(
77
+ model=self.model,
78
+ messages=[{"role": "system", "content": system},
79
+ {"role": "user", "content": user}],
80
+ temperature=self.temperature,
81
+ max_tokens=self.max_tokens,
82
+ )
83
+ if json_mode:
84
+ kwargs["response_format"] = {"type": "json_object"}
85
+ try:
86
+ resp = self.client.chat.completions.create(**kwargs)
87
+ except Exception:
88
+ if json_mode: # some providers reject json_object — retry without it
89
+ kwargs.pop("response_format", None)
90
+ resp = self.client.chat.completions.create(**kwargs)
91
+ else:
92
+ raise
93
+ return resp.choices[0].message.content or ""
94
+
95
+
96
+ class LocalLLM:
97
+ """STRETCH stub for a GPU-local Qwen-32B (transformers/vLLM/llama.cpp).
98
+
99
+ Point EndpointLLM at a local OpenAI-compatible server
100
+ (LLM_BASE_URL=http://127.0.0.1:8080/v1) and that path already works today; a
101
+ truly in-process backend would slot in here. Unimplemented so v1 doesn't
102
+ block on GPU access (SPEC §6, §12, §14).
103
+ """
104
+
105
+ def complete(self, system: str, user: str, json_mode: bool = False) -> str:
106
+ raise NotImplementedError(
107
+ "local backend needs a GPU; use LLM_BACKEND=endpoint (default) for dev."
108
+ )
109
+
110
+
111
+ def get_llm() -> LLM:
112
+ backend = os.environ.get("LLM_BACKEND", "endpoint").lower()
113
+ if backend == "local":
114
+ return LocalLLM()
115
+ return EndpointLLM()
arcana/loremaster.py ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Agent 1.5 — the Loremaster (deck reinterpretation pass).
2
+
3
+ After the Designer maps the 22 archetypes onto in-theme concepts, this agent
4
+ takes the WHOLE deck — each concept, the classical archetype it came from, that
5
+ archetype's canonical meaning, and the Designer's first-draft meanings — and
6
+ rewrites every card's meaning to be *native to the concept itself*, rather than a
7
+ stiff reproduction of the original tarot card. It sees all 22 at once, so it can
8
+ make the deck internally coherent and keep cards distinct.
9
+
10
+ The point: a card like "The One Ring" should read about corruption, burden and
11
+ the fate it drags behind it — not a generic "cycles and turning points" gloss
12
+ inherited from Wheel of Fortune. The archetype is the seed; the concept is the
13
+ plant. This pass also adds a one-line `essence` the Reader can draw on.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ from .archetypes import MAJOR_ARCANA, NAME_BY_NUMBER
18
+ from .designer import DeckError, extract_json
19
+ from .llm import LLM, get_llm
20
+
21
+ _FIELDS = ("essence", "upright_meaning", "reversed_meaning")
22
+
23
+
24
+ def _system() -> str:
25
+ return """\
26
+ You are the loremaster of a custom tarot deck. Each card began life as a classical
27
+ Major Arcana archetype, but has been re-cast as a concept from a single theme. Your
28
+ job is to make every card's meaning feel TRUE TO ITS OWN CONCEPT — drawn from what
29
+ that concept actually is, does, and evokes — instead of a stiff hand-me-down of the
30
+ original tarot card's text.
31
+
32
+ You are given, for each card: the concept, the archetype it was seeded from, that
33
+ archetype's canonical meaning, and a first-draft upright/reversed meaning. Keep the
34
+ archetype's underlying SHAPE (a Death-card still concerns endings; a Tower-card still
35
+ concerns sudden collapse) but re-express it through the concept's own specifics,
36
+ imagery and stakes. Lean into what makes THIS concept particular. Make the 22 cards
37
+ distinct from one another — no two should read interchangeably.
38
+
39
+ For each card produce:
40
+ - essence: one vivid line naming what this card uniquely means in this deck.
41
+ - upright_meaning: 2-3 sentences, concept-native, concrete and evocative — never
42
+ boilerplate that could be pasted onto any card.
43
+ - reversed_meaning: 1-2 sentences — the shadow / blockage / inversion, also
44
+ concept-native.
45
+
46
+ Speak in confident, grounded tarot voice. No hedging, no meta-commentary.
47
+
48
+ OUTPUT: strict JSON only, no prose, no fences:
49
+ { "cards": [ { "arcana_number": <0-21>, "essence": "...", "upright_meaning": "...", "reversed_meaning": "..." }, ... 22 ] }"""
50
+
51
+
52
+ def _user(deck: dict) -> str:
53
+ canon = {a.number: a.meaning for a in MAJOR_ARCANA}
54
+ blocks = []
55
+ for c in deck["cards"]:
56
+ n = c["arcana_number"]
57
+ blocks.append(
58
+ f"--- card {n} ---\n"
59
+ f"concept: {c['concept']}\n"
60
+ f"seeded from archetype: {NAME_BY_NUMBER[n]} (classical meaning: {canon[n]})\n"
61
+ f"draft upright: {c['upright_meaning']}\n"
62
+ f"draft reversed: {c['reversed_meaning']}"
63
+ )
64
+ return (
65
+ f"Theme: {deck.get('theme')}\n\n"
66
+ "Reinterpret all 22 cards so each meaning is native to its own concept. "
67
+ "Return the strict JSON object only.\n\n" + "\n\n".join(blocks)
68
+ )
69
+
70
+
71
+ def _apply(deck: dict, data: dict) -> dict:
72
+ if not isinstance(data, dict) or not isinstance(data.get("cards"), list):
73
+ raise DeckError("loremaster output missing 'cards' list")
74
+ refined = {}
75
+ for c in data["cards"]:
76
+ try:
77
+ n = int(c.get("arcana_number"))
78
+ except (TypeError, ValueError):
79
+ raise DeckError("loremaster card has non-integer arcana_number")
80
+ for f in _FIELDS:
81
+ v = c.get(f)
82
+ if not isinstance(v, str) or not v.strip():
83
+ raise DeckError(f"loremaster card {n} has empty '{f}'")
84
+ refined[n] = c
85
+ missing = [a.number for a in MAJOR_ARCANA if a.number not in refined]
86
+ if missing:
87
+ raise DeckError(f"loremaster missing arcana: {missing}")
88
+
89
+ for card in deck["cards"]:
90
+ r = refined[card["arcana_number"]]
91
+ card["essence"] = r["essence"].strip()
92
+ card["upright_meaning"] = r["upright_meaning"].strip()
93
+ card["reversed_meaning"] = r["reversed_meaning"].strip()
94
+ return deck
95
+
96
+
97
+ def refine_deck(deck: dict, llm: LLM | None = None) -> dict:
98
+ """Rewrite every card's meaning to be concept-native (one batched call, with
99
+ one repair retry). Mutates and returns the deck dict."""
100
+ llm = llm or get_llm()
101
+ system, user = _system(), _user(deck)
102
+
103
+ raw = llm.complete(system, user, json_mode=True)
104
+ try:
105
+ return _apply(deck, extract_json(raw))
106
+ except DeckError as first:
107
+ repair = (f"{user}\n\nYour previous reply was rejected: {first}. Return ONLY "
108
+ "the corrected strict JSON with all 22 cards, each having non-empty "
109
+ "essence, upright_meaning and reversed_meaning.")
110
+ raw2 = llm.complete(system, repair, json_mode=True)
111
+ return _apply(deck, extract_json(raw2))
arcana/prompts.py ADDED
@@ -0,0 +1,137 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Prompts for Agent 1 — the Deck Designer (SPEC §4).
2
+
3
+ Mapping quality is the single most important thing in this build, and the
4
+ few-shot examples below are what teach the model the "clever-but-right"
5
+ standard. They show the full 22-archetype mapping for one serious theme
6
+ (thermodynamics) and one silly theme (breakfast foods), plus one fully detailed
7
+ card so the model learns the output format and the meaning-rewriting voice.
8
+
9
+ The mappings honour the SPEC's own worked examples: Entropy→Death,
10
+ Absolute Zero→The Hermit, Critical Point→The Tower.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ from .archetypes import reference_block
15
+
16
+ # Compact teaching tables: archetype → concept — why it's earned (not arbitrary).
17
+ _FEWSHOT_THERMO = """\
18
+ THEME: thermodynamics
19
+ 0 The Fool → Activation Energy — the leap you must pay up front before anything can begin
20
+ I The Magician → Work — directed energy that channels raw potential into real change
21
+ II The High Priestess → Internal Energy — the hidden total you can never measure directly, only its changes
22
+ III The Empress → Heat Reservoir — an abundant, inexhaustible source that nurtures warmth into everything
23
+ IV The Emperor → The First Law — the unbreakable governing rule: energy is conserved, no exceptions
24
+ V The Hierophant → Standard State (STP) — the agreed convention every measurement bows to
25
+ VI The Lovers → Thermal Contact — two bodies coupled until their temperatures align as one
26
+ VII The Chariot → The Heat Engine — chaotic heat harnessed and steered into directed motion
27
+ VIII Strength → Heat Capacity — quiet endurance, absorbing energy without ever flaring up
28
+ IX The Hermit → Absolute Zero — utter stillness and solitude, all motion finally ceased
29
+ X Wheel of Fortune → The Carnot Cycle — the ideal loop that endlessly turns and returns to its start
30
+ XI Justice → The Second Law's Toll — every process pays an irreversible tax; you cannot break even
31
+ XII The Hanged Man → Metastability — suspended in a false minimum, surrendered but not yet resolved
32
+ XIII Death → Entropy — irreversible transformation, the one-way march toward the end-state
33
+ XIV Temperance → Thermal Equilibrium — hot and cold blended down to one moderate, settled mean
34
+ XV The Devil → Bound Energy — energy chained inside the system, forever unavailable to do work
35
+ XVI The Tower → The Critical Point — where distinctions suddenly collapse and the old phase fails
36
+ XVII The Star → Free Energy — the hopeful, still-usable energy that can yet be spent on work
37
+ XVIII The Moon → Thermal Fluctuations — random microscopic jitter beneath the calm macroscopic surface
38
+ XIX The Sun → Blackbody Radiation — the radiant glow of a hot body; literal light and vitality
39
+ XX Judgement → The Third Law — as you near absolute zero, entropy resolves to perfect order, a final reckoning
40
+ XXI The World → The Universe — system plus surroundings, the whole and total accounting"""
41
+
42
+ _FEWSHOT_BREAKFAST = """\
43
+ THEME: breakfast foods
44
+ 0 The Fool → The Banana — fresh beginnings, and the original literal pratfall waiting to happen
45
+ I The Magician → The Whisk — sheer willed motion that conjures raw ingredients into something new
46
+ II The High Priestess → The Covered Dish — hidden contents; you must lift the lid to reveal the unseen
47
+ III The Empress → The Stack of Pancakes — warm, syrup-rich abundance, endlessly nurturing
48
+ IV The Emperor → Black Coffee — the bitter, stabilizing authority that rules the whole morning
49
+ V The Hierophant → The Continental Breakfast — the same institutional spread laid out by tradition everywhere
50
+ VI The Lovers → Bacon and Eggs — the classic union, two partners always chosen together
51
+ VII The Chariot → The Toaster — directed force, bread launched upward through controlled heat
52
+ VIII Strength → Oatmeal — humble, gentle power that quietly sustains you all morning long
53
+ IX The Hermit → The Lone Boiled Egg — solitary and self-contained, sitting alone in its little cup
54
+ X Wheel of Fortune → The Cereal-Box Prize — pure chance; what you get is fate, sight unseen
55
+ XI Justice → The Diner Check — fair accounting; you pay for exactly what you ordered
56
+ XII The Hanged Man → French Toast Soaking — suspended in the custard, surrendered, transformed by the wait
57
+ XIII Death → The Burnt Toast — irreversible; you cannot un-burn it, only scrape it and begin again
58
+ XIV Temperance → The Smoothie — disparate fruits blended into one balanced, moderated whole
59
+ XV The Devil → The Sugary Cereal — sweet craving and indulgence, the bondage you can't put down
60
+ XVI The Tower → The Dropped Tray — sudden upheaval; the whole breakfast collapses to the floor at once
61
+ XVII The Star → Fresh-Squeezed Orange Juice — bright hope and vitamin-C renewal, sunny optimism
62
+ XVIII The Moon → The Mystery Omelette — ambiguity and unease; you are never quite sure what is inside
63
+ XIX The Sun → The Sunny-Side-Up Egg — literally the sun, a bright yolk of joy and clarity
64
+ XX Judgement → "Order's Up!" — the breakfast bell, the calling that summons you to the table
65
+ XXI The World → The Full English — completion; every element integrated together on one plate"""
66
+
67
+ # One fully detailed card so the model learns the output shape and the voice:
68
+ # meanings are the archetype RE-EXPRESSED through the concept, never boilerplate.
69
+ _FEWSHOT_CARD = """\
70
+ Example of one fully detailed card object (theme: thermodynamics):
71
+ {
72
+ "arcana_number": 13,
73
+ "arcana_name": "Death",
74
+ "concept": "Entropy",
75
+ "justification": "Both name an irreversible one-way transformation toward an end-state that cannot be undone.",
76
+ "upright_meaning": "An ending you cannot reverse. The old order is dispersing into the new and there is no path back — but this is the natural direction of things, not a punishment. Let what is spent be spent.",
77
+ "reversed_meaning": "Clinging to a structure the universe has already moved past; pouring energy into un-mixing what is mixed. A refusal to accept the arrow of time, and the exhaustion that refusal brings.",
78
+ "art_prompt": "a central illustration of a crumbling sandcastle dissolving grain by grain into a still dark sea, warm embers cooling to grey ash drifting outward, a single thread unravelling into the dusk"
79
+ }"""
80
+
81
+
82
+ def designer_system_prompt() -> str:
83
+ return f"""\
84
+ You are a master tarot deck designer with a scholar's grasp of symbolism. Your \
85
+ craft is taking ANY theme a person names and discovering, within that theme, the \
86
+ 22 Major Arcana hiding inside it.
87
+
88
+ You do not invent 22 cards from scratch. You are handed the 22 fixed Major Arcana \
89
+ archetypes below, and for EACH one you assign a single concept drawn from the \
90
+ theme — chosen because it shares the archetype's deep meaning. The fit must feel \
91
+ EARNED and clever-but-right, never arbitrary. A great mapping makes the reader \
92
+ think "of course — I never saw it before, but of course." A lazy or generic \
93
+ mapping ruins the whole deck.
94
+
95
+ THE 22 MAJOR ARCANA (archetype — canonical meaning):
96
+ {reference_block()}
97
+
98
+ RULES FOR A GREAT MAPPING:
99
+ - One in-theme concept per archetype. Exactly 22, covering arcana 0-21, no \
100
+ concept repeated.
101
+ - Choose the concept whose real meaning rhymes with the archetype's meaning — \
102
+ not merely a famous item from the theme stuffed into a slot. Earn it.
103
+ - `justification`: one crisp line naming the shared meaning that makes the fit click.
104
+ - `upright_meaning` / `reversed_meaning`: the archetype's meaning RE-EXPRESSED \
105
+ through the concept, in evocative tarot voice. Concrete and specific to this \
106
+ concept — never generic boilerplate that would fit any card.
107
+ - `art_prompt`: a vivid CENTRAL ILLUSTRATION ONLY — no border, no frame, no card \
108
+ layout, and NO text or numerals in the image (those are added later). Describe \
109
+ symbolic imagery, composition, and mood.
110
+ - `style_suffix`: choose ONE short visual style line that suits the whole theme \
111
+ (e.g. art-deco blueprint, warm storybook gouache, baroque oil painting). It will \
112
+ be appended to every card's art_prompt so the 22 cards share one cohesive look.
113
+
114
+ Study how these two reference decks earn every mapping:
115
+
116
+ {_FEWSHOT_THERMO}
117
+
118
+ {_FEWSHOT_BREAKFAST}
119
+
120
+ {_FEWSHOT_CARD}
121
+
122
+ OUTPUT CONTRACT — reply with STRICT JSON ONLY. No prose, no markdown fences. \
123
+ Shape:
124
+ {{
125
+ "theme": "<the theme>",
126
+ "style_suffix": "<one shared visual style line>",
127
+ "cards": [ {{ 22 card objects as shown, in arcana order 0..21 }} ]
128
+ }}"""
129
+
130
+
131
+ def designer_user_prompt(theme: str) -> str:
132
+ return (
133
+ f"Design the Major Arcana deck for this theme: {theme}\n\n"
134
+ "Discover the 22 concepts hiding inside it and map one to each archetype, "
135
+ "in order from 0 (The Fool) to 21 (The World). Make every mapping earned. "
136
+ "Reply with the strict JSON object only."
137
+ )
arcana/reader.py ADDED
@@ -0,0 +1,143 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Agent 2 — the Reader / Oracle, PROGRESSIVE (SPEC §5).
2
+
3
+ Real readings are sequential: cards turn one at a time, each interpreted in light
4
+ of what came before, with the full synthesis held for the end. This module mirrors
5
+ that — the app reveals a partial as each card lands (card_partial), then the held
6
+ finale (final_synthesis). The reader speaks the concepts' own language and never
7
+ names the classical archetype in its prose (the mapping is hidden, per the deck's
8
+ rendering), but it IS told where each card sits on the 0→21 Fool's Journey so it
9
+ can add that depth.
10
+
11
+ v1 uses one default voice — an earnest, warm mystic — lightly tinted by the deck's
12
+ visual style (§5). No custom free-text reading voice in v1.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import random
17
+ import re
18
+
19
+ from .llm import LLM, get_llm
20
+ from .styles import reading_tint
21
+
22
+ SPREADS: dict[str, list[str]] = {
23
+ "single": ["The Heart of It"],
24
+ "three": ["Past", "Present", "Future"],
25
+ }
26
+
27
+ BASE_VOICE = "an earnest, warm mystic — reverent and a little uncanny, but never silly"
28
+
29
+
30
+ def _strip_think(text: str) -> str:
31
+ return re.sub(r"<think>.*?</think>", "", text, flags=re.DOTALL).strip()
32
+
33
+
34
+ def draw_spread(deck: dict, spread: str = "three", reversals: bool = True,
35
+ seed: int | None = None) -> list[dict]:
36
+ """Draw cards (no repeats); orientations random only if reversals enabled."""
37
+ positions = SPREADS.get(spread, SPREADS["three"])
38
+ rng = random.Random(seed)
39
+ chosen = rng.sample(deck["cards"], k=len(positions))
40
+ drawn = []
41
+ for pos, card in zip(positions, chosen):
42
+ reversed_ = reversals and rng.random() < 0.5
43
+ drawn.append({
44
+ "position": pos,
45
+ "arcana_name": card["arcana_name"],
46
+ "arcana_number": card["arcana_number"],
47
+ "concept": card["concept"],
48
+ "orientation": "reversed" if reversed_ else "upright",
49
+ "meaning": card["reversed_meaning"] if reversed_ else card["upright_meaning"],
50
+ "essence": card.get("essence", ""),
51
+ "art_path": card.get("art_path"),
52
+ })
53
+ return drawn
54
+
55
+
56
+ def journey_note(n: int) -> str:
57
+ """Where arcana n sits on the 0→21 Fool's Journey — as a phrase, never the
58
+ classical card name (the mapping stays hidden in the prose)."""
59
+ if n == 0: return "the very first step of the journey, setting out"
60
+ if n <= 2: return "the journey's first awakening"
61
+ if n <= 5: return "the early road of the journey"
62
+ if n <= 7: return "the journey's first real choices"
63
+ if n == 8 or n == 9: return "the turn inward at mid-journey"
64
+ if n == 10: return "the great turning of the journey's wheel"
65
+ if n <= 12: return "the reckoning in the middle of the journey"
66
+ if n <= 14: return "the journey's death-and-rebirth passage"
67
+ if n == 15: return "the journey's confrontation with the shadow"
68
+ if n == 16: return "the crisis point of the journey"
69
+ if n <= 19: return "the journey's return toward the light"
70
+ if n == 20: return "the journey's reckoning and awakening"
71
+ return "the completion of the journey"
72
+
73
+
74
+ def _voice(deck: dict) -> str:
75
+ tint = reading_tint(deck.get("visual_style", ""))
76
+ return BASE_VOICE + (f"; {tint}" if tint else "")
77
+
78
+
79
+ def _prior_block(drawn: list[dict], upto: int) -> str:
80
+ if upto == 0:
81
+ return "(none yet — this is the first card)"
82
+ return "\n".join(f"- [{d['position']}] {d['concept']} ({d['orientation']})"
83
+ for d in drawn[:upto])
84
+
85
+
86
+ # ------------------------------------------------------------------ stages
87
+ def card_partial(deck: dict, question: str, drawn: list[dict], idx: int,
88
+ llm: LLM | None = None) -> str:
89
+ """Interpret ONLY drawn[idx], coloured by the cards already turned. Builds the
90
+ thread; does not resolve (the synthesis does)."""
91
+ llm = llm or get_llm()
92
+ theme = deck.get("theme", "this world")
93
+ d = drawn[idx]
94
+ q = question.strip() or "an open reading — whatever the cards wish to say"
95
+ system = f"""\
96
+ You are giving a live tarot reading, turning cards one at a time, from a deck whose
97
+ cards are concepts from {theme}. Your voice is {_voice(deck)}.
98
+
99
+ Interpret ONLY the card just turned — its meaning in its position, coloured by the
100
+ cards already shown. Call it by its concept name and treat it as that very thing,
101
+ with its own texture and stakes. Do NOT name classical tarot cards or archetypes.
102
+ Build the thread and let suspense gather — do NOT resolve the reading yet; the final
103
+ synthesis comes after the last card. 2 to 4 sentences, plain prose, no preamble."""
104
+ user = (
105
+ f"Question: {q}\n\n"
106
+ f"Cards already turned:\n{_prior_block(drawn, idx)}\n\n"
107
+ f"The card just turned — [{d['position']}] {d['concept']} ({d['orientation']})\n"
108
+ f" essence: {d.get('essence','')}\n"
109
+ f" meaning now: {d['meaning']}\n"
110
+ f" journey: this concept sits at {journey_note(d['arcana_number'])}.\n\n"
111
+ "Give the interpretation of this one card now."
112
+ )
113
+ return _strip_think(llm.complete(system, user, json_mode=False).strip())
114
+
115
+
116
+ def final_synthesis(deck: dict, question: str, drawn: list[dict],
117
+ partials: list[str] | None = None, llm: LLM | None = None) -> str:
118
+ """Weave all turned cards into one narrative + a closing verdict (§5)."""
119
+ llm = llm or get_llm()
120
+ theme = deck.get("theme", "this world")
121
+ q = question.strip() or "an open reading — whatever the cards wish to say"
122
+ spread_lines = []
123
+ for i, d in enumerate(drawn):
124
+ ess = f" — {d['essence']}" if d.get("essence") else ""
125
+ spread_lines.append(
126
+ f"- [{d['position']}] {d['concept']} ({d['orientation']}){ess}\n"
127
+ f" meaning now: {d['meaning']}")
128
+ prior = ("\n\nWhat you have said so far, card by card:\n" +
129
+ "\n".join(f" • {p}" for p in partials)) if partials else ""
130
+ system = f"""\
131
+ You are closing a tarot reading from a deck of concepts from {theme}. Your voice is
132
+ {_voice(deck)}.
133
+
134
+ Now weave ALL the cards into ONE coherent reading and a closing verdict that truly
135
+ answers the question. The craft is in collision — show how the cards modify one
136
+ another across their positions (not separate paragraphs), how the story they tell
137
+ together turns. Call each card by its concept name; never name classical tarot cards
138
+ or archetypes. Honour reversals. End with a clear, earned takeaway the querent can
139
+ hold. A few short paragraphs of flowing prose — no headings, no bullets."""
140
+ user = (f"Question: {q}\n\nThe full spread, in order:\n" +
141
+ "\n".join(spread_lines) + prior +
142
+ "\n\nNow give the final synthesis and verdict.")
143
+ return _strip_think(llm.complete(system, user, json_mode=False).strip())
arcana/styles.py ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """User-selectable visual styles (SPEC §8) — visual ONLY.
2
+
3
+ The user picks the deck's look at generation time. This drives the image-model
4
+ style suffix and nothing else; it does NOT add a custom reading voice (§5,
5
+ reading voice stays a single default). Each preset is just a stored suffix
6
+ string; "custom" passes the user's free text through. The compositor's frame
7
+ stays style-neutral in v1 (§9).
8
+
9
+ Guardrails ("no text, no border, cohesive palette…") are appended downstream in
10
+ imagegen.GLOBAL_STYLE, so suffixes here describe only the artistic look.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ DEFAULT_STYLE = "rider-waite-smith"
15
+
16
+ STYLE_SUFFIXES: dict[str, str] = {
17
+ "rider-waite-smith":
18
+ "Rider-Waite-Smith tarot art, rich symbolic illustration, warm storybook "
19
+ "palette, gold linework, medieval-renaissance figures",
20
+ "thoth":
21
+ "Thoth tarot art, dark esoteric and ornate, Art-Deco geometry, jewel "
22
+ "tones, dense occult symbolism, luminous",
23
+ "marseille":
24
+ "Tarot de Marseille art, flat woodcut print, austere bold black outlines, "
25
+ "primary red blue and yellow, medieval block-print",
26
+ "playful":
27
+ "bright modern playful pop illustration, bold flat colours, clean vector "
28
+ "shapes, whimsical and irreverent, high-contrast",
29
+ }
30
+
31
+ # label shown in the UI -> preset id (Custom handled separately)
32
+ STYLE_CHOICES: list[tuple[str, str]] = [
33
+ ("Rider-Waite-Smith", "rider-waite-smith"),
34
+ ("Thoth (dark, esoteric)", "thoth"),
35
+ ("Marseille (woodcut)", "marseille"),
36
+ ("Playful / Pop", "playful"),
37
+ ("Custom…", "custom"),
38
+ ]
39
+
40
+ # a tempting pre-fill so people discover the custom field (§8)
41
+ CUSTOM_PLACEHOLDER = "1920s scientific engraving"
42
+
43
+ # light esoteric tint for the reading voice, keyed by visual style (§5)
44
+ STYLE_READING_TINT: dict[str, str] = {
45
+ "thoth": "lean a little more esoteric and occult in your phrasing",
46
+ "marseille": "keep the phrasing plain, old and woodcut-stark",
47
+ "playful": "keep it lighter and more mischievous",
48
+ }
49
+
50
+
51
+ def resolve_style(visual_style: str, custom_text: str | None = None) -> tuple[str, str]:
52
+ """Return (style_id, style_suffix) for a chosen preset id or 'custom'.
53
+
54
+ 'custom' uses the user's free text (falling back to the placeholder); any
55
+ unknown id falls back to the default preset.
56
+ """
57
+ if visual_style == "custom":
58
+ text = (custom_text or "").strip() or CUSTOM_PLACEHOLDER
59
+ return "custom", text
60
+ if visual_style in STYLE_SUFFIXES:
61
+ return visual_style, STYLE_SUFFIXES[visual_style]
62
+ return DEFAULT_STYLE, STYLE_SUFFIXES[DEFAULT_STYLE]
63
+
64
+
65
+ def reading_tint(style_id: str) -> str:
66
+ return STYLE_READING_TINT.get(style_id, "")
assets/fonts/Cinzel.ttf ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:f4d83d34d1f6c741193e4acf4b3dff9531e5a67b6aa65228d00a7db72a4e0f34
3
+ size 125468
assets/fonts/EBGaramond.ttf ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:684fcc0db2239b43497341d596d68422ea60ef7522cf2e857071ad84e4ade2a4
3
+ size 851176
decks/breakfast_foods-playful-1780954994/00_the_banana.png ADDED

Git LFS Details

  • SHA256: 218bae8b4594ddd3e643a4188803404fb00e84a284f7737d7a581ac930718713
  • Pointer size: 131 Bytes
  • Size of remote file: 498 kB
decks/breakfast_foods-playful-1780954994/01_the_whisk.png ADDED

Git LFS Details

  • SHA256: 1d4cb619baf6bbf9c985639a1860c28d0c4eb25b150d76eda98dd57ba8b386d2
  • Pointer size: 131 Bytes
  • Size of remote file: 561 kB
decks/breakfast_foods-playful-1780954994/02_the_covered_dish.png ADDED

Git LFS Details

  • SHA256: 409d446096263ca00238467e39676d35d4184737e4662d19b0e416fa1e0473cd
  • Pointer size: 131 Bytes
  • Size of remote file: 711 kB
decks/breakfast_foods-playful-1780954994/03_the_stack_of_pancakes.png ADDED

Git LFS Details

  • SHA256: 2acd29766df2f39c9e7cd0ff99d09605e39a30566831c671099f48226a03dc79
  • Pointer size: 131 Bytes
  • Size of remote file: 456 kB
decks/breakfast_foods-playful-1780954994/04_black_coffee.png ADDED

Git LFS Details

  • SHA256: b1ded0b77f39da35bb45cbd07f72db2c60c33e70f53b45797d7f5a86cee78538
  • Pointer size: 131 Bytes
  • Size of remote file: 672 kB
decks/breakfast_foods-playful-1780954994/05_the_continental_breakfast.png ADDED

Git LFS Details

  • SHA256: d175921eb121aee30ca0765c4532634bb346141432b8e50437b691a9c7ce2d58
  • Pointer size: 131 Bytes
  • Size of remote file: 508 kB
decks/breakfast_foods-playful-1780954994/06_bacon_and_eggs.png ADDED

Git LFS Details

  • SHA256: e327c99b5235546d302ffc573c4d2ca9000e15113d9255f7f59af96d11a4320c
  • Pointer size: 131 Bytes
  • Size of remote file: 627 kB
decks/breakfast_foods-playful-1780954994/07_the_toaster.png ADDED

Git LFS Details

  • SHA256: e6b520705f39aa3e522328773c9b66ff918162c456f26829ba3810ef944fd8f4
  • Pointer size: 131 Bytes
  • Size of remote file: 458 kB
decks/breakfast_foods-playful-1780954994/08_oatmeal.png ADDED

Git LFS Details

  • SHA256: d3d18696b508894925985635fe05afe5c7b79d13afde39cbc114758921ad2b78
  • Pointer size: 131 Bytes
  • Size of remote file: 771 kB
decks/breakfast_foods-playful-1780954994/09_the_lone_boiled_egg.png ADDED

Git LFS Details

  • SHA256: a723e0e40a3ab0b24eadce9a0ccc6ad50c17124fb84106beed29d5302888121c
  • Pointer size: 131 Bytes
  • Size of remote file: 330 kB
decks/breakfast_foods-playful-1780954994/10_the_cereal_box_prize.png ADDED

Git LFS Details

  • SHA256: ebfd1c1ffcd2a355280d2e83939cf9de01f6c1fdfba987aa8a85f6c64c9d8f51
  • Pointer size: 131 Bytes
  • Size of remote file: 611 kB
decks/breakfast_foods-playful-1780954994/11_the_diner_check.png ADDED

Git LFS Details

  • SHA256: 21e763a4877aedc8be3db5589cc2fb983b10707d84bdcd537eb4e1e89892d83e
  • Pointer size: 131 Bytes
  • Size of remote file: 889 kB
decks/breakfast_foods-playful-1780954994/12_french_toast_soaking.png ADDED

Git LFS Details

  • SHA256: 7cd448fd4a598ff8d26a3d74a42348744842b74e6cd6ec6193fa2f8763383f05
  • Pointer size: 131 Bytes
  • Size of remote file: 418 kB
decks/breakfast_foods-playful-1780954994/13_the_burnt_toast.png ADDED

Git LFS Details

  • SHA256: 7bc51d524d7ce1626a170642e4665b4288a62535b0a62e5f5a6ae1cccf197142
  • Pointer size: 131 Bytes
  • Size of remote file: 695 kB
decks/breakfast_foods-playful-1780954994/14_the_smoothie.png ADDED

Git LFS Details

  • SHA256: 5957afbc5f99c42461b98d7662606eee0d5770e755eda1bf093727d0d72888af
  • Pointer size: 131 Bytes
  • Size of remote file: 322 kB
decks/breakfast_foods-playful-1780954994/15_the_sugary_cereal.png ADDED

Git LFS Details

  • SHA256: 81ae96c873e47f8c9766a8cc24290b999cf4c8cac704622a4e90f03a81ed4c4f
  • Pointer size: 131 Bytes
  • Size of remote file: 576 kB
decks/breakfast_foods-playful-1780954994/16_the_dropped_tray.png ADDED

Git LFS Details

  • SHA256: 51d29a06cbe51c55cf8f5bfa3d4a1ce4775d7f4daa0b1564e25b471fcb7b300d
  • Pointer size: 131 Bytes
  • Size of remote file: 525 kB
decks/breakfast_foods-playful-1780954994/17_fresh_squeezed_orange_juice.png ADDED

Git LFS Details

  • SHA256: bca6c7832eee106a4c7894d0132c45f8cd53df880f7220bb4260d2da1069628c
  • Pointer size: 131 Bytes
  • Size of remote file: 452 kB
decks/breakfast_foods-playful-1780954994/18_the_mystery_omelette.png ADDED

Git LFS Details

  • SHA256: fc810ffbfe51970ea42f0cf90b05528f29db7d4c4a600a869c64449f8dd5ab61
  • Pointer size: 131 Bytes
  • Size of remote file: 652 kB
decks/breakfast_foods-playful-1780954994/19_the_sunny_side_up_egg.png ADDED

Git LFS Details

  • SHA256: f93b77b85823b5ec8b086d07eb35ec0b43123e34f685e50bd1fb50f1e0eb6796
  • Pointer size: 131 Bytes
  • Size of remote file: 647 kB
decks/breakfast_foods-playful-1780954994/20_order_s_up.png ADDED

Git LFS Details

  • SHA256: bbfdd34c5710f30a011574c5ad68ed4e5abb836ff04ab399b4f01fb473b9297b
  • Pointer size: 131 Bytes
  • Size of remote file: 464 kB
decks/breakfast_foods-playful-1780954994/21_the_full_english.png ADDED

Git LFS Details

  • SHA256: 5bc7ca410eba06b80bdc5a6e0311ee789a9de8e126f3c6c0d36a9f73fcc52909
  • Pointer size: 131 Bytes
  • Size of remote file: 699 kB
decks/breakfast_foods-playful-1780954994/back.png ADDED

Git LFS Details

  • SHA256: f36697cfc2640d0a130b8f43e0de11822bfebb455ed046b506d85a975aab976d
  • Pointer size: 132 Bytes
  • Size of remote file: 1.38 MB
decks/breakfast_foods-playful-1780954994/deck.json ADDED
@@ -0,0 +1,274 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "theme": "breakfast foods",
3
+ "style_suffix": "bright modern playful pop illustration, bold flat colours, clean vector shapes, whimsical and irreverent, high-contrast",
4
+ "cards": [
5
+ {
6
+ "arcana_number": 0,
7
+ "arcana_name": "The Fool",
8
+ "concept": "The Banana",
9
+ "justification": "A fresh, ripe banana is a simple and innocent beginning, often accompanied by the risk of misstep or falling — a literal pratfall.",
10
+ "upright_meaning": "The Banana marks the start of something sweet and ripe — a moment of innocence and optimism, ripe with possibility and a touch of whimsy.",
11
+ "reversed_meaning": "A false start or a miscalculation — slipping on your own assumptions or rushing in too soon.",
12
+ "art_prompt": "a bright yellow banana hanging from a green stem, sunlight filtering through, with a small figure standing at the edge of a tree branch, one foot in the air",
13
+ "art_path": "decks/breakfast_foods-playful-1780954994/00_the_banana.png",
14
+ "essence": "Playful new beginnings with a hint of fragility.",
15
+ "seed": 871341
16
+ },
17
+ {
18
+ "arcana_number": 1,
19
+ "arcana_name": "The Magician",
20
+ "concept": "The Whisk",
21
+ "justification": "The whisk is a tool that transforms raw ingredients into something new through focused motion and energy.",
22
+ "upright_meaning": "The Whisk turns raw ingredients into something new — your will is stirring, blending, and bringing ideas to life with focused energy.",
23
+ "reversed_meaning": "A lack of momentum — spinning without progress, or mixing without purpose.",
24
+ "art_prompt": "a hand holding a whisk moving through a cloud of egg whites, which swirl and rise into a foamy tower",
25
+ "art_path": "decks/breakfast_foods-playful-1780954994/01_the_whisk.png",
26
+ "essence": "Manifesting through motion and blending.",
27
+ "seed": 871342
28
+ },
29
+ {
30
+ "arcana_number": 2,
31
+ "arcana_name": "The High Priestess",
32
+ "concept": "The Covered Dish",
33
+ "justification": "The covered dish holds a mystery — something hidden and waiting to be revealed, much like intuition.",
34
+ "upright_meaning": "The Covered Dish holds something hidden beneath its lid — trust in the secret forces at work, even when you cannot yet see them.",
35
+ "reversed_meaning": "Ignoring what lies beneath — closing the lid too tightly or refusing to look inside.",
36
+ "art_prompt": "a steaming covered casserole on a wooden table, the lid slightly ajar, revealing a golden, bubbly interior",
37
+ "art_path": "decks/breakfast_foods-playful-1780954994/02_the_covered_dish.png",
38
+ "essence": "Mystery beneath the surface.",
39
+ "seed": 871343
40
+ },
41
+ {
42
+ "arcana_number": 3,
43
+ "arcana_name": "The Empress",
44
+ "concept": "The Stack of Pancakes",
45
+ "justification": "Pancakes are nurturing and abundant, layered with care and richness, much like the Empress's nurturing abundance.",
46
+ "upright_meaning": "The Stack of Pancakes is a bountiful creation, rising from care and generosity — a gift of warmth and sustenance shared with others.",
47
+ "reversed_meaning": "Overload or neglect — too many layers without support, or a neglected pile that never rises.",
48
+ "art_prompt": "a warm stack of fluffy pancakes, syrup glistening on each layer, with a red cherry on top",
49
+ "art_path": "decks/breakfast_foods-playful-1780954994/03_the_stack_of_pancakes.png",
50
+ "essence": "Nurturing abundance, layer by layer.",
51
+ "seed": 871344
52
+ },
53
+ {
54
+ "arcana_number": 4,
55
+ "arcana_name": "The Emperor",
56
+ "concept": "Black Coffee",
57
+ "justification": "Black coffee is bitter and bold, symbolizing control and the structure of morning routine.",
58
+ "upright_meaning": "Black Coffee offers a bold clarity — a moment of strong authority, a decision made with focus and confidence.",
59
+ "reversed_meaning": "Rigid control leading to bitterness — or a stale routine that no longer fuels you.",
60
+ "art_prompt": "a steaming black coffee in a porcelain mug, sitting on a wooden tray with a clock and newspaper",
61
+ "art_path": "decks/breakfast_foods-playful-1780954994/04_black_coffee.png",
62
+ "essence": "Clarity through strength and structure.",
63
+ "seed": 871345
64
+ },
65
+ {
66
+ "arcana_number": 5,
67
+ "arcana_name": "The Hierophant",
68
+ "concept": "The Continental Breakfast",
69
+ "justification": "The continental breakfast is a standard, traditional offering served at hotels and cafes worldwide.",
70
+ "upright_meaning": "The Continental Breakfast invites you into a ritual of comfort and connection — finding your place within a shared experience.",
71
+ "reversed_meaning": "Feeling trapped by routine — eating out of habit rather than heart.",
72
+ "art_prompt": "a breakfast tray with bread, butter, jam, and a small glass of juice on a white cloth",
73
+ "art_path": "decks/breakfast_foods-playful-1780954994/05_the_continental_breakfast.png",
74
+ "essence": "Tradition served with a sense of place.",
75
+ "seed": 871346
76
+ },
77
+ {
78
+ "arcana_number": 6,
79
+ "arcana_name": "The Lovers",
80
+ "concept": "Bacon and Eggs",
81
+ "justification": "Bacon and eggs are a classic pair, often chosen together as a harmonious combination.",
82
+ "upright_meaning": "Bacon and Eggs show a deep harmony of parts — when two forces align to create something greater than themselves.",
83
+ "reversed_meaning": "A failed pairing — when components clash instead of complementing, or when one dominates the other.",
84
+ "art_prompt": "a sizzling pan with eggs and bacon side by side, their juices mingling in a golden pool",
85
+ "art_path": "decks/breakfast_foods-playful-1780954994/06_bacon_and_eggs.png",
86
+ "essence": "Complementary union.",
87
+ "seed": 871347
88
+ },
89
+ {
90
+ "arcana_number": 7,
91
+ "arcana_name": "The Chariot",
92
+ "concept": "The Toaster",
93
+ "justification": "The toaster channels heat to control the bread’s transformation into a desired, triumphant form — a chariot of morning energy.",
94
+ "upright_meaning": "The Toaster moves with precision — mastering time and temperature to achieve your goal with focus and fire.",
95
+ "reversed_meaning": "A failure to execute — undercooked intentions or a fire that burns too hot, too fast.",
96
+ "art_prompt": "a vintage toaster glowing red-hot, with golden slices of bread emerging on each side",
97
+ "art_path": "decks/breakfast_foods-playful-1780954994/07_the_toaster.png",
98
+ "essence": "Control through heat and momentum.",
99
+ "seed": 871348
100
+ },
101
+ {
102
+ "arcana_number": 8,
103
+ "arcana_name": "Strength",
104
+ "concept": "Oatmeal",
105
+ "justification": "Oatmeal provides quiet, inner strength and sustains through the morning with gentle power.",
106
+ "upright_meaning": "Oatmeal is the strength of steady warmth — the kind that nourishes without noise, and supports without strain.",
107
+ "reversed_meaning": "Drained energy or lack of foundation — feeling empty or brittle despite having rested.",
108
+ "art_prompt": "a bowl of steaming oatmeal with cinnamon sprinkled on top, held in a woman's hand",
109
+ "art_path": "decks/breakfast_foods-playful-1780954994/08_oatmeal.png",
110
+ "essence": "Strength in quiet endurance.",
111
+ "seed": 871349
112
+ },
113
+ {
114
+ "arcana_number": 9,
115
+ "arcana_name": "The Hermit",
116
+ "concept": "The Lone Boiled Egg",
117
+ "justification": "The boiled egg is self-contained and solitary, sitting in quiet reflection.",
118
+ "upright_meaning": "The Lone Boiled Egg offers a moment of stillness — a time to look inward, to listen to your own quiet voice.",
119
+ "reversed_meaning": "Loneliness without purpose — being alone but not at peace with yourself.",
120
+ "art_prompt": "a single boiled egg in a small porcelain cup, sitting on a windowsill with soft morning light",
121
+ "art_path": "decks/breakfast_foods-playful-1780954994/09_the_lone_boiled_egg.png",
122
+ "essence": "Introspection in a shell.",
123
+ "seed": 871350
124
+ },
125
+ {
126
+ "arcana_number": 10,
127
+ "arcana_name": "Wheel of Fortune",
128
+ "concept": "The Cereal-Box Prize",
129
+ "justification": "The cereal-box prize is a small but precious surprise — a moment of fate in a box.",
130
+ "upright_meaning": "The Cereal-Box Prize is a twist of fate — a small treasure from a larger game, a chance to be surprised by fortune.",
131
+ "reversed_meaning": "Missed opportunity — the prize is gone, or the box is empty just when you needed it.",
132
+ "art_prompt": "a child holding a cereal box with a toy peeking out from the top, eyes wide with wonder",
133
+ "art_path": "decks/breakfast_foods-playful-1780954994/10_the_cereal_box_prize.png",
134
+ "essence": "Luck in the box.",
135
+ "seed": 871351
136
+ },
137
+ {
138
+ "arcana_number": 11,
139
+ "arcana_name": "Justice",
140
+ "concept": "The Diner Check",
141
+ "justification": "The check at the diner represents the fair accounting of what was ordered and paid for.",
142
+ "upright_meaning": "The Diner Check reveals what you owe and what is owed to you — a moment of truth in exchange and expectation.",
143
+ "reversed_meaning": "Unbalanced transactions — being shortchanged, or failing to give what is fair.",
144
+ "art_prompt": "a waitress sliding a check across the diner counter, a pen in hand, waiting for signature",
145
+ "art_path": "decks/breakfast_foods-playful-1780954994/11_the_diner_check.png",
146
+ "essence": "Fair accounting at the table.",
147
+ "seed": 871352
148
+ },
149
+ {
150
+ "arcana_number": 12,
151
+ "arcana_name": "The Hanged Man",
152
+ "concept": "French Toast Soaking",
153
+ "justification": "French toast is suspended in custard, waiting for transformation — a state of suspension and acceptance.",
154
+ "upright_meaning": "French Toast Soaking invites you to let the old be absorbed — the slow transformation of self through patience and surrender.",
155
+ "reversed_meaning": "Stuck in waiting — refusing to let go, or waiting without purpose.",
156
+ "art_prompt": "a slice of bread soaking in a golden custard bath, slowly absorbing the liquid",
157
+ "art_path": "decks/breakfast_foods-playful-1780954994/12_french_toast_soaking.png",
158
+ "essence": "Soaking in transformation.",
159
+ "seed": 871353
160
+ },
161
+ {
162
+ "arcana_number": 13,
163
+ "arcana_name": "Death",
164
+ "concept": "The Burnt Toast",
165
+ "justification": "Burnt toast is an irreversible change — a transformation into something unusable.",
166
+ "upright_meaning": "The Burnt Toast signals a necessary end — something must be sacrificed to clear the path for what is to come.",
167
+ "reversed_meaning": "Clinging to the past — trying to revive what is already lost.",
168
+ "art_prompt": "a piece of burnt toast, curled at the edges, sitting in a cold plate of crumbs",
169
+ "art_path": "decks/breakfast_foods-playful-1780954994/13_the_burnt_toast.png",
170
+ "essence": "Ashes of a flame.",
171
+ "seed": 871354
172
+ },
173
+ {
174
+ "arcana_number": 14,
175
+ "arcana_name": "Temperance",
176
+ "concept": "The Smoothie",
177
+ "justification": "Smoothies blend contrasting flavors into a balanced and harmonious whole.",
178
+ "upright_meaning": "The Smoothie shows harmony in blend — when ingredients that seem incompatible unite into something both nourishing and refreshing.",
179
+ "reversed_meaning": "An imbalance of taste — too much of one flavor, or a drink that refuses to settle.",
180
+ "art_prompt": "a vibrant green smoothie in a glass, with a straw and a banana half hanging over the edge",
181
+ "art_path": "decks/breakfast_foods-playful-1780954994/14_the_smoothie.png",
182
+ "essence": "Balancing the flavors.",
183
+ "seed": 871355
184
+ },
185
+ {
186
+ "arcana_number": 15,
187
+ "arcana_name": "The Devil",
188
+ "concept": "The Sugary Cereal",
189
+ "justification": "Sugary cereal is addictive and hard to resist — a bondage to craving and indulgence.",
190
+ "upright_meaning": "The Sugary Cereal is a trap of sweetness — you are tied to what you crave, unable to break free from its grip.",
191
+ "reversed_meaning": "Breaking the spell — shaking free from unhealthy patterns and finding clarity.",
192
+ "art_prompt": "a bowl of colorful sugary cereal, the milk bubbling with cartoonish delight",
193
+ "art_path": "decks/breakfast_foods-playful-1780954994/15_the_sugary_cereal.png",
194
+ "essence": "Bound by craving.",
195
+ "seed": 871356
196
+ },
197
+ {
198
+ "arcana_number": 16,
199
+ "arcana_name": "The Tower",
200
+ "concept": "The Dropped Tray",
201
+ "justification": "A dropped tray is a sudden collapse — everything falls apart in a moment.",
202
+ "upright_meaning": "The Dropped Tray is chaos unleashed — the sudden undoing of what you thought was carefully arranged.",
203
+ "reversed_meaning": "Refusing to let go — holding on to broken pieces and pretending they still serve a purpose.",
204
+ "art_prompt": "a tray of food mid-fall, glasses and food spilling in slow motion across the floor",
205
+ "art_path": "decks/breakfast_foods-playful-1780954994/16_the_dropped_tray.png",
206
+ "essence": "Order overturned.",
207
+ "seed": 871357
208
+ },
209
+ {
210
+ "arcana_number": 17,
211
+ "arcana_name": "The Star",
212
+ "concept": "Fresh-Squeezed Orange Juice",
213
+ "justification": "Fresh orange juice brings hope and renewal — a burst of vitality in the morning.",
214
+ "upright_meaning": "Fresh-Squeezed Orange Juice brings a burst of hope — the kind that lifts your spirit and restores your energy.",
215
+ "reversed_meaning": "Bitter disappointment — the promise of light turned sour.",
216
+ "art_prompt": "a glass of fresh-squeezed orange juice, sunlight streaming in, with drops of juice dripping from the orange",
217
+ "art_path": "decks/breakfast_foods-playful-1780954994/17_fresh_squeezed_orange_juice.png",
218
+ "essence": "Light in the glass.",
219
+ "seed": 871358
220
+ },
221
+ {
222
+ "arcana_number": 18,
223
+ "arcana_name": "The Moon",
224
+ "concept": "The Mystery Omelette",
225
+ "justification": "The omelette is made with unknown ingredients — a symbol of uncertainty and fear.",
226
+ "upright_meaning": "The Mystery Omelette offers uncertainty — you are cooking with unknowns, trusting the process even if the result is unclear.",
227
+ "reversed_meaning": "Truth revealed — the veil is lifted, and the contents are known, even if they are unsettling.",
228
+ "art_prompt": "a waiter holding a silver dome over a plate, the omelette hidden beneath, eyes squinting in curiosity",
229
+ "art_path": "decks/breakfast_foods-playful-1780954994/18_the_mystery_omelette.png",
230
+ "essence": "Mystery in the pan.",
231
+ "seed": 871359
232
+ },
233
+ {
234
+ "arcana_number": 19,
235
+ "arcana_name": "The Sun",
236
+ "concept": "The Sunny-Side-Up Egg",
237
+ "justification": "The Sunny-Side-Up Egg is a literal sun — bright, joyous, and full of vitality.",
238
+ "upright_meaning": "The Sunny-Side-Up Egg is a symbol of clear success — your efforts have risen, and the future looks golden and unbroken.",
239
+ "reversed_meaning": "A cracked promise — the yolk breaks, and the optimism fades.",
240
+ "art_prompt": "a sunny-side-up egg on a white plate, surrounded by herbs and sunlight",
241
+ "art_path": "decks/breakfast_foods-playful-1780954994/19_the_sunny_side_up_egg.png",
242
+ "essence": "Sun in the bowl.",
243
+ "seed": 871360
244
+ },
245
+ {
246
+ "arcana_number": 20,
247
+ "arcana_name": "Judgement",
248
+ "concept": "\"Order's Up!\"",
249
+ "justification": "The breakfast bell is the call to judgment — you are summoned to receive the fruit of your labor.",
250
+ "upright_meaning": "Order's Up is a summons to action — the time has come to respond, to wake up or take responsibility.",
251
+ "reversed_meaning": "Ignoring the call — pretending you don’t hear it or that it isn’t for you.",
252
+ "art_prompt": "a diner bell hanging from a wooden counter, with a chef holding a tray, calling out to the customer",
253
+ "art_path": "decks/breakfast_foods-playful-1780954994/20_order_s_up.png",
254
+ "essence": "The call from the kitchen.",
255
+ "seed": 871361
256
+ },
257
+ {
258
+ "arcana_number": 21,
259
+ "arcana_name": "The World",
260
+ "concept": "The Full English",
261
+ "justification": "The full English is a complete and integrated breakfast — everything on the plate working together in harmony.",
262
+ "upright_meaning": "The Full English is the gathering of all parts — a satisfying, whole meal that shows integration and fulfillment.",
263
+ "reversed_meaning": "Incompletion or imbalance — missing ingredients, or a meal that fails to come together.",
264
+ "art_prompt": "a plate containing bacon, eggs, sausages, beans, toast, and mushrooms, all perfectly arranged",
265
+ "art_path": "decks/breakfast_foods-playful-1780954994/21_the_full_english.png",
266
+ "essence": "Completion on the plate.",
267
+ "seed": 871362
268
+ }
269
+ ],
270
+ "deck_id": "breakfast_foods-playful-1780954994",
271
+ "visual_style": "playful",
272
+ "seed_base": 871341,
273
+ "back_path": "decks/breakfast_foods-playful-1780954994/back.png"
274
+ }
decks/lord_of_the_rings-bundled/00_frodo_baggins.png ADDED

Git LFS Details

  • SHA256: fb9d9975ea23c4fbe1a60db3c9e7600ebd553e3a203c3e688cc8323b34874ab4
  • Pointer size: 132 Bytes
  • Size of remote file: 1.43 MB
decks/lord_of_the_rings-bundled/01_gandalf_the_grey.png ADDED

Git LFS Details

  • SHA256: 83cae7fc315f3c6ff492aa051edf5e4416f1ba09a25a495c05323b16d4f98c61
  • Pointer size: 132 Bytes
  • Size of remote file: 1.54 MB
decks/lord_of_the_rings-bundled/02_the_mirror_of_galadriel.png ADDED

Git LFS Details

  • SHA256: 53403ebd5ad2073c0f65dab76943ae8635e6b10a4c3ad6c92c07d9d9f565499b
  • Pointer size: 132 Bytes
  • Size of remote file: 1.68 MB
decks/lord_of_the_rings-bundled/03_lothlórien.png ADDED

Git LFS Details

  • SHA256: a639ef360c852e4a4161166fb97e1dae8bb15620171322a69783f1637cd479ef
  • Pointer size: 132 Bytes
  • Size of remote file: 1.37 MB
decks/lord_of_the_rings-bundled/04_théoden_king.png ADDED

Git LFS Details

  • SHA256: d1e79a40eeed65361b2c11c09b2ae3d1401c90c6a1dee7aebc64b2f0fa5bc7c8
  • Pointer size: 131 Bytes
  • Size of remote file: 670 kB
decks/lord_of_the_rings-bundled/05_the_council_of_elrond.png ADDED

Git LFS Details

  • SHA256: 4e94e3facf1dff221db8b4fd9cd46f9d7d640b2f59d4bf5853719d8b109a2904
  • Pointer size: 132 Bytes
  • Size of remote file: 1.5 MB
decks/lord_of_the_rings-bundled/06_aragorn_and_arwen.png ADDED

Git LFS Details

  • SHA256: 28b6200e8c3d3966f205da2f57ec8e34746c81d4d31a0ff1fb83cacfe8bb4f44
  • Pointer size: 132 Bytes
  • Size of remote file: 1.07 MB
decks/lord_of_the_rings-bundled/07_the_battle_of_pelennor_f.png ADDED

Git LFS Details

  • SHA256: 75c16677ec4f3e142ec34d1261964ba94118506d5b99f2de4f53d13651bf0355
  • Pointer size: 132 Bytes
  • Size of remote file: 1.63 MB