Arcana v1: themed concept-tarot generator + progressive reader (3 demo decks)
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .gitattributes +71 -0
- .gitignore +8 -0
- README.md +77 -5
- app.py +394 -0
- arcana/__init__.py +0 -0
- arcana/archetypes.py +60 -0
- arcana/build.py +142 -0
- arcana/compositor.py +81 -0
- arcana/designer.py +170 -0
- arcana/frame.py +79 -0
- arcana/imagegen.py +106 -0
- arcana/llm.py +115 -0
- arcana/loremaster.py +111 -0
- arcana/prompts.py +137 -0
- arcana/reader.py +143 -0
- arcana/styles.py +66 -0
- assets/fonts/Cinzel.ttf +3 -0
- assets/fonts/EBGaramond.ttf +3 -0
- decks/breakfast_foods-playful-1780954994/00_the_banana.png +3 -0
- decks/breakfast_foods-playful-1780954994/01_the_whisk.png +3 -0
- decks/breakfast_foods-playful-1780954994/02_the_covered_dish.png +3 -0
- decks/breakfast_foods-playful-1780954994/03_the_stack_of_pancakes.png +3 -0
- decks/breakfast_foods-playful-1780954994/04_black_coffee.png +3 -0
- decks/breakfast_foods-playful-1780954994/05_the_continental_breakfast.png +3 -0
- decks/breakfast_foods-playful-1780954994/06_bacon_and_eggs.png +3 -0
- decks/breakfast_foods-playful-1780954994/07_the_toaster.png +3 -0
- decks/breakfast_foods-playful-1780954994/08_oatmeal.png +3 -0
- decks/breakfast_foods-playful-1780954994/09_the_lone_boiled_egg.png +3 -0
- decks/breakfast_foods-playful-1780954994/10_the_cereal_box_prize.png +3 -0
- decks/breakfast_foods-playful-1780954994/11_the_diner_check.png +3 -0
- decks/breakfast_foods-playful-1780954994/12_french_toast_soaking.png +3 -0
- decks/breakfast_foods-playful-1780954994/13_the_burnt_toast.png +3 -0
- decks/breakfast_foods-playful-1780954994/14_the_smoothie.png +3 -0
- decks/breakfast_foods-playful-1780954994/15_the_sugary_cereal.png +3 -0
- decks/breakfast_foods-playful-1780954994/16_the_dropped_tray.png +3 -0
- decks/breakfast_foods-playful-1780954994/17_fresh_squeezed_orange_juice.png +3 -0
- decks/breakfast_foods-playful-1780954994/18_the_mystery_omelette.png +3 -0
- decks/breakfast_foods-playful-1780954994/19_the_sunny_side_up_egg.png +3 -0
- decks/breakfast_foods-playful-1780954994/20_order_s_up.png +3 -0
- decks/breakfast_foods-playful-1780954994/21_the_full_english.png +3 -0
- decks/breakfast_foods-playful-1780954994/back.png +3 -0
- decks/breakfast_foods-playful-1780954994/deck.json +274 -0
- decks/lord_of_the_rings-bundled/00_frodo_baggins.png +3 -0
- decks/lord_of_the_rings-bundled/01_gandalf_the_grey.png +3 -0
- decks/lord_of_the_rings-bundled/02_the_mirror_of_galadriel.png +3 -0
- decks/lord_of_the_rings-bundled/03_lothlórien.png +3 -0
- decks/lord_of_the_rings-bundled/04_théoden_king.png +3 -0
- decks/lord_of_the_rings-bundled/05_the_council_of_elrond.png +3 -0
- decks/lord_of_the_rings-bundled/06_aragorn_and_arwen.png +3 -0
- 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:
|
| 6 |
sdk: gradio
|
| 7 |
-
sdk_version: 6.
|
| 8 |
-
python_version: '3.13'
|
| 9 |
app_file: app.py
|
| 10 |
pinned: false
|
|
|
|
| 11 |
---
|
| 12 |
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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'> </span><span class='concept'> </span><span class='orient'> </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
|
decks/breakfast_foods-playful-1780954994/01_the_whisk.png
ADDED
|
Git LFS Details
|
decks/breakfast_foods-playful-1780954994/02_the_covered_dish.png
ADDED
|
Git LFS Details
|
decks/breakfast_foods-playful-1780954994/03_the_stack_of_pancakes.png
ADDED
|
Git LFS Details
|
decks/breakfast_foods-playful-1780954994/04_black_coffee.png
ADDED
|
Git LFS Details
|
decks/breakfast_foods-playful-1780954994/05_the_continental_breakfast.png
ADDED
|
Git LFS Details
|
decks/breakfast_foods-playful-1780954994/06_bacon_and_eggs.png
ADDED
|
Git LFS Details
|
decks/breakfast_foods-playful-1780954994/07_the_toaster.png
ADDED
|
Git LFS Details
|
decks/breakfast_foods-playful-1780954994/08_oatmeal.png
ADDED
|
Git LFS Details
|
decks/breakfast_foods-playful-1780954994/09_the_lone_boiled_egg.png
ADDED
|
Git LFS Details
|
decks/breakfast_foods-playful-1780954994/10_the_cereal_box_prize.png
ADDED
|
Git LFS Details
|
decks/breakfast_foods-playful-1780954994/11_the_diner_check.png
ADDED
|
Git LFS Details
|
decks/breakfast_foods-playful-1780954994/12_french_toast_soaking.png
ADDED
|
Git LFS Details
|
decks/breakfast_foods-playful-1780954994/13_the_burnt_toast.png
ADDED
|
Git LFS Details
|
decks/breakfast_foods-playful-1780954994/14_the_smoothie.png
ADDED
|
Git LFS Details
|
decks/breakfast_foods-playful-1780954994/15_the_sugary_cereal.png
ADDED
|
Git LFS Details
|
decks/breakfast_foods-playful-1780954994/16_the_dropped_tray.png
ADDED
|
Git LFS Details
|
decks/breakfast_foods-playful-1780954994/17_fresh_squeezed_orange_juice.png
ADDED
|
Git LFS Details
|
decks/breakfast_foods-playful-1780954994/18_the_mystery_omelette.png
ADDED
|
Git LFS Details
|
decks/breakfast_foods-playful-1780954994/19_the_sunny_side_up_egg.png
ADDED
|
Git LFS Details
|
decks/breakfast_foods-playful-1780954994/20_order_s_up.png
ADDED
|
Git LFS Details
|
decks/breakfast_foods-playful-1780954994/21_the_full_english.png
ADDED
|
Git LFS Details
|
decks/breakfast_foods-playful-1780954994/back.png
ADDED
|
Git LFS Details
|
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
|
decks/lord_of_the_rings-bundled/01_gandalf_the_grey.png
ADDED
|
Git LFS Details
|
decks/lord_of_the_rings-bundled/02_the_mirror_of_galadriel.png
ADDED
|
Git LFS Details
|
decks/lord_of_the_rings-bundled/03_lothlórien.png
ADDED
|
Git LFS Details
|
decks/lord_of_the_rings-bundled/04_théoden_king.png
ADDED
|
Git LFS Details
|
decks/lord_of_the_rings-bundled/05_the_council_of_elrond.png
ADDED
|
Git LFS Details
|
decks/lord_of_the_rings-bundled/06_aragorn_and_arwen.png
ADDED
|
Git LFS Details
|
decks/lord_of_the_rings-bundled/07_the_battle_of_pelennor_f.png
ADDED
|
Git LFS Details
|