docs(codex): publish Build Small submission package

#4
by thangvip - opened
README.md CHANGED
@@ -10,43 +10,160 @@ app_file: app.py
10
  fullWidth: true
11
  header: mini
12
  pinned: true
13
- short_description: Walk through a watercolor path of grounded encouragement.
 
14
  models:
15
  - build-small-hackathon/compliment-forest-minicpm5-1b
16
  - build-small-hackathon/compliment-forest-flux-lora
 
 
 
 
 
 
17
  datasets:
18
  - build-small-hackathon/compliment-forest-sft
19
  - build-small-hackathon/compliment-forest-watercolor
20
  - build-small-hackathon/compliment-forest-traces
 
21
  tags:
22
  - gradio
23
  - build-small-hackathon
24
- - local-first
25
- - watercolor
 
26
  - llama.cpp
 
 
 
 
 
 
 
 
 
27
  ---
28
 
29
  # The Compliment Forest
30
 
31
- Type a name and a situation, then walk through a progressive watercolor path.
32
- Each clearing pairs a creature with grounded encouragement, a reflection, and a
33
- copyable tiny spell.
 
34
 
35
- The live Space uses the deterministic local demo backend so it remains fast and
36
- available on CPU hardware. The same application supports the published
37
- MiniCPM5-1B GGUF through a local `llama.cpp` server and FLUX.1-dev with the
38
- published watercolor LoRA by setting `CF_TEXT_BACKEND=llama_cpp` and
39
- `CF_IMAGE_BACKEND=flux`. No hosted inference API is called at runtime.
40
 
41
- ## Published artifacts
42
 
43
- - Text model: `build-small-hackathon/compliment-forest-minicpm5-1b`
44
- - Text adapter: `build-small-hackathon/compliment-forest-minicpm5-1b-lora`
45
- - Text SFT data: `build-small-hackathon/compliment-forest-sft`
46
- - Watercolor LoRA: `build-small-hackathon/compliment-forest-flux-lora`
47
- - Watercolor data: `build-small-hackathon/compliment-forest-watercolor`
48
- - Linked-model traces: `build-small-hackathon/compliment-forest-traces`
49
 
50
- This is whimsical encouragement, not therapy or a substitute for professional
51
- support. Crisis and acute-risk inputs are routed to human support instead of
52
- generating a forest.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
  fullWidth: true
11
  header: mini
12
  pinned: true
13
+ license: apache-2.0
14
+ short_description: Turn a worry into a grounded, illustrated path forward.
15
  models:
16
  - build-small-hackathon/compliment-forest-minicpm5-1b
17
  - build-small-hackathon/compliment-forest-flux-lora
18
+ - openbmb/MiniCPM4.1-8B
19
+ - black-forest-labs/FLUX.1-schnell
20
+ - thangvip/compliment-forest-watercolor-flux-lora-v2
21
+ - thangvip/compliment-forest-paper-cut-flux-lora-v2
22
+ - thangvip/compliment-forest-moonlit-gouache-flux-lora-v2
23
+ - thangvip/compliment-forest-botanical-ink-flux-lora-v2
24
  datasets:
25
  - build-small-hackathon/compliment-forest-sft
26
  - build-small-hackathon/compliment-forest-watercolor
27
  - build-small-hackathon/compliment-forest-traces
28
+ - thangvip/compliment-forest-multistyle-v2
29
  tags:
30
  - gradio
31
  - build-small-hackathon
32
+ - minicpm
33
+ - modal
34
+ - text-to-image
35
  - llama.cpp
36
+ - track:backyard
37
+ - sponsor:openbmb
38
+ - sponsor:openai
39
+ - sponsor:modal
40
+ - achievement:welltuned
41
+ - achievement:offbrand
42
+ - achievement:llama
43
+ - achievement:sharing
44
+ - achievement:fieldnotes
45
  ---
46
 
47
  # The Compliment Forest
48
 
49
+ The Compliment Forest turns a worry into a five-chapter illustrated walk. It
50
+ asks five adaptive questions, separates facts from fearful predictions, offers
51
+ realistic options, suggests one small action, and ends with a simple plan the
52
+ visitor can carry back into the day.
53
 
54
+ This is whimsical encouragement, not therapy or a substitute for professional
55
+ support. Crisis and acute-risk inputs stop before model generation and direct
56
+ the visitor toward human help.
 
 
57
 
58
+ ## Backyard AI: The Real Problem
59
 
60
+ The Compliment Forest is built for the **Backyard AI** track. It addresses an
61
+ everyday problem in modern society: people carry worries about test results,
62
+ changing jobs, belonging, comparison, and an uncertain future, but the support
63
+ they receive is often vague or disconnected from what actually happened.
 
 
64
 
65
+ The product gives a person a private place to explain one real concern in their
66
+ own words. It asks what feels at stake, separates known facts from fearful
67
+ predictions, and turns the conversation into understandable options and one
68
+ small action. The illustrated forest makes that difficult reflection feel less
69
+ clinical and easier to approach, while the practical content remains grounded
70
+ in the person's situation.
71
+
72
+ ## Try It
73
+
74
+ - Hackathon Space:
75
+ https://huggingface.co/spaces/build-small-hackathon/compliment-forest
76
+ - Build article:
77
+ [Growing the Compliment Forest](docs/build-small-hackathon-article.md)
78
+ - Demo script: [85-95 second recording plan](docs/demo-script.md)
79
+ - Demo video: recording script is ready; public video link will be added here
80
+ - Social post: publish-ready copy is ready; public post link will be added here
81
+
82
+ ## Why It Is AI-Native
83
+
84
+ A fixed template cannot know whether a low test score hurts because of identity,
85
+ comparison, uncertainty, or a specific learning gap. The forest uses an
86
+ adaptive intake and a planner-author-critic pipeline to build a different path
87
+ for each visitor.
88
+
89
+ The five roles have distinct jobs:
90
+
91
+ 1. `arrive` acknowledges the feeling and concrete concern once.
92
+ 2. `steady` separates known facts from the outcome fear predicts.
93
+ 3. `widen` offers realistic interpretations or options.
94
+ 4. `step` gives one small, optional, low-risk action.
95
+ 5. `carry` leaves a simple plan or decision rule.
96
+
97
+ Local validators reject repeated prose, repeated long source phrases, invented
98
+ dates or actions, unsupported biography, stock abstraction, and a `step`
99
+ chapter without practical help. Failed chapters are regenerated selectively.
100
+ If repair still fails, the app tries one fresh forest and then returns an honest
101
+ error instead of canned encouragement.
102
+
103
+ ## Small-Model Stack
104
+
105
+ The live text and image stack is about 25B parameters in total, below the
106
+ hackathon's 32B total limit.
107
+
108
+ - **Text:** `openbmb/MiniCPM4.1-8B`, hosted on a Modal A100 endpoint.
109
+ - **Images:** `black-forest-labs/FLUX.1-schnell` with four rank-16 style LoRAs,
110
+ hosted on a separate Modal A100 80GB endpoint.
111
+ - **Local path:** the published 1.08B MiniCPM5 fine-tune is available as a
112
+ Q4_K_M GGUF through `llama.cpp`.
113
+ - **Training:** the MiniCPM and FLUX adapters, validation runs, and deployment
114
+ experiments used Modal.
115
+
116
+ Text and image inference scale independently. The canonical hackathon Space
117
+ serves the custom interface and streams NDJSON progress. Because organization
118
+ members cannot manage that Space's secrets, it forwards generation requests to
119
+ the owner-controlled CPU orchestrator, which HMAC-signs calls to the two Modal
120
+ services. No credential is stored in the public repository.
121
+
122
+ ## Published Artifacts
123
+
124
+ - [MiniCPM5-1B fine-tune](https://huggingface.co/build-small-hackathon/compliment-forest-minicpm5-1b)
125
+ - [MiniCPM text adapter](https://huggingface.co/build-small-hackathon/compliment-forest-minicpm5-1b-lora)
126
+ - [Text SFT dataset](https://huggingface.co/datasets/build-small-hackathon/compliment-forest-sft)
127
+ - [Watercolor FLUX LoRA](https://huggingface.co/build-small-hackathon/compliment-forest-flux-lora)
128
+ - [Watercolor dataset](https://huggingface.co/datasets/build-small-hackathon/compliment-forest-watercolor)
129
+ - [Sanitized linked-model traces](https://huggingface.co/datasets/build-small-hackathon/compliment-forest-traces)
130
+ - [Multi-style dataset](https://huggingface.co/datasets/thangvip/compliment-forest-multistyle-v2)
131
+
132
+ ## Sponsor Work
133
+
134
+ **OpenBMB:** MiniCPM is the core language model family for planning, authoring,
135
+ critique, adaptive intake, and the published local fine-tune.
136
+
137
+ **Modal:** Modal powered text and image inference, LoRA training, GGUF
138
+ validation, and the independently scaling GPU endpoints used by the live app.
139
+
140
+ **OpenAI Codex:** Codex was used throughout implementation and debugging:
141
+ reading the codebase, writing tests, tracing malformed structured output,
142
+ redesigning the prompt and quality gates, deploying Space revisions, and
143
+ verifying full live flows.
144
+
145
+ ## Run Locally
146
+
147
+ ```bash
148
+ uv sync --extra dev
149
+ uv run python app.py
150
+ ```
151
+
152
+ The default local backend is deterministic. To run the published local text
153
+ model through `llama.cpp`:
154
+
155
+ ```bash
156
+ CF_TEXT_BACKEND=llama_cpp
157
+ CF_IMAGE_BACKEND=flux
158
+ uv run --extra inference python app.py
159
+ ```
160
+
161
+ ## Verification
162
+
163
+ ```bash
164
+ uv run pytest -q \
165
+ --ignore=tests/test_build_multistyle_dataset.py \
166
+ --ignore=tests/test_dataset_builder.py
167
+ ```
168
+
169
+ Current maintained result: **155 passed**.
app.py CHANGED
@@ -1,3 +1,4 @@
 
1
  import sys
2
  from pathlib import Path
3
 
@@ -6,9 +7,8 @@ sys.path.insert(0, str(Path(__file__).resolve().parent / "src"))
6
  from compliment_forest.server import create_app
7
 
8
  app = create_app()
9
- demo = app
10
 
11
- if __name__ == "__main__":
12
- import uvicorn
13
-
14
- uvicorn.run(app, host="0.0.0.0", port=7860)
 
1
+ import os
2
  import sys
3
  from pathlib import Path
4
 
 
7
  from compliment_forest.server import create_app
8
 
9
  app = create_app()
 
10
 
11
+ if os.getenv("SPACE_ID"):
12
+ app.launch(show_error=True)
13
+ elif __name__ == "__main__":
14
+ app.launch(server_name="0.0.0.0", server_port=7860)
docs/build-small-hackathon-article.md ADDED
@@ -0,0 +1,215 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Growing the Compliment Forest: Small Models, Honest Encouragement, and Five Clearings
2
+
3
+ Most AI encouragement tools make the same mistake: they become vague exactly
4
+ when the user needs something concrete.
5
+
6
+ Someone writes, "I worry about my test score," and receives a polished cloud of
7
+ phrases about believing in themselves, trusting the journey, or keeping their
8
+ own pace. The words are kind, but they do not help the person understand the
9
+ worry or decide what to do next.
10
+
11
+ These are ordinary problems in modern society. Students can feel that one score
12
+ defines their intelligence. Workers can feel trapped between an unhealthy job
13
+ and fear of an uncertain search. Social comparison can turn one difficult
14
+ moment into a judgment about an entire future.
15
+
16
+ The Compliment Forest began with a different question:
17
+
18
+ **Can a small model help someone understand one real worry and choose a useful
19
+ next step without becoming generic or pretending to be a therapist?**
20
+
21
+ The result is a Gradio application that turns a worry into a five-chapter,
22
+ illustrated walk. It is whimsical on the surface, but its generation pipeline
23
+ is deliberately strict underneath.
24
+
25
+ ## Why This Is Backyard AI
26
+
27
+ We built The Compliment Forest for the **Backyard AI** track because it focuses
28
+ on a common human problem close to home: people often need help making sense of
29
+ school pressure, work uncertainty, belonging, comparison, or fear about what
30
+ comes next. They may not need a grand solution. They need to feel understood,
31
+ separate evidence from prediction, see realistic choices, and identify one
32
+ manageable action.
33
+
34
+ Generic reassurance does not solve that problem. Telling someone to believe in
35
+ themselves may sound warm, but it does not help them decide whether to review a
36
+ missed test question, identify a knowledge gap, ask for clearer expectations,
37
+ or gather more information before making a job decision.
38
+
39
+ The forest is designed to make that support easier to approach. The visual
40
+ journey lowers the emotional barrier to reflection, while the model pipeline
41
+ keeps the result tied to the person's own words. It does not promise that the
42
+ worry will disappear. It helps the person leave with a clearer understanding
43
+ and a small next move.
44
+
45
+ ## The Experience
46
+
47
+ The visitor starts with a name and one sentence about what is troubling them.
48
+ The forest then asks five adaptive multiple-choice questions. These questions
49
+ stay focused on the actual problem:
50
+
51
+ - What triggered the worry?
52
+ - What feels most at stake?
53
+ - When is it harder or easier?
54
+ - What support or information would help?
55
+ - What would count as a small win?
56
+
57
+ After the visitor chooses an image style, the application generates five
58
+ clearings:
59
+
60
+ 1. **Arrive:** acknowledge the feeling and concern.
61
+ 2. **Steady:** separate facts from the outcome fear predicts.
62
+ 3. **Widen:** offer realistic explanations or options.
63
+ 4. **Step:** suggest one small, optional action.
64
+ 5. **Carry:** leave a simple plan or rule to remember.
65
+
66
+ Each clearing includes a scene, short narration, reflection, mantra, and a
67
+ fresh illustration. The browser reveals them progressively rather than showing
68
+ a wall of generated text.
69
+
70
+ ## Why a Planner-Author-Critic Pipeline?
71
+
72
+ Free-form generation was not reliable enough for a sensitive experience.
73
+ Larger prompts produced warmer prose, but they also encouraged plausible
74
+ inventions: interviews the user never attended, applications they never sent,
75
+ dates they never mentioned, or actions they never completed.
76
+
77
+ The application therefore divides text generation into roles.
78
+
79
+ The **planner** creates a conservative evidence plan. Every fact anchor must
80
+ copy an exact phrase from the user's situation. A fear remains an uncertainty;
81
+ it cannot silently become a fact.
82
+
83
+ The **author** writes the five-chapter forest from that validated plan.
84
+
85
+ The **critic** identifies chapters that are repetitive, unsupported, generic,
86
+ or structurally weak.
87
+
88
+ Python validators then enforce constraints that should not be delegated to
89
+ prose judgment:
90
+
91
+ - source phrases must occur in the user's input;
92
+ - generated numbers and dates must be supported;
93
+ - completed actions and biography cannot be invented;
94
+ - long user sentences may be echoed only once;
95
+ - clearings cannot substantially repeat one another;
96
+ - stock abstract language is rejected;
97
+ - the `step` clearing must contain practical help.
98
+
99
+ When a chapter fails, the author rewrites only that chapter. Valid chapters are
100
+ preserved exactly. If targeted repair still fails, the application requests one
101
+ fresh full forest. If that also fails, it returns an honest error before image
102
+ generation. It never replaces the result with canned encouragement.
103
+
104
+ That last decision came from a real failure. An earlier safety fallback always
105
+ returned five valid chapters, but every chapter repeated the user's sentence
106
+ and surrounded it with abstract language. It looked polished and passed the
107
+ schema, yet it failed the person. Removing that fallback made the system more
108
+ honest and ultimately more useful.
109
+
110
+ ## Small Models, Different Jobs
111
+
112
+ The live text path uses `openbmb/MiniCPM4.1-8B`. MiniCPM handles adaptive
113
+ intake, evidence planning, authoring, and critique. Together with the roughly
114
+ 17B-parameter FLUX image stack, the live application is about 25B parameters in
115
+ total and stays below the hackathon's 32B total cap.
116
+
117
+ The project also publishes a 1.08B MiniCPM5 fine-tune trained on 1,500
118
+ schema-validated examples. It was converted to a 688 MB Q4_K_M GGUF and
119
+ smoke-tested with `llama.cpp`. That local path remains part of the same
120
+ application for reproducible, off-grid experiments.
121
+
122
+ Images use `FLUX.1-schnell` with four rank-16 LoRA adapters:
123
+
124
+ - Watercolor Storybook
125
+ - Layered Paper Cut
126
+ - Moonlit Gouache
127
+ - Botanical Ink Wash
128
+
129
+ The multi-style dataset contains 160 generated examples balanced across
130
+ animals, people, symbolic objects, and environments. Balancing subjects was
131
+ important. The first dataset changed style successfully but produced too many
132
+ animals, so the visual variety felt smaller than the style menu suggested.
133
+
134
+ ## Modal as the GPU Layer
135
+
136
+ The canonical organization Space serves the custom interface and streams the
137
+ API response. Since organization members cannot manage its secrets, it forwards
138
+ generation requests to an owner-controlled CPU Space that holds the HMAC
139
+ credential. Text and image workloads then run on separate Modal applications:
140
+
141
+ - MiniCPM4.1-8B on an A100 40GB endpoint
142
+ - FLUX.1-schnell plus the four style adapters on an A100 80GB endpoint
143
+
144
+ This separation matters. Text planning and image rendering have different
145
+ memory and scaling behavior. Keeping them in separate containers prevents one
146
+ model from evicting the other and lets each service scale to zero
147
+ independently. The public repository contains no credentials.
148
+
149
+ Modal was also used for adapter training, validation grids, GGUF smoke tests,
150
+ and deployment experiments. The runtime bridge signs requests with HMAC, and
151
+ the organization Space preserves the NDJSON stream so a long generation
152
+ remains visibly alive through both Hugging Face hops.
153
+
154
+ ## Codex as an Engineering Partner
155
+
156
+ OpenAI Codex was used across the project rather than for one isolated code
157
+ generation step.
158
+
159
+ It read the architecture and handoff notes, traced production errors across the
160
+ Space and Modal boundaries, wrote regression tests before fixes, strengthened
161
+ JSON parsing, redesigned prompt contracts, calibrated deterministic quality
162
+ checks, deployed Space revisions, and exercised full live user flows.
163
+
164
+ The most useful Codex work was not producing more code. It was preserving the
165
+ discipline to find root causes. A malformed critic response, a repeated intake
166
+ question, an incomplete planner object, and a five-role survivor failure looked
167
+ like separate bugs. Following their data flow showed a shared issue: strict
168
+ model contracts need bounded repair, precise diagnostics, and deterministic
169
+ validation at the right boundary.
170
+
171
+ ## Safety and Privacy
172
+
173
+ The Compliment Forest is not therapy. A guard stops crisis, self-harm, abuse,
174
+ and acute medical inputs before model calls and provides a human-support
175
+ message.
176
+
177
+ Public traces use fictional scenarios. Identity, situation text, secrets,
178
+ tokens, and image payloads are not published. The trace dataset records the
179
+ shape of planner-author-critic handoffs so others can inspect the architecture
180
+ without exposing a visitor's private worry.
181
+
182
+ ## What I Learned
183
+
184
+ **A schema is necessary, but not sufficient.** Perfect JSON can still contain
185
+ bad help.
186
+
187
+ **Concrete does not mean invented.** Useful advice can be specific while
188
+ remaining conditional and grounded in the user's words.
189
+
190
+ **Fallbacks can hide product failure.** A deterministic success response is
191
+ worse than an honest retry when it erases personalization.
192
+
193
+ **Small models improve when each call has one job.** Planning, writing, and
194
+ critique are easier to validate than one giant prompt.
195
+
196
+ **Pacing is part of model design.** Streaming one clearing at a time changes a
197
+ slow generation into a walk.
198
+
199
+ **Visual diversity needs subject diversity.** Four styles are not truly four
200
+ experiences if every image contains the same kind of character.
201
+
202
+ ## Links
203
+
204
+ - Space:
205
+ https://huggingface.co/spaces/build-small-hackathon/compliment-forest
206
+ - MiniCPM5-1B model:
207
+ https://huggingface.co/build-small-hackathon/compliment-forest-minicpm5-1b
208
+ - SFT dataset:
209
+ https://huggingface.co/datasets/build-small-hackathon/compliment-forest-sft
210
+ - FLUX LoRA:
211
+ https://huggingface.co/build-small-hackathon/compliment-forest-flux-lora
212
+ - Sanitized traces:
213
+ https://huggingface.co/datasets/build-small-hackathon/compliment-forest-traces
214
+ - Multi-style dataset:
215
+ https://huggingface.co/datasets/thangvip/compliment-forest-multistyle-v2
docs/demo-script.md ADDED
@@ -0,0 +1,112 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # The Compliment Forest Demo Script
2
+
3
+ Target length: 85-95 seconds.
4
+
5
+ Use a pre-generated completed forest for the final reveal. Record one real
6
+ generation separately, then edit through the waiting periods so the video shows
7
+ the complete product without pretending inference is instantaneous.
8
+
9
+ ## 0:00-0:08 - Hook
10
+
11
+ **Screen:** Open on a completed Layered Paper Cut forest. Slowly scroll past two
12
+ different clearings.
13
+
14
+ **Voiceover:**
15
+
16
+ > Most AI encouragement sounds kind, but says very little. The Compliment
17
+ > Forest turns one real worry into a grounded, illustrated path forward.
18
+
19
+ ## 0:08-0:22 - Start With a Real Worry
20
+
21
+ **Screen:** Return to the start. Enter:
22
+
23
+ - Name: `Ka`
24
+ - Situation: `I worry that one test score means I am not as smart as I thought.`
25
+
26
+ Submit and show two adaptive questions with different selected answers.
27
+
28
+ **Voiceover:**
29
+
30
+ > I begin with one sentence. The forest asks five adaptive questions about
31
+ > what happened, what feels at stake, and what useful progress would look like.
32
+ > It does not ask me to choose a generic mood.
33
+
34
+ ## 0:22-0:31 - Choose the Visual Language
35
+
36
+ **Screen:** Move quickly through the remaining answers. Select **Layered Paper
37
+ Cut** and click **Grow my forest**.
38
+
39
+ **Voiceover:**
40
+
41
+ > Then I choose one of four LoRA-trained visual styles. Text and images run on
42
+ > separate Modal GPU services, while the Hugging Face Space streams progress.
43
+
44
+ ## 0:31-0:58 - Show the Five-Chapter Arc
45
+
46
+ **Screen:** Cut to the completed forest. Highlight each role title as the page
47
+ scrolls:
48
+
49
+ 1. arrive
50
+ 2. steady
51
+ 3. widen
52
+ 4. step
53
+ 5. carry
54
+
55
+ Pause longest on `step`. Show a sentence containing a concrete action such as
56
+ reviewing one missed question or identifying one topic to practice.
57
+
58
+ **Voiceover:**
59
+
60
+ > MiniCPM plans from exact phrases in my input, writes five chapters, and
61
+ > critiques them. The path first acknowledges the feeling, then separates facts
62
+ > from fear, offers realistic options, gives one small action, and ends with a
63
+ > simple plan.
64
+
65
+ ## 0:58-1:12 - Explain What Makes It Reliable
66
+
67
+ **Screen:** Overlay a simple diagram:
68
+
69
+ `adaptive intake -> planner -> author -> critic -> validators -> FLUX images`
70
+
71
+ Then briefly show a test terminal with `155 passed`.
72
+
73
+ **Voiceover:**
74
+
75
+ > Local validators reject invented biography, repeated sentences, unsupported
76
+ > dates, vague fallback language, and advice with no practical step. Bad
77
+ > chapters are repaired selectively. If repair fails, the app returns an honest
78
+ > retry instead of canned prose.
79
+
80
+ ## 1:12-1:24 - Show the Small-Model Work
81
+
82
+ **Screen:** Show the Hugging Face model, dataset, LoRA, and trace cards in a
83
+ quick four-panel montage.
84
+
85
+ **Voiceover:**
86
+
87
+ > The project also publishes a 1.08-billion-parameter MiniCPM fine-tune and
88
+ > llama.cpp GGUF, four FLUX style adapters, training data, and sanitized
89
+ > planner-author-critic traces.
90
+
91
+ ## 1:24-1:32 - Close
92
+
93
+ **Screen:** Return to the full forest and its final mantra.
94
+
95
+ **Voiceover:**
96
+
97
+ > The Compliment Forest is whimsical encouragement with engineering boundaries:
98
+ > small models, honest uncertainty, and one useful step back into the day.
99
+
100
+ **End card:**
101
+
102
+ `huggingface.co/spaces/build-small-hackathon/compliment-forest`
103
+
104
+ ## Recording Checklist
105
+
106
+ - Record at 1440p or 1080p, 16:9.
107
+ - Keep browser zoom near 90% so the custom interface remains readable.
108
+ - Hide bookmarks, personal tabs, tokens, and terminal paths.
109
+ - Use captions for every voiceover line.
110
+ - Do not show a crisis phrase in the main demo; mention the safety boundary in
111
+ the article and README so the product story stays focused.
112
+ - Export a thumbnail from the completed paper-cut forest.
docs/social-post.md ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Social Post Drafts
2
+
3
+ Replace `[VIDEO URL]` after uploading the demo.
4
+
5
+ ## LinkedIn / Hugging Face Community
6
+
7
+ I built **The Compliment Forest** for the Hugging Face Build Small Hackathon.
8
+
9
+ Most AI encouragement becomes abstract when a person needs something concrete.
10
+ The Compliment Forest turns one worry into a five-chapter illustrated walk:
11
+ acknowledge the feeling, separate facts from fear, consider realistic options,
12
+ choose one small action, and carry a simple plan forward.
13
+
14
+ The stack:
15
+
16
+ - MiniCPM4.1-8B for adaptive intake, planning, authoring, and critique
17
+ - a published 1.08B MiniCPM fine-tune and llama.cpp GGUF
18
+ - FLUX.1-schnell with four LoRA-trained visual styles
19
+ - separate Modal GPU services for text and image inference
20
+ - a custom Gradio Server frontend on Hugging Face Spaces
21
+ - deterministic validators for grounding, repetition, invented biography, and
22
+ practical advice
23
+
24
+ OpenAI Codex helped across the full engineering loop: codebase exploration,
25
+ test-first debugging, structured-output repair, prompt redesign, deployment,
26
+ and live verification.
27
+
28
+ Try it:
29
+ https://huggingface.co/spaces/build-small-hackathon/compliment-forest
30
+
31
+ Demo: [VIDEO URL]
32
+
33
+ #BuildSmall #HuggingFace #MiniCPM #Modal #OpenAICodex #Gradio #GenerativeAI
34
+
35
+ ## X / Bluesky
36
+
37
+ I built The Compliment Forest for #BuildSmall: one worry becomes a grounded,
38
+ five-chapter illustrated path with MiniCPM, FLUX LoRAs, Modal, a custom Gradio
39
+ UI, and strict checks against repeated or invented advice.
40
+
41
+ Space:
42
+ https://huggingface.co/spaces/build-small-hackathon/compliment-forest
43
+
44
+ Demo: [VIDEO URL]
45
+
46
+ #HuggingFace #MiniCPM #OpenAICodex
47
+
frontend/app.js CHANGED
@@ -3,17 +3,28 @@
3
  const entryView = document.querySelector("#entry-view");
4
  const experienceView = document.querySelector("#experience-view");
5
  const form = document.querySelector("#forest-form");
 
 
6
  const nameInput = document.querySelector("#name");
7
  const situationInput = document.querySelector("#situation");
 
8
  const growButton = document.querySelector("#grow-button");
9
  const statusRegion = document.querySelector("#status-region");
 
 
 
 
 
10
  const forestTitle = document.querySelector("#forest-title");
11
  const clearingProgress = document.querySelector("#clearing-progress");
 
12
  const progressDots = document.querySelector("#progress-dots");
 
 
13
  const clearingImage = document.querySelector("#clearing-image");
14
- const creature = document.querySelector("#creature");
15
- const strength = document.querySelector("#strength");
16
- const encouragement = document.querySelector("#encouragement");
17
  const reflection = document.querySelector("#reflection");
18
  const spell = document.querySelector("#spell");
19
  const copySpell = document.querySelector("#copy-spell");
@@ -22,45 +33,269 @@ const saveForest = document.querySelector("#save-forest");
22
  const startOver = document.querySelector("#start-over");
23
  const brandHome = document.querySelector("#brand-home");
24
 
 
 
 
 
 
 
 
 
 
25
  const state = {
26
  forestTitle: "",
27
  clearingCount: 0,
28
  clearings: [],
29
  currentIndex: 0,
 
 
 
30
  complete: false,
31
  name: "",
32
  situation: "",
 
 
 
33
  };
34
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35
  function resetState() {
36
  state.forestTitle = "";
37
  state.clearingCount = 0;
38
  state.clearings = [];
39
  state.currentIndex = 0;
 
 
40
  state.complete = false;
 
41
  statusRegion.textContent = "";
42
  statusRegion.classList.remove("is-error");
 
 
 
 
 
43
  saveForest.hidden = true;
44
  walkOn.hidden = false;
45
  walkOn.disabled = false;
46
  walkOn.querySelector("span").textContent = "Walk on";
47
  }
48
 
49
- function showEntry() {
50
- resetState();
51
- experienceView.hidden = true;
52
- entryView.hidden = false;
53
- growButton.disabled = false;
54
- growButton.querySelector("span").textContent = "Grow my forest";
55
- window.scrollTo({ top: 0, behavior: "smooth" });
56
- nameInput.focus();
 
 
 
 
 
 
 
57
  }
58
 
59
- function setStatus(message, isError = false) {
60
- statusRegion.textContent = message;
61
- statusRegion.classList.toggle("is-error", isError);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62
  }
63
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
64
  function buildDots() {
65
  progressDots.replaceChildren();
66
  const count = state.clearingCount || state.clearings.length;
@@ -81,7 +316,19 @@ function updateActions() {
81
  walkOn.hidden = atFinalEnd;
82
  saveForest.hidden = !atFinalEnd;
83
  walkOn.disabled = !hasNext;
84
- walkOn.querySelector("span").textContent = hasNext ? "Walk on" : "Painting the next clearing";
 
 
 
 
 
 
 
 
 
 
 
 
85
  }
86
 
87
  function renderCurrent() {
@@ -94,11 +341,18 @@ function renderCurrent() {
94
  clearingProgress.textContent = `Clearing ${state.currentIndex + 1} of ${
95
  state.clearingCount || state.clearings.length
96
  }`;
97
- clearingImage.src = item.image || "/assets/fox-path.png";
98
- clearingImage.alt = item.clearing.creature;
99
- creature.textContent = item.clearing.creature;
100
- strength.textContent = item.clearing.strength;
101
- encouragement.textContent = item.clearing.line;
 
 
 
 
 
 
 
102
  reflection.textContent = item.clearing.reflection;
103
  spell.textContent = item.clearing.spell;
104
  buildDots();
@@ -120,6 +374,10 @@ function handleEvent(event) {
120
  if (event.type === "forest") {
121
  state.forestTitle = event.data.forest_title;
122
  state.clearingCount = event.data.clearing_count || 0;
 
 
 
 
123
  setStatus("The first clearing is almost in view.");
124
  return;
125
  }
@@ -132,6 +390,11 @@ function handleEvent(event) {
132
  }
133
  return;
134
  }
 
 
 
 
 
135
  if (event.type === "complete") {
136
  state.complete = true;
137
  updateActions();
@@ -161,34 +424,6 @@ async function readForestStream(response) {
161
  if (buffer.trim()) handleEvent(JSON.parse(buffer));
162
  }
163
 
164
- form.addEventListener("submit", async (submitEvent) => {
165
- submitEvent.preventDefault();
166
- if (!form.reportValidity()) return;
167
-
168
- resetState();
169
- state.name = nameInput.value.trim();
170
- state.situation = situationInput.value.trim();
171
- growButton.disabled = true;
172
- growButton.querySelector("span").textContent = "Growing your path";
173
- setStatus("The forest is listening.");
174
-
175
- try {
176
- const response = await fetch("/api/forest", {
177
- method: "POST",
178
- headers: { "Content-Type": "application/json" },
179
- body: JSON.stringify({
180
- name: state.name,
181
- situation: state.situation,
182
- }),
183
- });
184
- await readForestStream(response);
185
- } catch (error) {
186
- setStatus(error.message || "The forest could not begin. Please try again.", true);
187
- growButton.disabled = false;
188
- growButton.querySelector("span").textContent = "Grow my forest";
189
- }
190
- });
191
-
192
  walkOn.addEventListener("click", () => {
193
  if (state.currentIndex + 1 >= state.clearings.length) return;
194
  state.currentIndex += 1;
@@ -204,26 +439,126 @@ copySpell.addEventListener("click", async () => {
204
  }, 1600);
205
  });
206
 
207
- function wrapText(context, text, x, y, maxWidth, lineHeight, maxLines = 4) {
208
- const words = text.split(/\s+/);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
209
  let line = "";
210
- let lineCount = 0;
211
  for (const word of words) {
212
  const next = line ? `${line} ${word}` : word;
213
  if (context.measureText(next).width > maxWidth && line) {
214
- context.fillText(line, x, y + lineCount * lineHeight);
215
  line = word;
216
- lineCount += 1;
217
- if (lineCount >= maxLines) return y + lineCount * lineHeight;
218
  } else {
219
  line = next;
220
  }
221
  }
222
- if (line && lineCount < maxLines) {
223
- context.fillText(line, x, y + lineCount * lineHeight);
224
- lineCount += 1;
 
 
 
 
225
  }
226
- return y + lineCount * lineHeight;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
227
  }
228
 
229
  function loadImage(source) {
@@ -237,12 +572,13 @@ function loadImage(source) {
237
 
238
  async function drawForestCard() {
239
  const width = 1400;
240
- const rowHeight = 360;
241
- const height = 300 + state.clearings.length * rowHeight + 150;
242
  const canvas = document.createElement("canvas");
243
  canvas.width = width;
244
- canvas.height = height;
245
- const context = canvas.getContext("2d");
 
 
 
246
 
247
  context.fillStyle = "#f5f0df";
248
  context.fillRect(0, 0, width, height);
@@ -257,45 +593,92 @@ async function drawForestCard() {
257
 
258
  context.fillStyle = "#243c31";
259
  context.textAlign = "center";
260
- context.font = '58px "Iowan Old Style", Georgia, serif';
261
- context.fillText(state.forestTitle, width / 2, 92);
 
 
 
 
 
 
262
  context.font = '22px "Avenir Next", Arial, sans-serif';
263
- context.fillText("A path from The Compliment Forest", width / 2, 138);
 
 
 
 
 
 
 
 
 
 
 
264
  context.textAlign = "left";
265
 
266
- for (let index = 0; index < state.clearings.length; index += 1) {
267
- const item = state.clearings[index];
268
- const top = 205 + index * rowHeight;
269
  try {
270
- const image = await loadImage(item.image || "/assets/fox-path.png");
271
- context.save();
272
- context.beginPath();
273
- context.roundRect(80, top, 350, 280, 20);
274
- context.clip();
275
- context.drawImage(image, 80, top, 350, 280);
276
- context.restore();
 
 
 
 
277
  } catch {
278
  context.fillStyle = "#d8ddc8";
279
  context.fillRect(80, top, 350, 280);
280
  }
281
 
282
  context.fillStyle = "#3f5634";
283
- context.font = '600 17px "Avenir Next", Arial, sans-serif';
284
- context.fillText(item.clearing.creature.toUpperCase(), 490, top + 24);
285
- context.font = '39px "Iowan Old Style", Georgia, serif';
286
- context.fillText(item.clearing.strength, 490, top + 72);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
287
  context.fillStyle = "#243c31";
288
- context.font = '31px "Iowan Old Style", Georgia, serif';
289
- let textY = wrapText(context, item.clearing.line, 490, top + 123, 800, 41, 3);
 
 
 
 
 
 
290
  context.strokeStyle = "rgba(83,104,61,0.45)";
291
  context.lineWidth = 2;
292
  context.beginPath();
293
  context.moveTo(490, textY + 18);
294
  context.lineTo(1290, textY + 18);
295
  context.stroke();
296
- context.font = '25px "Iowan Old Style", Georgia, serif';
297
  context.fillStyle = "#3f5634";
298
- wrapText(context, item.clearing.spell, 490, textY + 62, 800, 34, 2);
 
 
 
 
 
 
299
  }
300
 
301
  context.textAlign = "center";
@@ -324,6 +707,18 @@ saveForest.addEventListener("click", async () => {
324
  }, "image/png");
325
  });
326
 
 
 
 
 
 
 
 
 
 
 
 
 
327
  startOver.addEventListener("click", showEntry);
328
  brandHome.addEventListener("click", showEntry);
329
-
 
3
  const entryView = document.querySelector("#entry-view");
4
  const experienceView = document.querySelector("#experience-view");
5
  const form = document.querySelector("#forest-form");
6
+ const formSteps = Array.from(document.querySelectorAll(".form-step"));
7
+ const stepTracker = document.querySelector("#step-tracker");
8
  const nameInput = document.querySelector("#name");
9
  const situationInput = document.querySelector("#situation");
10
+ const styleInput = document.querySelector("#style");
11
  const growButton = document.querySelector("#grow-button");
12
  const statusRegion = document.querySelector("#status-region");
13
+ const intakeStep = document.querySelector("#intake-step");
14
+ const intakeQuestion = document.querySelector("#intake-question");
15
+ const intakeOptions = document.querySelector("#intake-options");
16
+ const intakeProgress = document.querySelector("#intake-progress");
17
+ const styleStep = document.querySelector("#style-step");
18
  const forestTitle = document.querySelector("#forest-title");
19
  const clearingProgress = document.querySelector("#clearing-progress");
20
+ const artStyleLabel = document.querySelector("#art-style-label");
21
  const progressDots = document.querySelector("#progress-dots");
22
+ const soundscapePlayer = document.querySelector("#soundscape-player");
23
+ const soundscapeAudio = document.querySelector("#soundscape-audio");
24
  const clearingImage = document.querySelector("#clearing-image");
25
+ const sceneTitle = document.querySelector("#scene-title");
26
+ const sceneIntro = document.querySelector("#scene-intro");
27
+ const narration = document.querySelector("#narration");
28
  const reflection = document.querySelector("#reflection");
29
  const spell = document.querySelector("#spell");
30
  const copySpell = document.querySelector("#copy-spell");
 
33
  const startOver = document.querySelector("#start-over");
34
  const brandHome = document.querySelector("#brand-home");
35
 
36
+ const INTAKE_TURNS = 5;
37
+
38
+ const STYLE_LABELS = {
39
+ watercolor: "Watercolor Storybook",
40
+ paper_cut: "Layered Paper Cut",
41
+ moonlit_gouache: "Moonlit Gouache",
42
+ botanical_ink: "Botanical Ink Wash",
43
+ };
44
+
45
  const state = {
46
  forestTitle: "",
47
  clearingCount: 0,
48
  clearings: [],
49
  currentIndex: 0,
50
+ currentStep: "name",
51
+ intake: [],
52
+ pendingQuestion: null,
53
  complete: false,
54
  name: "",
55
  situation: "",
56
+ style: "surprise",
57
+ resolvedStyle: "",
58
+ seed: 0,
59
  };
60
 
61
+ function randomSeed() {
62
+ const values = new Uint32Array(1);
63
+ crypto.getRandomValues(values);
64
+ return values[0] % 2147483648;
65
+ }
66
+
67
+ function setStatus(message, isError = false) {
68
+ statusRegion.textContent = message;
69
+ statusRegion.classList.toggle("is-error", isError);
70
+ }
71
+
72
+ function updateStepTracker() {
73
+ const items = Array.from(stepTracker.querySelectorAll("li"));
74
+ let currentSlot;
75
+ if (state.currentStep === "name") currentSlot = 0;
76
+ else if (state.currentStep === "style") currentSlot = 6;
77
+ else if (state.currentStep === "intake") currentSlot = 1 + state.intake.length;
78
+ else currentSlot = 0;
79
+ for (const [index, item] of items.entries()) {
80
+ item.classList.toggle("is-current", index === currentSlot);
81
+ item.classList.toggle("is-complete", index < currentSlot);
82
+ }
83
+ }
84
+
85
+ function showStep(step, focus = true) {
86
+ state.currentStep = step;
87
+ const lookup = {
88
+ name: '[data-step="0"]',
89
+ intake: '[data-step="intake"]',
90
+ style: '[data-step="style"]',
91
+ };
92
+ for (const section of formSteps) {
93
+ section.hidden = true;
94
+ section.classList.remove("is-active");
95
+ }
96
+ const active = document.querySelector(lookup[step]);
97
+ if (active) {
98
+ active.hidden = false;
99
+ active.classList.add("is-active");
100
+ if (focus) {
101
+ const target = active.querySelector("input, textarea, button, select");
102
+ if (target) target.focus({ preventScroll: true });
103
+ }
104
+ }
105
+ updateStepTracker();
106
+ }
107
+
108
  function resetState() {
109
  state.forestTitle = "";
110
  state.clearingCount = 0;
111
  state.clearings = [];
112
  state.currentIndex = 0;
113
+ state.intake = [];
114
+ state.pendingQuestion = null;
115
  state.complete = false;
116
+ state.resolvedStyle = "";
117
  statusRegion.textContent = "";
118
  statusRegion.classList.remove("is-error");
119
+ artStyleLabel.textContent = "";
120
+ soundscapeAudio.pause();
121
+ soundscapeAudio.removeAttribute("src");
122
+ soundscapeAudio.load();
123
+ soundscapePlayer.hidden = true;
124
  saveForest.hidden = true;
125
  walkOn.hidden = false;
126
  walkOn.disabled = false;
127
  walkOn.querySelector("span").textContent = "Walk on";
128
  }
129
 
130
+ function renderIntakeQuestion(question) {
131
+ state.pendingQuestion = question;
132
+ intakeProgress.textContent = `Question ${state.intake.length + 1} of ${INTAKE_TURNS}`;
133
+ intakeQuestion.textContent = question.question;
134
+ intakeOptions.replaceChildren();
135
+ for (const option of question.options) {
136
+ const label = document.createElement("label");
137
+ label.className = "choice-card";
138
+ const sanitized = option.replace(/"/g, "&quot;");
139
+ label.innerHTML = `
140
+ <input type="radio" name="intake-option" value="${sanitized}" />
141
+ <span>${option}</span>
142
+ `;
143
+ intakeOptions.append(label);
144
+ }
145
  }
146
 
147
+ async function fetchNextIntakeQuestion() {
148
+ setStatus("The forest is listening… (first response can take ~60s while it wakes)");
149
+ let response;
150
+ try {
151
+ response = await fetch("/api/intake/next", {
152
+ method: "POST",
153
+ headers: { "Content-Type": "application/json" },
154
+ body: JSON.stringify({
155
+ name: state.name,
156
+ situation: state.situation,
157
+ history: state.intake,
158
+ }),
159
+ });
160
+ } catch (networkError) {
161
+ throw new Error(
162
+ `Could not reach the forest (network): ${networkError.message}`,
163
+ );
164
+ }
165
+ if (!response.ok) {
166
+ let detail = "";
167
+ try {
168
+ const body = await response.json();
169
+ detail = body && body.detail ? body.detail : JSON.stringify(body);
170
+ } catch {
171
+ detail = await response.text().catch(() => "");
172
+ }
173
+ throw new Error(
174
+ `The forest is too quiet (HTTP ${response.status}): ${detail.slice(0, 800)}`,
175
+ );
176
+ }
177
+ const question = await response.json();
178
+ setStatus("The forest has a question for you.");
179
+ return question;
180
+ }
181
+
182
+ async function startIntake() {
183
+ state.name = nameInput.value.trim();
184
+ state.situation = situationInput.value.trim();
185
+ state.intake = [];
186
+ showStep("intake");
187
+ await advanceIntake();
188
+ }
189
+
190
+ async function advanceIntake() {
191
+ if (state.intake.length >= INTAKE_TURNS) {
192
+ showStep("style");
193
+ setStatus("");
194
+ return;
195
+ }
196
+ try {
197
+ const question = await fetchNextIntakeQuestion();
198
+ renderIntakeQuestion(question);
199
+ } catch (error) {
200
+ setStatus(error.message, true);
201
+ }
202
+ }
203
+
204
+ function validateNameStep() {
205
+ for (const input of [nameInput, situationInput]) {
206
+ if (!input.checkValidity()) {
207
+ input.reportValidity();
208
+ return false;
209
+ }
210
+ }
211
+ return true;
212
+ }
213
+
214
+ function validateIntakeChoice() {
215
+ const checked = intakeOptions.querySelector('input[name="intake-option"]:checked');
216
+ if (!checked) {
217
+ setStatus("Choose one option before walking on.", true);
218
+ return null;
219
+ }
220
+ setStatus("");
221
+ return checked.value;
222
+ }
223
+
224
+ for (const button of document.querySelectorAll(".step-next")) {
225
+ button.addEventListener("click", async () => {
226
+ if (state.currentStep === "name") {
227
+ if (!validateNameStep()) return;
228
+ await startIntake();
229
+ return;
230
+ }
231
+ if (state.currentStep === "intake") {
232
+ const answer = validateIntakeChoice();
233
+ if (!answer || !state.pendingQuestion) return;
234
+ state.intake.push({ question: state.pendingQuestion.question, answer });
235
+ state.pendingQuestion = null;
236
+ updateStepTracker();
237
+ await advanceIntake();
238
+ return;
239
+ }
240
+ });
241
  }
242
 
243
+ for (const button of document.querySelectorAll(".step-back")) {
244
+ button.addEventListener("click", async () => {
245
+ if (state.currentStep === "intake") {
246
+ if (state.intake.length === 0) {
247
+ showStep("name");
248
+ return;
249
+ }
250
+ state.intake.pop();
251
+ state.pendingQuestion = null;
252
+ updateStepTracker();
253
+ await advanceIntake();
254
+ return;
255
+ }
256
+ if (state.currentStep === "style") {
257
+ // Drop the last answer so advanceIntake re-asks instead of bouncing back to style.
258
+ if (state.intake.length > 0) state.intake.pop();
259
+ state.pendingQuestion = null;
260
+ showStep("intake");
261
+ updateStepTracker();
262
+ await advanceIntake();
263
+ }
264
+ });
265
+ }
266
+
267
+ form.addEventListener("submit", async (submitEvent) => {
268
+ submitEvent.preventDefault();
269
+ if (state.currentStep !== "style") return;
270
+ state.style = styleInput.value;
271
+ state.seed = randomSeed();
272
+ growButton.disabled = true;
273
+ growButton.querySelector("span").textContent = "Growing your path";
274
+ setStatus("The forest is shaping the walk you talked about.");
275
+
276
+ try {
277
+ const response = await fetch("/api/forest", {
278
+ method: "POST",
279
+ headers: { "Content-Type": "application/json" },
280
+ body: JSON.stringify({
281
+ name: state.name,
282
+ situation: state.situation,
283
+ style: state.style,
284
+ seed: state.seed,
285
+ intake: state.intake,
286
+ }),
287
+ });
288
+ await readForestStream(response);
289
+ } catch (error) {
290
+ setStatus(
291
+ error.message || "The forest could not begin. Please try again.",
292
+ true,
293
+ );
294
+ growButton.disabled = false;
295
+ growButton.querySelector("span").textContent = "Grow my forest";
296
+ }
297
+ });
298
+
299
  function buildDots() {
300
  progressDots.replaceChildren();
301
  const count = state.clearingCount || state.clearings.length;
 
316
  walkOn.hidden = atFinalEnd;
317
  saveForest.hidden = !atFinalEnd;
318
  walkOn.disabled = !hasNext;
319
+ walkOn.querySelector("span").textContent = hasNext
320
+ ? "Walk on"
321
+ : "Painting the next clearing";
322
+ }
323
+
324
+ function renderNarration(text) {
325
+ narration.replaceChildren();
326
+ const paragraphs = text.split(/\n{2,}/).map((piece) => piece.trim()).filter(Boolean);
327
+ for (const paragraph of paragraphs) {
328
+ const node = document.createElement("p");
329
+ node.textContent = paragraph;
330
+ narration.append(node);
331
+ }
332
  }
333
 
334
  function renderCurrent() {
 
341
  clearingProgress.textContent = `Clearing ${state.currentIndex + 1} of ${
342
  state.clearingCount || state.clearings.length
343
  }`;
344
+ if (item.image) {
345
+ clearingImage.src = item.image;
346
+ clearingImage.alt = item.clearing.scene_title;
347
+ clearingImage.hidden = false;
348
+ } else {
349
+ clearingImage.removeAttribute("src");
350
+ clearingImage.alt = "";
351
+ clearingImage.hidden = true;
352
+ }
353
+ sceneTitle.textContent = item.clearing.scene_title;
354
+ sceneIntro.textContent = item.clearing.scene_intro;
355
+ renderNarration(item.clearing.narration);
356
  reflection.textContent = item.clearing.reflection;
357
  spell.textContent = item.clearing.spell;
358
  buildDots();
 
374
  if (event.type === "forest") {
375
  state.forestTitle = event.data.forest_title;
376
  state.clearingCount = event.data.clearing_count || 0;
377
+ state.resolvedStyle = event.data.style || "";
378
+ artStyleLabel.textContent = state.resolvedStyle
379
+ ? `Painted in ${STYLE_LABELS[state.resolvedStyle] || state.resolvedStyle}`
380
+ : "";
381
  setStatus("The first clearing is almost in view.");
382
  return;
383
  }
 
390
  }
391
  return;
392
  }
393
+ if (event.type === "soundscape") {
394
+ soundscapeAudio.src = event.data.audio;
395
+ soundscapePlayer.hidden = false;
396
+ return;
397
+ }
398
  if (event.type === "complete") {
399
  state.complete = true;
400
  updateActions();
 
424
  if (buffer.trim()) handleEvent(JSON.parse(buffer));
425
  }
426
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
427
  walkOn.addEventListener("click", () => {
428
  if (state.currentIndex + 1 >= state.clearings.length) return;
429
  state.currentIndex += 1;
 
439
  }, 1600);
440
  });
441
 
442
+ function splitOversizeWord(context, word, maxWidth) {
443
+ if (context.measureText(word).width <= maxWidth) return [word];
444
+ const pieces = [];
445
+ let piece = "";
446
+ for (const character of word) {
447
+ const next = piece + character;
448
+ if (piece && context.measureText(next).width > maxWidth) {
449
+ pieces.push(piece);
450
+ piece = character;
451
+ } else {
452
+ piece = next;
453
+ }
454
+ }
455
+ if (piece) pieces.push(piece);
456
+ return pieces;
457
+ }
458
+
459
+ function wrapTextLines(context, text, maxWidth) {
460
+ const words = text
461
+ .trim()
462
+ .split(/\s+/)
463
+ .flatMap((word) => splitOversizeWord(context, word, maxWidth));
464
+ const lines = [];
465
  let line = "";
 
466
  for (const word of words) {
467
  const next = line ? `${line} ${word}` : word;
468
  if (context.measureText(next).width > maxWidth && line) {
469
+ lines.push(line);
470
  line = word;
 
 
471
  } else {
472
  line = next;
473
  }
474
  }
475
+ if (line) lines.push(line);
476
+ return lines.length ? lines : [""];
477
+ }
478
+
479
+ function drawWrappedText(context, lines, x, y, lineHeight) {
480
+ for (const [index, line] of lines.entries()) {
481
+ context.fillText(line, x, y + index * lineHeight);
482
  }
483
+ return y + lines.length * lineHeight;
484
+ }
485
+
486
+ function measureTextBlock(context, text, font, maxWidth, lineHeight) {
487
+ context.font = font;
488
+ const lines = wrapTextLines(context, text, maxWidth);
489
+ return { font, lines, lineHeight, height: lines.length * lineHeight };
490
+ }
491
+
492
+ function measureForestCardLayout(context) {
493
+ const textWidth = 800;
494
+ const title = measureTextBlock(
495
+ context,
496
+ state.forestTitle,
497
+ '58px "Iowan Old Style", Georgia, serif',
498
+ 1180,
499
+ 66,
500
+ );
501
+ const headerHeight =
502
+ 178 + Math.max(0, title.lines.length - 1) * title.lineHeight;
503
+ let top = headerHeight + 28;
504
+ const rows = state.clearings.map((item) => {
505
+ const sceneBlock = measureTextBlock(
506
+ context,
507
+ item.clearing.scene_title.toUpperCase(),
508
+ '600 17px "Avenir Next", Arial, sans-serif',
509
+ textWidth,
510
+ 24,
511
+ );
512
+ const introBlock = measureTextBlock(
513
+ context,
514
+ item.clearing.scene_intro,
515
+ 'italic 22px "Iowan Old Style", Georgia, serif',
516
+ textWidth,
517
+ 30,
518
+ );
519
+ const narrationBlock = measureTextBlock(
520
+ context,
521
+ item.clearing.narration.replace(/\n+/g, " "),
522
+ '28px "Iowan Old Style", Georgia, serif',
523
+ textWidth,
524
+ 38,
525
+ );
526
+ const spellBlock = measureTextBlock(
527
+ context,
528
+ item.clearing.spell,
529
+ '25px "Iowan Old Style", Georgia, serif',
530
+ textWidth,
531
+ 34,
532
+ );
533
+ const textHeight =
534
+ 24 +
535
+ sceneBlock.height +
536
+ 8 +
537
+ introBlock.height +
538
+ 18 +
539
+ narrationBlock.height +
540
+ 36 +
541
+ spellBlock.height +
542
+ 18;
543
+ const height = Math.max(360, textHeight);
544
+ const row = {
545
+ item,
546
+ top,
547
+ height,
548
+ sceneBlock,
549
+ introBlock,
550
+ narrationBlock,
551
+ spellBlock,
552
+ };
553
+ top += height + 38;
554
+ return row;
555
+ });
556
+ return {
557
+ title,
558
+ headerHeight,
559
+ rows,
560
+ height: top + 92,
561
+ };
562
  }
563
 
564
  function loadImage(source) {
 
572
 
573
  async function drawForestCard() {
574
  const width = 1400;
 
 
575
  const canvas = document.createElement("canvas");
576
  canvas.width = width;
577
+ let context = canvas.getContext("2d");
578
+ const layout = measureForestCardLayout(context);
579
+ canvas.height = layout.height;
580
+ context = canvas.getContext("2d");
581
+ const height = layout.height;
582
 
583
  context.fillStyle = "#f5f0df";
584
  context.fillRect(0, 0, width, height);
 
593
 
594
  context.fillStyle = "#243c31";
595
  context.textAlign = "center";
596
+ context.font = layout.title.font;
597
+ drawWrappedText(
598
+ context,
599
+ layout.title.lines,
600
+ width / 2,
601
+ 92,
602
+ layout.title.lineHeight,
603
+ );
604
  context.font = '22px "Avenir Next", Arial, sans-serif';
605
+ const subtitleY =
606
+ 138 + Math.max(0, layout.title.lines.length - 1) * layout.title.lineHeight;
607
+ context.fillText("A path from The Compliment Forest", width / 2, subtitleY);
608
+ if (state.resolvedStyle) {
609
+ context.font = '18px "Avenir Next", Arial, sans-serif';
610
+ context.fillStyle = "#3f5634";
611
+ context.fillText(
612
+ `Painted in ${STYLE_LABELS[state.resolvedStyle] || state.resolvedStyle}`,
613
+ width / 2,
614
+ subtitleY + 32,
615
+ );
616
+ }
617
  context.textAlign = "left";
618
 
619
+ for (const row of layout.rows) {
620
+ const { item, top } = row;
 
621
  try {
622
+ if (item.image) {
623
+ const image = await loadImage(item.image);
624
+ context.save();
625
+ context.beginPath();
626
+ context.roundRect(80, top, 350, 280, 20);
627
+ context.clip();
628
+ context.drawImage(image, 80, top, 350, 280);
629
+ context.restore();
630
+ } else {
631
+ throw new Error("No generated image");
632
+ }
633
  } catch {
634
  context.fillStyle = "#d8ddc8";
635
  context.fillRect(80, top, 350, 280);
636
  }
637
 
638
  context.fillStyle = "#3f5634";
639
+ context.font = row.sceneBlock.font;
640
+ let textY = drawWrappedText(
641
+ context,
642
+ row.sceneBlock.lines,
643
+ 490,
644
+ top + 24,
645
+ row.sceneBlock.lineHeight,
646
+ );
647
+ textY += 8;
648
+ context.fillStyle = "#4c574f";
649
+ context.font = row.introBlock.font;
650
+ textY = drawWrappedText(
651
+ context,
652
+ row.introBlock.lines,
653
+ 490,
654
+ textY,
655
+ row.introBlock.lineHeight,
656
+ );
657
+ textY += 18;
658
  context.fillStyle = "#243c31";
659
+ context.font = row.narrationBlock.font;
660
+ textY = drawWrappedText(
661
+ context,
662
+ row.narrationBlock.lines,
663
+ 490,
664
+ textY,
665
+ row.narrationBlock.lineHeight,
666
+ );
667
  context.strokeStyle = "rgba(83,104,61,0.45)";
668
  context.lineWidth = 2;
669
  context.beginPath();
670
  context.moveTo(490, textY + 18);
671
  context.lineTo(1290, textY + 18);
672
  context.stroke();
673
+ context.font = row.spellBlock.font;
674
  context.fillStyle = "#3f5634";
675
+ drawWrappedText(
676
+ context,
677
+ row.spellBlock.lines,
678
+ 490,
679
+ textY + 62,
680
+ row.spellBlock.lineHeight,
681
+ );
682
  }
683
 
684
  context.textAlign = "center";
 
707
  }, "image/png");
708
  });
709
 
710
+ function showEntry() {
711
+ resetState();
712
+ form.reset();
713
+ showStep("name", false);
714
+ experienceView.hidden = true;
715
+ entryView.hidden = false;
716
+ growButton.disabled = false;
717
+ growButton.querySelector("span").textContent = "Grow my forest";
718
+ window.scrollTo({ top: 0, behavior: "smooth" });
719
+ nameInput.focus();
720
+ }
721
+
722
  startOver.addEventListener("click", showEntry);
723
  brandHome.addEventListener("click", showEntry);
724
+ showStep("name", false);
frontend/index.html CHANGED
@@ -1,143 +1,302 @@
1
  <!doctype html>
2
  <html lang="en">
3
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width, initial-scale=1" />
6
- <meta
7
- name="description"
8
- content="A small-model-powered path of grounded, illustrated encouragement."
9
- />
10
- <title>The Compliment Forest</title>
11
- <link rel="stylesheet" href="/styles.css" />
12
- </head>
13
- <body>
14
- <div class="paper-grain" aria-hidden="true"></div>
15
- <div class="fireflies" aria-hidden="true">
16
- <i></i><i></i><i></i><i></i><i></i>
17
- </div>
18
-
19
- <header class="site-header">
20
- <button class="brand" id="brand-home" type="button" aria-label="Return to the beginning">
21
- <svg class="brand-mark" viewBox="0 0 64 64" aria-hidden="true">
22
- <path d="M16 48C5 35 10 16 27 10c15-5 30 4 31 20 1 18-18 29-34 21" />
23
- <path d="M18 45c10-17 21-24 35-27M17 20c6 1 10 4 13 9M34 11c0 6 2 10 7 14" />
24
- <ellipse cx="13" cy="35" rx="3" ry="7" transform="rotate(-31 13 35)" />
25
- <ellipse cx="24" cy="16" rx="3" ry="7" transform="rotate(34 24 16)" />
26
- <ellipse cx="48" cy="16" rx="3" ry="7" transform="rotate(57 48 16)" />
27
- <ellipse cx="53" cy="37" rx="3" ry="7" transform="rotate(102 53 37)" />
28
- </svg>
29
- <span>The Compliment Forest</span>
30
- </button>
31
- </header>
32
-
33
- <main id="app">
34
- <section class="view entry-view" id="entry-view">
35
- <div class="entry-copy">
36
- <h1>The Compliment<br />Forest</h1>
37
- <p class="intro">
38
- Name what you're carrying. We'll walk until a few quiet strengths find you.
39
- </p>
40
-
41
- <form id="forest-form" novalidate>
42
- <label for="name">Your name</label>
43
- <input
44
- id="name"
45
- name="name"
46
- type="text"
47
- placeholder="Mika"
48
- autocomplete="name"
49
- maxlength="80"
50
- required
51
- />
52
-
53
- <label for="situation">What's happening?</label>
54
- <textarea
55
- id="situation"
56
- name="situation"
57
- placeholder="Starting a new job, and worried I won't belong..."
58
- maxlength="1200"
59
- rows="4"
60
- required
61
- ></textarea>
62
-
63
- <button class="primary-button grow-button" id="grow-button" type="submit">
64
- <span>Grow my forest</span>
65
- <svg viewBox="0 0 32 20" aria-hidden="true">
66
- <path d="M2 10h24M19 3l8 7-8 7M13 8c-4-5-8-4-9-2 4 1 7 3 9 6" />
67
- </svg>
68
- </button>
69
- </form>
70
-
71
- <div
72
- class="status-region"
73
- id="status-region"
74
- role="status"
75
- aria-live="polite"
76
- aria-atomic="true"
77
- ></div>
78
  </div>
79
 
80
- <figure class="entry-art">
81
- <img src="/assets/fox-path.png" alt="" />
82
- </figure>
83
- </section>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
84
 
85
- <section class="view experience-view" hidden id="experience-view">
86
- <div class="path-header">
87
- <h1 id="forest-title">A path is growing</h1>
88
- <p id="clearing-progress">Clearing 1</p>
89
- <div class="progress-dots" id="progress-dots" aria-hidden="true"></div>
90
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
91
 
92
- <article class="clearing">
93
- <figure class="clearing-art">
94
- <div class="image-wash" aria-hidden="true"></div>
95
- <img id="clearing-image" src="" alt="" />
96
- </figure>
97
 
98
- <div class="clearing-copy">
99
- <p class="creature" id="creature"></p>
100
- <h2 id="strength"></h2>
101
- <blockquote id="encouragement"></blockquote>
 
 
 
 
102
 
103
- <section class="carried-question">
104
- <p class="section-label">A question to carry</p>
105
- <p id="reflection"></p>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
106
  </section>
107
 
108
- <section class="spell-section">
109
- <p class="section-label">Tiny spell</p>
110
- <p class="spell" id="spell"></p>
111
- <button class="copy-button" id="copy-spell" type="button">
112
- <svg viewBox="0 0 24 24" aria-hidden="true">
113
- <rect x="8" y="8" width="11" height="12" rx="1" />
114
- <path d="M16 8V4H5v12h3" />
115
- </svg>
116
- <span>Copy spell</span>
117
- </button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
118
  </section>
 
119
 
120
- <div class="path-actions">
121
- <button class="primary-button" id="walk-on" type="button">
122
- <span>Walk on</span>
123
- <svg viewBox="0 0 32 20" aria-hidden="true">
124
- <path d="M2 10h24M19 3l8 7-8 7M13 8c-4-5-8-4-9-2 4 1 7 3 9 6" />
125
- </svg>
126
- </button>
127
- <button class="primary-button save-button" id="save-forest" type="button" hidden>
128
- <span>Save your forest</span>
129
- <svg viewBox="0 0 24 24" aria-hidden="true">
130
- <path d="M12 3v12m0 0 5-5m-5 5-5-5M5 20h14" />
131
- </svg>
132
- </button>
133
- <button class="text-button" id="start-over" type="button">Start another path</button>
134
- </div>
135
- </div>
136
- </article>
137
- </section>
138
- </main>
139
-
140
- <footer>Whimsical encouragement, not a substitute for professional support.</footer>
141
- <script src="/app.js" defer></script>
142
- </body>
143
  </html>
 
1
  <!doctype html>
2
  <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <meta
7
+ name="description"
8
+ content="A guided, small-model-powered path of grounded, illustrated encouragement."
9
+ />
10
+ <title>The Compliment Forest</title>
11
+ <link rel="stylesheet" href="/styles.css" />
12
+ </head>
13
+ <body>
14
+ <div class="paper-grain" aria-hidden="true"></div>
15
+ <div class="fireflies" aria-hidden="true">
16
+ <i></i><i></i><i></i><i></i><i></i>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
  </div>
18
 
19
+ <header class="site-header">
20
+ <button
21
+ class="brand"
22
+ id="brand-home"
23
+ type="button"
24
+ aria-label="Return to the beginning"
25
+ >
26
+ <svg class="brand-mark" viewBox="0 0 64 64" aria-hidden="true">
27
+ <path
28
+ d="M16 48C5 35 10 16 27 10c15-5 30 4 31 20 1 18-18 29-34 21"
29
+ />
30
+ <path
31
+ d="M18 45c10-17 21-24 35-27M17 20c6 1 10 4 13 9M34 11c0 6 2 10 7 14"
32
+ />
33
+ <ellipse
34
+ cx="13"
35
+ cy="35"
36
+ rx="3"
37
+ ry="7"
38
+ transform="rotate(-31 13 35)"
39
+ />
40
+ <ellipse
41
+ cx="24"
42
+ cy="16"
43
+ rx="3"
44
+ ry="7"
45
+ transform="rotate(34 24 16)"
46
+ />
47
+ <ellipse
48
+ cx="48"
49
+ cy="16"
50
+ rx="3"
51
+ ry="7"
52
+ transform="rotate(57 48 16)"
53
+ />
54
+ <ellipse
55
+ cx="53"
56
+ cy="37"
57
+ rx="3"
58
+ ry="7"
59
+ transform="rotate(102 53 37)"
60
+ />
61
+ </svg>
62
+ <span>The Compliment Forest</span>
63
+ </button>
64
+ </header>
65
+
66
+ <main id="app">
67
+ <section class="view entry-view" id="entry-view">
68
+ <div class="entry-copy">
69
+ <p class="eyebrow">A private-feeling guided walk</p>
70
+ <h1>The Compliment<br />Forest</h1>
71
+ <p class="intro">
72
+ Name what you're carrying. The forest is listening before it asks five
73
+ quiet questions, then walks a path with you.
74
+ </p>
75
 
76
+ <form id="forest-form" novalidate>
77
+ <ol class="step-tracker" id="step-tracker" aria-label="Forest intake progress">
78
+ <li class="is-current">Name it</li>
79
+ <li>Question 1 of 5</li>
80
+ <li>Question 2 of 5</li>
81
+ <li>Question 3 of 5</li>
82
+ <li>Question 4 of 5</li>
83
+ <li>Question 5 of 5</li>
84
+ <li>Paint it</li>
85
+ </ol>
86
+
87
+ <section class="form-step is-active" data-step="0">
88
+ <p class="step-kicker">Step 1 of 5</p>
89
+ <h2>What should the forest call you?</h2>
90
+
91
+ <label for="name">Your name</label>
92
+ <input
93
+ id="name"
94
+ name="name"
95
+ type="text"
96
+ placeholder="Mika"
97
+ autocomplete="name"
98
+ maxlength="80"
99
+ required
100
+ />
101
+
102
+ <label for="situation"
103
+ >What are you carrying right now?</label
104
+ >
105
+ <textarea
106
+ id="situation"
107
+ name="situation"
108
+ placeholder="Starting a new job, and worried I won't belong..."
109
+ maxlength="1200"
110
+ rows="4"
111
+ required
112
+ ></textarea>
113
+
114
+ <div class="step-actions">
115
+ <button
116
+ class="primary-button step-next"
117
+ type="button"
118
+ >
119
+ Begin the walk
120
+ </button>
121
+ </div>
122
+ </section>
123
+
124
+ <section class="form-step" data-step="intake" id="intake-step" hidden>
125
+ <p class="step-kicker" id="intake-progress">Question 1 of 5</p>
126
+ <h2 id="intake-question">The forest is listening for your first question.</h2>
127
+ <p class="step-help">Pick the option that comes closest. The next question adapts.</p>
128
+
129
+ <div
130
+ class="choice-grid two-column"
131
+ id="intake-options"
132
+ role="radiogroup"
133
+ aria-label="Forest question"
134
+ ></div>
135
+
136
+ <div class="step-actions split-actions">
137
+ <button class="text-button step-back" type="button">Back</button>
138
+ <button class="primary-button step-next" type="button">
139
+ Walk on
140
+ </button>
141
+ </div>
142
+ </section>
143
 
144
+ <section class="form-step" data-step="style" id="style-step" hidden>
145
+ <p class="step-kicker">Painting the path</p>
146
+ <h2>How should the forest paint your walk?</h2>
 
 
147
 
148
+ <label for="style">Art style</label>
149
+ <select id="style" name="style">
150
+ <option value="surprise">Surprise me</option>
151
+ <option value="watercolor">Watercolor Storybook</option>
152
+ <option value="paper_cut">Layered Paper Cut</option>
153
+ <option value="moonlit_gouache">Moonlit Gouache</option>
154
+ <option value="botanical_ink">Botanical Ink Wash</option>
155
+ </select>
156
 
157
+ <div class="step-actions split-actions">
158
+ <button class="text-button step-back" type="button">Back</button>
159
+ <button class="primary-button grow-button" id="grow-button" type="submit">
160
+ <span>Grow my forest</span>
161
+ <svg viewBox="0 0 32 20" aria-hidden="true">
162
+ <path d="M2 10h24M19 3l8 7-8 7M13 8c-4-5-8-4-9-2 4 1 7 3 9 6" />
163
+ </svg>
164
+ </button>
165
+ </div>
166
+ </section>
167
+ </form>
168
+
169
+ <div
170
+ class="status-region"
171
+ id="status-region"
172
+ role="status"
173
+ aria-live="polite"
174
+ aria-atomic="true"
175
+ ></div>
176
+ </div>
177
+
178
+ <figure class="entry-art">
179
+ <img src="/assets/fox-path.png" alt="" />
180
+ </figure>
181
  </section>
182
 
183
+ <section class="view experience-view" hidden id="experience-view">
184
+ <div class="path-header">
185
+ <h1 id="forest-title">A path is growing</h1>
186
+ <p id="clearing-progress">Clearing 1</p>
187
+ <p
188
+ class="art-style-label"
189
+ id="art-style-label"
190
+ aria-live="polite"
191
+ ></p>
192
+ <div
193
+ class="progress-dots"
194
+ id="progress-dots"
195
+ aria-hidden="true"
196
+ ></div>
197
+ </div>
198
+
199
+ <section
200
+ class="soundscape-player"
201
+ id="soundscape-player"
202
+ aria-live="polite"
203
+ hidden
204
+ >
205
+ <div>
206
+ <p class="section-label">Forest soundscape</p>
207
+ <p class="soundscape-note">
208
+ A generated instrumental companion. Press play when
209
+ ready.
210
+ </p>
211
+ </div>
212
+ <audio
213
+ id="soundscape-audio"
214
+ controls
215
+ loop
216
+ preload="metadata"
217
+ ></audio>
218
+ </section>
219
+
220
+ <article class="clearing">
221
+ <figure class="clearing-art">
222
+ <div class="image-wash" aria-hidden="true"></div>
223
+ <img id="clearing-image" src="" alt="" />
224
+ </figure>
225
+
226
+ <div class="clearing-copy">
227
+ <p class="scene-title" id="scene-title"></p>
228
+ <p class="scene-intro" id="scene-intro"></p>
229
+ <div class="narration" id="narration"></div>
230
+
231
+ <section class="carried-question">
232
+ <p class="section-label">A question to carry</p>
233
+ <p id="reflection"></p>
234
+ </section>
235
+
236
+ <section class="spell-section">
237
+ <p class="section-label">Tiny spell</p>
238
+ <p class="spell" id="spell"></p>
239
+ <button
240
+ class="copy-button"
241
+ id="copy-spell"
242
+ type="button"
243
+ >
244
+ <svg viewBox="0 0 24 24" aria-hidden="true">
245
+ <rect
246
+ x="8"
247
+ y="8"
248
+ width="11"
249
+ height="12"
250
+ rx="1"
251
+ />
252
+ <path d="M16 8V4H5v12h3" />
253
+ </svg>
254
+ <span>Copy spell</span>
255
+ </button>
256
+ </section>
257
+
258
+ <div class="path-actions">
259
+ <button
260
+ class="primary-button"
261
+ id="walk-on"
262
+ type="button"
263
+ >
264
+ <span>Walk on</span>
265
+ <svg viewBox="0 0 32 20" aria-hidden="true">
266
+ <path
267
+ d="M2 10h24M19 3l8 7-8 7M13 8c-4-5-8-4-9-2 4 1 7 3 9 6"
268
+ />
269
+ </svg>
270
+ </button>
271
+ <button
272
+ class="primary-button save-button"
273
+ id="save-forest"
274
+ type="button"
275
+ hidden
276
+ >
277
+ <span>Save your forest</span>
278
+ <svg viewBox="0 0 24 24" aria-hidden="true">
279
+ <path
280
+ d="M12 3v12m0 0 5-5m-5 5-5-5M5 20h14"
281
+ />
282
+ </svg>
283
+ </button>
284
+ <button
285
+ class="text-button"
286
+ id="start-over"
287
+ type="button"
288
+ >
289
+ Start another path
290
+ </button>
291
+ </div>
292
+ </div>
293
+ </article>
294
  </section>
295
+ </main>
296
 
297
+ <footer>
298
+ Whimsical encouragement, not a substitute for professional support.
299
+ </footer>
300
+ <script src="/app.js" defer></script>
301
+ </body>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
302
  </html>
frontend/styles.css CHANGED
@@ -1,866 +1,1204 @@
1
  :root {
2
- --paper: #f5f0df;
3
- --paper-light: #fbf8ed;
4
- --ink: #243c31;
5
- --ink-soft: #4c574f;
6
- --moss: #53683d;
7
- --moss-dark: #3f5634;
8
- --sage: #8fa184;
9
- --sage-light: #c8cfb7;
10
- --rose: #c98f83;
11
- --honey: #e4b75e;
12
- --line: rgba(82, 104, 61, 0.48);
13
- --serif: "Iowan Old Style", "Palatino Linotype", "Book Antiqua", Baskerville, Georgia, serif;
14
- --sans: "Avenir Next", Avenir, "Segoe UI", Helvetica, Arial, sans-serif;
 
 
15
  }
16
 
17
  * {
18
- box-sizing: border-box;
19
  }
20
 
21
  html {
22
- min-width: 320px;
23
- min-height: 100%;
24
- background: var(--paper);
25
  }
26
 
27
  body {
28
- min-height: 100vh;
29
- margin: 0;
30
- overflow-x: hidden;
31
- color: var(--ink);
32
- background:
33
- radial-gradient(circle at 22% 18%, rgba(255, 255, 255, 0.58), transparent 35%),
34
- radial-gradient(circle at 83% 71%, rgba(207, 213, 186, 0.18), transparent 32%),
35
- var(--paper);
36
- font-family: var(--sans);
37
- text-rendering: optimizeLegibility;
 
 
 
 
 
 
 
 
38
  }
39
 
40
  button,
41
  input,
 
42
  textarea {
43
- font: inherit;
44
  }
45
 
46
  button {
47
- color: inherit;
48
  }
49
 
50
  .paper-grain {
51
- position: fixed;
52
- z-index: -1;
53
- inset: 0;
54
- pointer-events: none;
55
- opacity: 0.28;
56
- background-image: url("/assets/paper-texture.svg");
57
  }
58
 
59
  .site-header {
60
- display: flex;
61
- align-items: center;
62
- width: min(100% - 64px, 1380px);
63
- height: 104px;
64
- margin: 0 auto;
65
  }
66
 
67
  .brand {
68
- display: inline-flex;
69
- align-items: center;
70
- gap: 16px;
71
- padding: 0;
72
- border: 0;
73
- background: transparent;
74
- font-family: var(--serif);
75
- font-size: 1.42rem;
76
- cursor: pointer;
77
  }
78
 
79
  .brand-mark {
80
- width: 54px;
81
- height: 54px;
82
- overflow: visible;
83
- fill: none;
84
- stroke: #6f825b;
85
- stroke-width: 1.7;
86
- stroke-linecap: round;
87
- stroke-linejoin: round;
88
  }
89
 
90
  .brand-mark ellipse {
91
- fill: rgba(111, 130, 91, 0.5);
92
- stroke-width: 1;
93
  }
94
 
95
  .view {
96
- width: min(100% - 64px, 1380px);
97
- margin: 0 auto;
98
  }
99
 
100
  .entry-view {
101
- display: grid;
102
- grid-template-columns: minmax(420px, 0.88fr) minmax(520px, 1.2fr);
103
- align-items: center;
104
- min-height: calc(100vh - 174px);
105
  }
106
 
107
  .entry-copy {
108
- z-index: 2;
109
- max-width: 570px;
110
- padding: 38px 0 72px 34px;
111
  }
112
 
113
  .entry-copy h1,
114
  .path-header h1 {
115
- margin: 0;
116
- color: var(--ink);
117
- font-family: var(--serif);
118
- font-weight: 400;
119
- letter-spacing: -0.045em;
120
  }
121
 
122
  .entry-copy h1 {
123
- font-size: clamp(4.2rem, 7vw, 7.25rem);
124
- line-height: 0.82;
125
  }
126
 
127
  .intro {
128
- max-width: 540px;
129
- margin: 34px 0 42px;
130
- color: var(--ink-soft);
131
- font-size: clamp(1.25rem, 1.7vw, 1.7rem);
132
- line-height: 1.48;
133
  }
134
 
135
  #forest-form {
136
- display: grid;
137
- gap: 10px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
138
  }
139
 
140
  #forest-form label {
141
- margin-top: 12px;
142
- color: #26382f;
143
- font-size: 1.02rem;
144
- font-weight: 600;
145
  }
146
 
147
  #forest-form input,
 
148
  #forest-form textarea {
149
- width: 100%;
150
- border: 1.5px solid rgba(48, 78, 62, 0.8);
151
- border-radius: 5px;
152
- outline: 0;
153
- color: var(--ink);
154
- background: rgba(251, 248, 237, 0.58);
155
- box-shadow: inset 0 0 22px rgba(255, 255, 255, 0.35);
156
- font-size: 1.14rem;
157
- transition:
158
- border-color 180ms ease,
159
- box-shadow 180ms ease,
160
- background 180ms ease;
161
  }
162
 
163
  #forest-form input {
164
- height: 58px;
165
- padding: 0 18px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
166
  }
167
 
168
  #forest-form textarea {
169
- min-height: 128px;
170
- padding: 16px 18px;
171
- line-height: 1.45;
172
- resize: vertical;
173
  }
174
 
175
  #forest-form input::placeholder,
176
  #forest-form textarea::placeholder {
177
- color: rgba(68, 78, 72, 0.55);
178
  }
179
 
180
  #forest-form input:focus,
 
181
  #forest-form textarea:focus {
182
- border-color: var(--moss);
183
- background: rgba(255, 253, 245, 0.88);
184
- box-shadow: 0 0 0 4px rgba(83, 104, 61, 0.14);
185
  }
186
 
187
  .primary-button {
188
- display: inline-flex;
189
- align-items: center;
190
- justify-content: center;
191
- gap: 16px;
192
- min-height: 58px;
193
- padding: 14px 30px;
194
- border: 1px solid rgba(47, 67, 39, 0.72);
195
- border-radius: 5px;
196
- color: #fffdf3;
197
- background: var(--moss);
198
- box-shadow: 0 7px 22px rgba(63, 86, 52, 0.12);
199
- font-family: var(--serif);
200
- font-size: 1.45rem;
201
- cursor: pointer;
202
- transition:
203
- transform 160ms ease,
204
- background 160ms ease,
205
- box-shadow 160ms ease;
206
  }
207
 
208
  .primary-button svg {
209
- width: 30px;
210
- height: 20px;
211
- fill: none;
212
- stroke: currentColor;
213
- stroke-width: 1.7;
214
- stroke-linecap: round;
215
- stroke-linejoin: round;
216
  }
217
 
218
  .primary-button:hover:not(:disabled) {
219
- transform: translateY(-2px);
220
- background: var(--moss-dark);
221
- box-shadow: 0 11px 28px rgba(63, 86, 52, 0.2);
222
  }
223
 
224
  .primary-button:disabled {
225
- cursor: wait;
226
- opacity: 0.62;
227
  }
228
 
229
  .grow-button {
230
- width: 100%;
231
- margin-top: 20px;
232
- min-height: 68px;
233
- font-size: 1.82rem;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
234
  }
235
 
236
  .status-region {
237
- min-height: 28px;
238
- margin-top: 18px;
239
- color: var(--moss-dark);
240
- font-size: 0.98rem;
241
- line-height: 1.5;
242
  }
243
 
244
  .status-region:not(:empty)::before {
245
- display: inline-block;
246
- width: 7px;
247
- height: 7px;
248
- margin: 0 10px 1px 2px;
249
- border-radius: 50%;
250
- background: var(--honey);
251
- box-shadow: 0 0 10px rgba(228, 183, 94, 0.75);
252
- content: "";
253
- animation: breathe 1.7s ease-in-out infinite;
254
  }
255
 
256
  .status-region.is-error {
257
- max-width: 570px;
258
- padding: 14px 16px;
259
- border-left: 3px solid var(--rose);
260
- color: #5e3934;
261
- background: rgba(201, 143, 131, 0.12);
262
  }
263
 
264
  .entry-art {
265
- position: relative;
266
- align-self: stretch;
267
- min-height: 680px;
268
- margin: -78px -120px -10px -100px;
269
- overflow: hidden;
270
  }
271
 
272
  .entry-art::after {
273
- position: absolute;
274
- inset: 0;
275
- pointer-events: none;
276
- background:
277
- linear-gradient(90deg, var(--paper) 0%, transparent 22%),
278
- linear-gradient(0deg, var(--paper) 0%, transparent 12%);
279
- content: "";
280
  }
281
 
282
  .entry-art img {
283
- width: 100%;
284
- height: 100%;
285
- object-fit: cover;
286
- object-position: 56% 50%;
287
- mix-blend-mode: multiply;
288
  }
289
 
290
  .experience-view {
291
- min-height: calc(100vh - 174px);
292
- padding: 0 24px 48px;
293
  }
294
 
295
  .path-header {
296
- max-width: 980px;
297
- margin: -14px auto 36px;
298
- text-align: center;
299
  }
300
 
301
  .path-header h1 {
302
- font-size: clamp(3rem, 5vw, 5.3rem);
303
- line-height: 1.05;
304
  }
305
 
306
  #clearing-progress {
307
- margin: 18px 0 10px;
308
- color: var(--ink-soft);
309
- font-size: 1rem;
310
- letter-spacing: 0.08em;
 
 
 
 
 
 
 
 
311
  }
312
 
313
  .progress-dots {
314
- display: flex;
315
- justify-content: center;
316
- gap: 18px;
317
- min-height: 18px;
318
  }
319
 
320
  .progress-dot {
321
- width: 14px;
322
- height: 14px;
323
- border-radius: 50%;
324
- background: rgba(143, 161, 132, 0.24);
325
- transition:
326
- transform 180ms ease,
327
- background 180ms ease;
328
  }
329
 
330
  .progress-dot.is-complete {
331
- background: rgba(143, 161, 132, 0.7);
332
  }
333
 
334
  .progress-dot.is-current {
335
- transform: scale(1.1);
336
- background: var(--moss-dark);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
337
  }
338
 
339
  .clearing {
340
- display: grid;
341
- grid-template-columns: minmax(460px, 1.05fr) minmax(430px, 0.95fr);
342
- gap: clamp(52px, 7vw, 118px);
343
- align-items: center;
344
- width: min(100%, 1240px);
345
- margin: 0 auto;
346
  }
347
 
348
  .clearing-art {
349
- position: relative;
350
- min-height: 610px;
351
- margin: 0;
352
  }
353
 
354
  .image-wash {
355
- position: absolute;
356
- inset: 10% 2% 2%;
357
- border-radius: 48% 52% 55% 45%;
358
- background:
359
- radial-gradient(circle at 43% 45%, rgba(182, 197, 190, 0.45), transparent 43%),
360
- radial-gradient(circle at 48% 76%, rgba(121, 141, 100, 0.2), transparent 45%);
361
- filter: blur(13px);
 
 
 
 
 
 
 
 
362
  }
363
 
364
  .clearing-art img {
365
- position: absolute;
366
- z-index: 1;
367
- inset: 0;
368
- width: 100%;
369
- height: 100%;
370
- border-radius: 44% 48% 45% 50%;
371
- object-fit: cover;
372
- mix-blend-mode: multiply;
373
- mask-image: radial-gradient(ellipse 66% 76% at center, black 54%, transparent 100%);
 
 
 
 
374
  }
375
 
376
  .clearing-copy {
377
- max-width: 570px;
378
- padding: 18px 0 32px;
379
  }
380
 
381
- .creature,
382
  .section-label {
383
- margin: 0;
384
- color: var(--moss-dark);
385
- font-size: 0.79rem;
386
- font-weight: 650;
387
- letter-spacing: 0.2em;
388
- text-transform: uppercase;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
389
  }
390
 
391
- #strength {
392
- margin: 10px 0 20px;
393
- font-family: var(--serif);
394
- font-size: clamp(2rem, 3vw, 3.15rem);
395
- font-weight: 400;
396
- line-height: 1.1;
397
  }
398
 
399
- #encouragement {
400
- margin: 0 0 34px;
401
- font-family: var(--serif);
402
- font-size: clamp(1.78rem, 2.8vw, 2.75rem);
403
- font-weight: 400;
404
- line-height: 1.23;
405
  }
406
 
407
  .carried-question,
408
  .spell-section {
409
- padding: 26px 2px;
410
- border-top: 2px solid var(--line);
411
  }
412
 
413
  #reflection,
414
  .spell {
415
- margin: 10px 0 0;
416
- font-family: var(--serif);
417
- font-size: clamp(1.35rem, 2.1vw, 2rem);
418
- line-height: 1.28;
419
  }
420
 
421
  .spell-section {
422
- padding-bottom: 20px;
423
  }
424
 
425
  .spell {
426
- margin-bottom: 14px;
427
  }
428
 
429
  .copy-button {
430
- display: inline-flex;
431
- align-items: center;
432
- gap: 9px;
433
- min-height: 42px;
434
- padding: 8px 17px;
435
- border: 1.4px solid var(--moss-dark);
436
- border-radius: 5px;
437
- background: rgba(251, 248, 237, 0.55);
438
- cursor: pointer;
439
  }
440
 
441
  .copy-button svg {
442
- width: 20px;
443
- height: 20px;
444
- fill: none;
445
- stroke: currentColor;
446
- stroke-width: 1.6;
447
  }
448
 
449
  .path-actions {
450
- display: grid;
451
- gap: 10px;
452
- margin-top: 18px;
453
  }
454
 
455
  .save-button {
456
- background: #6b7048;
457
  }
458
 
459
  .text-button {
460
- justify-self: center;
461
- padding: 10px 18px;
462
- border: 0;
463
- color: var(--moss-dark);
464
- background: transparent;
465
- font-size: 1.02rem;
466
- cursor: pointer;
467
  }
468
 
469
  .text-button:hover {
470
- text-decoration: underline;
471
- text-underline-offset: 4px;
472
  }
473
 
474
  footer {
475
- width: min(100% - 40px, 1380px);
476
- margin: 0 auto;
477
- padding: 16px 0 24px;
478
- color: rgba(54, 65, 58, 0.78);
479
- font-size: 0.78rem;
480
- text-align: center;
481
  }
482
 
483
  .fireflies {
484
- position: fixed;
485
- z-index: 4;
486
- inset: 0;
487
- pointer-events: none;
488
  }
489
 
490
  .fireflies i {
491
- position: absolute;
492
- width: 5px;
493
- height: 5px;
494
- border-radius: 50%;
495
- background: rgba(244, 199, 92, 0.8);
496
- box-shadow: 0 0 12px rgba(244, 199, 92, 0.85);
497
- animation: drift 8s ease-in-out infinite;
498
  }
499
 
500
  .fireflies i:nth-child(1) {
501
- top: 31%;
502
- left: 78%;
503
  }
504
 
505
  .fireflies i:nth-child(2) {
506
- top: 65%;
507
- left: 69%;
508
- animation-delay: -2s;
509
  }
510
 
511
  .fireflies i:nth-child(3) {
512
- top: 43%;
513
- left: 91%;
514
- animation-delay: -4s;
515
  }
516
 
517
  .fireflies i:nth-child(4) {
518
- top: 76%;
519
- left: 85%;
520
- animation-delay: -6s;
521
  }
522
 
523
  .fireflies i:nth-child(5) {
524
- top: 56%;
525
- left: 53%;
526
- animation-delay: -3s;
527
  }
528
 
529
  [hidden] {
530
- display: none !important;
531
  }
532
 
533
  button:focus-visible,
534
  input:focus-visible,
 
535
  textarea:focus-visible {
536
- outline: 3px solid rgba(228, 183, 94, 0.72);
537
- outline-offset: 4px;
538
  }
539
 
540
  @keyframes drift {
541
- 0%,
542
- 100% {
543
- opacity: 0.35;
544
- transform: translate(0, 0);
545
- }
546
 
547
- 45% {
548
- opacity: 1;
549
- transform: translate(9px, -14px);
550
- }
551
  }
552
 
553
  @keyframes breathe {
554
- 0%,
555
- 100% {
556
- opacity: 0.45;
557
- transform: scale(0.8);
558
- }
559
 
560
- 50% {
561
- opacity: 1;
562
- transform: scale(1.12);
563
- }
564
  }
565
 
566
- @media (max-width: 1040px) {
567
- .entry-view {
568
- grid-template-columns: minmax(400px, 0.9fr) 1.1fr;
569
- }
 
570
 
571
- .entry-art {
572
- margin-left: -150px;
573
- }
574
-
575
- .clearing {
576
- gap: 48px;
577
- }
578
  }
579
 
580
- @media (max-height: 820px) and (min-width: 761px) {
581
- .site-header {
582
- height: 72px;
583
- }
584
-
585
- .brand {
586
- gap: 11px;
587
- font-size: 1.08rem;
588
- }
589
-
590
- .brand-mark {
591
- width: 40px;
592
- height: 40px;
593
- }
594
-
595
- .entry-view {
596
- min-height: calc(100vh - 112px);
597
- }
598
-
599
- .entry-copy {
600
- padding-top: 12px;
601
- padding-bottom: 18px;
602
- }
603
-
604
- .entry-copy h1 {
605
- font-size: clamp(3.8rem, 5.8vw, 5rem);
606
- line-height: 0.8;
607
- }
608
-
609
- .intro {
610
- margin: 16px 0 13px;
611
- font-size: 1.12rem;
612
- line-height: 1.38;
613
- }
614
-
615
- #forest-form {
616
- gap: 5px;
617
- }
618
-
619
- #forest-form label {
620
- margin-top: 6px;
621
- font-size: 0.87rem;
622
- }
623
-
624
- #forest-form input {
625
- height: 46px;
626
- }
627
-
628
- #forest-form textarea {
629
- min-height: 64px;
630
- padding-top: 11px;
631
- padding-bottom: 11px;
632
- }
633
-
634
- .grow-button {
635
- min-height: 48px;
636
- margin-top: 7px;
637
- font-size: 1.35rem;
638
- }
639
-
640
- .status-region {
641
- min-height: 20px;
642
- margin-top: 8px;
643
- font-size: 0.85rem;
644
- }
645
-
646
- .entry-art {
647
- min-height: 570px;
648
- }
649
-
650
- .experience-view {
651
- min-height: calc(100vh - 112px);
652
- padding-bottom: 6px;
653
- }
654
 
655
- .path-header {
656
- margin: -18px auto 12px;
657
- }
658
 
659
- .path-header h1 {
660
- font-size: clamp(2.8rem, 4.2vw, 3.5rem);
661
- }
 
662
 
663
- #clearing-progress {
664
- margin: 8px 0 6px;
665
- font-size: 0.82rem;
666
- }
667
-
668
- .progress-dots {
669
- gap: 14px;
670
- min-height: 12px;
671
- }
672
-
673
- .progress-dot {
674
- width: 11px;
675
- height: 11px;
676
- }
677
-
678
- .clearing {
679
- grid-template-columns: minmax(390px, 0.9fr) minmax(520px, 1.1fr);
680
- gap: 42px;
681
- }
682
-
683
- .clearing-art {
684
- min-height: 475px;
685
- }
686
-
687
- .clearing-copy {
688
- max-width: 640px;
689
- padding: 0 0 8px;
690
- }
691
-
692
- #strength {
693
- margin: 7px 0 10px;
694
- font-size: 2.15rem;
695
- }
696
-
697
- #encouragement {
698
- margin-bottom: 15px;
699
- font-size: 1.78rem;
700
- line-height: 1.16;
701
- }
702
-
703
- .carried-question,
704
- .spell-section {
705
- padding: 10px 2px;
706
- }
707
-
708
- #reflection,
709
- .spell {
710
- margin-top: 6px;
711
- font-size: 1.2rem;
712
- }
713
-
714
- .spell {
715
- margin-bottom: 8px;
716
- }
717
-
718
- .copy-button {
719
- min-height: 36px;
720
- padding: 6px 14px;
721
- }
722
-
723
- .path-actions {
724
- gap: 4px;
725
- margin-top: 8px;
726
- }
727
-
728
- .path-actions .primary-button {
729
- min-height: 45px;
730
- padding-top: 8px;
731
- padding-bottom: 8px;
732
- font-size: 1.25rem;
733
- }
734
-
735
- .text-button {
736
- padding-top: 5px;
737
- padding-bottom: 5px;
738
- font-size: 0.9rem;
739
- }
740
-
741
- footer {
742
- padding-top: 7px;
743
- padding-bottom: 9px;
744
- font-size: 0.7rem;
745
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
746
  }
747
 
748
  @media (max-width: 760px) {
749
- .site-header {
750
- width: min(100% - 36px, 720px);
751
- height: 78px;
752
- }
753
-
754
- .brand {
755
- gap: 10px;
756
- font-size: 1.05rem;
757
- }
758
-
759
- .brand-mark {
760
- width: 38px;
761
- height: 38px;
762
- }
763
-
764
- .view {
765
- width: min(100% - 36px, 720px);
766
- }
767
-
768
- .entry-view {
769
- display: flex;
770
- flex-direction: column;
771
- min-height: auto;
772
- }
773
-
774
- .entry-copy {
775
- width: 100%;
776
- max-width: none;
777
- padding: 34px 0 24px;
778
- }
779
-
780
- .entry-copy h1 {
781
- font-size: clamp(3.1rem, 15vw, 4.7rem);
782
- letter-spacing: -0.055em;
783
- }
784
-
785
- .intro {
786
- margin: 28px 0 30px;
787
- font-size: 1.2rem;
788
- }
789
-
790
- .entry-art {
791
- order: -1;
792
- align-self: auto;
793
- width: calc(100% + 36px);
794
- min-height: 310px;
795
- margin: -26px -18px -65px;
796
- opacity: 0.92;
797
- }
798
-
799
- .entry-art::after {
800
- background:
801
- linear-gradient(0deg, var(--paper) 0%, transparent 42%),
802
- linear-gradient(90deg, var(--paper) 0%, transparent 14%);
803
- }
804
-
805
- .entry-art img {
806
- object-position: 66% 52%;
807
- }
808
-
809
- .grow-button {
810
- font-size: 1.48rem;
811
- }
812
-
813
- .experience-view {
814
- padding: 0 0 32px;
815
- }
816
-
817
- .path-header {
818
- margin: 10px auto 24px;
819
- }
820
-
821
- .path-header h1 {
822
- font-size: clamp(2.75rem, 13vw, 4.5rem);
823
- }
824
-
825
- .clearing {
826
- display: block;
827
- }
828
-
829
- .clearing-art {
830
- min-height: 390px;
831
- margin: -6px -10px 4px;
832
- }
833
-
834
- .clearing-copy {
835
- max-width: none;
836
- padding: 8px 0 20px;
837
- }
838
-
839
- #encouragement {
840
- font-size: clamp(1.9rem, 8vw, 2.55rem);
841
- }
842
-
843
- #reflection,
844
- .spell {
845
- font-size: 1.55rem;
846
- }
847
-
848
- footer {
849
- padding-bottom: 22px;
850
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
851
  }
852
 
853
  @media (prefers-reduced-motion: reduce) {
854
- *,
855
- *::before,
856
- *::after {
857
- scroll-behavior: auto !important;
858
- animation-duration: 0.01ms !important;
859
- animation-iteration-count: 1 !important;
860
- transition-duration: 0.01ms !important;
861
- }
862
-
863
- .fireflies {
864
- display: none;
865
- }
866
  }
 
1
  :root {
2
+ --paper: #f5f0df;
3
+ --paper-light: #fbf8ed;
4
+ --ink: #243c31;
5
+ --ink-soft: #4c574f;
6
+ --moss: #53683d;
7
+ --moss-dark: #3f5634;
8
+ --sage: #8fa184;
9
+ --sage-light: #c8cfb7;
10
+ --rose: #c98f83;
11
+ --honey: #e4b75e;
12
+ --line: rgba(82, 104, 61, 0.48);
13
+ --serif:
14
+ "Iowan Old Style", "Palatino Linotype", "Book Antiqua", Baskerville,
15
+ Georgia, serif;
16
+ --sans: "Avenir Next", Avenir, "Segoe UI", Helvetica, Arial, sans-serif;
17
  }
18
 
19
  * {
20
+ box-sizing: border-box;
21
  }
22
 
23
  html {
24
+ min-width: 320px;
25
+ min-height: 100%;
26
+ background: var(--paper);
27
  }
28
 
29
  body {
30
+ min-height: 100vh;
31
+ margin: 0;
32
+ overflow-x: hidden;
33
+ color: var(--ink);
34
+ background:
35
+ radial-gradient(
36
+ circle at 22% 18%,
37
+ rgba(255, 255, 255, 0.58),
38
+ transparent 35%
39
+ ),
40
+ radial-gradient(
41
+ circle at 83% 71%,
42
+ rgba(207, 213, 186, 0.18),
43
+ transparent 32%
44
+ ),
45
+ var(--paper);
46
+ font-family: var(--sans);
47
+ text-rendering: optimizeLegibility;
48
  }
49
 
50
  button,
51
  input,
52
+ select,
53
  textarea {
54
+ font: inherit;
55
  }
56
 
57
  button {
58
+ color: inherit;
59
  }
60
 
61
  .paper-grain {
62
+ position: fixed;
63
+ z-index: -1;
64
+ inset: 0;
65
+ pointer-events: none;
66
+ opacity: 0.28;
67
+ background-image: url("/assets/paper-texture.svg");
68
  }
69
 
70
  .site-header {
71
+ display: flex;
72
+ align-items: center;
73
+ width: min(calc(100% - 64px), 1380px);
74
+ height: 104px;
75
+ margin: 0 auto;
76
  }
77
 
78
  .brand {
79
+ display: inline-flex;
80
+ align-items: center;
81
+ gap: 16px;
82
+ padding: 0;
83
+ border: 0;
84
+ background: transparent;
85
+ font-family: var(--serif);
86
+ font-size: 1.42rem;
87
+ cursor: pointer;
88
  }
89
 
90
  .brand-mark {
91
+ width: 54px;
92
+ height: 54px;
93
+ overflow: visible;
94
+ fill: none;
95
+ stroke: #6f825b;
96
+ stroke-width: 1.7;
97
+ stroke-linecap: round;
98
+ stroke-linejoin: round;
99
  }
100
 
101
  .brand-mark ellipse {
102
+ fill: rgba(111, 130, 91, 0.5);
103
+ stroke-width: 1;
104
  }
105
 
106
  .view {
107
+ width: min(calc(100% - 64px), 1380px);
108
+ margin: 0 auto;
109
  }
110
 
111
  .entry-view {
112
+ display: grid;
113
+ grid-template-columns: minmax(420px, 0.88fr) minmax(520px, 1.2fr);
114
+ align-items: center;
115
+ min-height: calc(100vh - 174px);
116
  }
117
 
118
  .entry-copy {
119
+ z-index: 2;
120
+ max-width: 570px;
121
+ padding: 38px 0 72px 34px;
122
  }
123
 
124
  .entry-copy h1,
125
  .path-header h1 {
126
+ margin: 0;
127
+ color: var(--ink);
128
+ font-family: var(--serif);
129
+ font-weight: 400;
130
+ letter-spacing: -0.045em;
131
  }
132
 
133
  .entry-copy h1 {
134
+ font-size: clamp(4.2rem, 7vw, 7.25rem);
135
+ line-height: 0.82;
136
  }
137
 
138
  .intro {
139
+ max-width: 540px;
140
+ margin: 34px 0 42px;
141
+ color: var(--ink-soft);
142
+ font-size: clamp(1.25rem, 1.7vw, 1.7rem);
143
+ line-height: 1.48;
144
  }
145
 
146
  #forest-form {
147
+ display: grid;
148
+ gap: 10px;
149
+ }
150
+
151
+ .eyebrow,
152
+ .step-kicker {
153
+ margin: 0 0 12px;
154
+ color: var(--moss-dark);
155
+ font-size: 0.84rem;
156
+ font-weight: 700;
157
+ letter-spacing: 0.12em;
158
+ text-transform: uppercase;
159
+ }
160
+
161
+ .step-tracker {
162
+ display: grid;
163
+ grid-template-columns: repeat(5, 1fr);
164
+ gap: 8px;
165
+ padding: 0;
166
+ margin: 0 0 24px;
167
+ list-style: none;
168
+ }
169
+
170
+ .step-tracker li {
171
+ position: relative;
172
+ min-height: 36px;
173
+ padding: 10px 8px;
174
+ border: 1px solid rgba(83, 104, 61, 0.2);
175
+ border-radius: 999px;
176
+ color: rgba(36, 60, 49, 0.55);
177
+ background: rgba(251, 248, 237, 0.48);
178
+ font-size: 0.72rem;
179
+ font-weight: 700;
180
+ text-align: center;
181
+ }
182
+
183
+ .step-tracker li.is-current,
184
+ .step-tracker li.is-complete {
185
+ border-color: rgba(83, 104, 61, 0.58);
186
+ color: var(--ink);
187
+ background: rgba(200, 207, 183, 0.34);
188
+ }
189
+
190
+ .form-step {
191
+ display: grid;
192
+ gap: 12px;
193
+ animation: stepIn 260ms ease both;
194
+ }
195
+
196
+ .form-step[hidden] {
197
+ display: none;
198
+ }
199
+
200
+ .form-step h2 {
201
+ margin: 0;
202
+ font-family: var(--serif);
203
+ font-size: clamp(2rem, 3.1vw, 3.1rem);
204
+ font-weight: 400;
205
+ line-height: 1.04;
206
+ letter-spacing: -0.035em;
207
+ }
208
+
209
+ .step-help {
210
+ margin: 0 0 8px;
211
+ color: var(--ink-soft);
212
+ line-height: 1.5;
213
+ }
214
+
215
+ #forest-form fieldset {
216
+ min-width: 0;
217
+ padding: 0;
218
+ margin: 8px 0 18px;
219
+ border: 0;
220
+ }
221
+
222
+ #forest-form legend {
223
+ margin-bottom: 10px;
224
+ color: #26382f;
225
+ font-weight: 700;
226
  }
227
 
228
  #forest-form label {
229
+ margin-top: 12px;
230
+ color: #26382f;
231
+ font-size: 1.02rem;
232
+ font-weight: 600;
233
  }
234
 
235
  #forest-form input,
236
+ #forest-form select,
237
  #forest-form textarea {
238
+ width: 100%;
239
+ border: 1.5px solid rgba(48, 78, 62, 0.8);
240
+ border-radius: 5px;
241
+ outline: 0;
242
+ color: var(--ink);
243
+ background: rgba(251, 248, 237, 0.58);
244
+ box-shadow: inset 0 0 22px rgba(255, 255, 255, 0.35);
245
+ font-size: 1.14rem;
246
+ transition:
247
+ border-color 180ms ease,
248
+ box-shadow 180ms ease,
249
+ background 180ms ease;
250
  }
251
 
252
  #forest-form input {
253
+ height: 58px;
254
+ padding: 0 18px;
255
+ }
256
+
257
+ #forest-form select {
258
+ height: 58px;
259
+ padding: 0 48px 0 18px;
260
+ appearance: none;
261
+ background-image:
262
+ linear-gradient(45deg, transparent 50%, var(--moss) 50%),
263
+ linear-gradient(135deg, var(--moss) 50%, transparent 50%);
264
+ background-position:
265
+ calc(100% - 23px) 25px,
266
+ calc(100% - 17px) 25px;
267
+ background-repeat: no-repeat;
268
+ background-size: 6px 6px;
269
  }
270
 
271
  #forest-form textarea {
272
+ min-height: 128px;
273
+ padding: 16px 18px;
274
+ line-height: 1.45;
275
+ resize: vertical;
276
  }
277
 
278
  #forest-form input::placeholder,
279
  #forest-form textarea::placeholder {
280
+ color: rgba(68, 78, 72, 0.55);
281
  }
282
 
283
  #forest-form input:focus,
284
+ #forest-form select:focus,
285
  #forest-form textarea:focus {
286
+ border-color: var(--moss);
287
+ background: rgba(255, 253, 245, 0.88);
288
+ box-shadow: 0 0 0 4px rgba(83, 104, 61, 0.14);
289
  }
290
 
291
  .primary-button {
292
+ display: inline-flex;
293
+ align-items: center;
294
+ justify-content: center;
295
+ gap: 16px;
296
+ min-height: 58px;
297
+ padding: 14px 30px;
298
+ border: 1px solid rgba(47, 67, 39, 0.72);
299
+ border-radius: 5px;
300
+ color: #fffdf3;
301
+ background: var(--moss);
302
+ box-shadow: 0 7px 22px rgba(63, 86, 52, 0.12);
303
+ font-family: var(--serif);
304
+ font-size: 1.45rem;
305
+ cursor: pointer;
306
+ transition:
307
+ transform 160ms ease,
308
+ background 160ms ease,
309
+ box-shadow 160ms ease;
310
  }
311
 
312
  .primary-button svg {
313
+ width: 30px;
314
+ height: 20px;
315
+ fill: none;
316
+ stroke: currentColor;
317
+ stroke-width: 1.7;
318
+ stroke-linecap: round;
319
+ stroke-linejoin: round;
320
  }
321
 
322
  .primary-button:hover:not(:disabled) {
323
+ transform: translateY(-2px);
324
+ background: var(--moss-dark);
325
+ box-shadow: 0 11px 28px rgba(63, 86, 52, 0.2);
326
  }
327
 
328
  .primary-button:disabled {
329
+ cursor: wait;
330
+ opacity: 0.62;
331
  }
332
 
333
  .grow-button {
334
+ width: 100%;
335
+ margin-top: 20px;
336
+ min-height: 68px;
337
+ font-size: 1.82rem;
338
+ }
339
+
340
+ .step-actions {
341
+ display: flex;
342
+ justify-content: flex-end;
343
+ gap: 16px;
344
+ margin-top: 18px;
345
+ }
346
+
347
+ .split-actions {
348
+ justify-content: space-between;
349
+ }
350
+
351
+ .split-actions .grow-button {
352
+ width: auto;
353
+ min-width: min(100%, 280px);
354
+ margin-top: 0;
355
+ }
356
+
357
+ .choice-grid {
358
+ display: grid;
359
+ gap: 12px;
360
+ }
361
+
362
+ .choice-grid.two-column {
363
+ grid-template-columns: repeat(2, minmax(0, 1fr));
364
+ }
365
+
366
+ .choice-grid.three-column {
367
+ grid-template-columns: repeat(3, minmax(0, 1fr));
368
+ }
369
+
370
+ .choice-card {
371
+ position: relative;
372
+ display: grid;
373
+ gap: 5px;
374
+ min-height: 104px;
375
+ padding: 16px 16px 16px 46px;
376
+ margin: 0;
377
+ border: 1.5px solid rgba(48, 78, 62, 0.34);
378
+ border-radius: 14px;
379
+ background: rgba(251, 248, 237, 0.56);
380
+ box-shadow: inset 0 0 24px rgba(255, 255, 255, 0.22);
381
+ cursor: pointer;
382
+ transition:
383
+ transform 160ms ease,
384
+ border-color 160ms ease,
385
+ background 160ms ease,
386
+ box-shadow 160ms ease;
387
+ }
388
+
389
+ .choice-card.compact {
390
+ min-height: 66px;
391
+ align-content: center;
392
+ }
393
+
394
+ #forest-form .choice-card input {
395
+ position: absolute;
396
+ top: 18px;
397
+ left: 17px;
398
+ width: 17px;
399
+ height: 17px;
400
+ padding: 0;
401
+ border: 0;
402
+ border-radius: 50%;
403
+ background: transparent;
404
+ box-shadow: none;
405
+ accent-color: var(--moss);
406
+ }
407
+
408
+ .choice-card span {
409
+ color: var(--ink);
410
+ font-family: var(--serif);
411
+ font-size: 1.18rem;
412
+ font-weight: 400;
413
+ line-height: 1.12;
414
+ }
415
+
416
+ .choice-card small {
417
+ color: var(--ink-soft);
418
+ font-size: 0.82rem;
419
+ font-weight: 500;
420
+ line-height: 1.35;
421
+ }
422
+
423
+ .choice-card:has(input:checked) {
424
+ border-color: rgba(63, 86, 52, 0.88);
425
+ background: rgba(200, 207, 183, 0.3);
426
+ box-shadow: 0 8px 22px rgba(63, 86, 52, 0.1);
427
+ }
428
+
429
+ .choice-card:hover {
430
+ transform: translateY(-1px);
431
+ border-color: rgba(63, 86, 52, 0.62);
432
  }
433
 
434
  .status-region {
435
+ min-height: 28px;
436
+ margin-top: 18px;
437
+ color: var(--moss-dark);
438
+ font-size: 0.98rem;
439
+ line-height: 1.5;
440
  }
441
 
442
  .status-region:not(:empty)::before {
443
+ display: inline-block;
444
+ width: 7px;
445
+ height: 7px;
446
+ margin: 0 10px 1px 2px;
447
+ border-radius: 50%;
448
+ background: var(--honey);
449
+ box-shadow: 0 0 10px rgba(228, 183, 94, 0.75);
450
+ content: "";
451
+ animation: breathe 1.7s ease-in-out infinite;
452
  }
453
 
454
  .status-region.is-error {
455
+ max-width: 570px;
456
+ padding: 14px 16px;
457
+ border-left: 3px solid var(--rose);
458
+ color: #5e3934;
459
+ background: rgba(201, 143, 131, 0.12);
460
  }
461
 
462
  .entry-art {
463
+ position: relative;
464
+ align-self: stretch;
465
+ min-height: 680px;
466
+ margin: -78px -120px -10px -100px;
467
+ overflow: hidden;
468
  }
469
 
470
  .entry-art::after {
471
+ position: absolute;
472
+ inset: 0;
473
+ pointer-events: none;
474
+ background:
475
+ linear-gradient(90deg, var(--paper) 0%, transparent 22%),
476
+ linear-gradient(0deg, var(--paper) 0%, transparent 12%);
477
+ content: "";
478
  }
479
 
480
  .entry-art img {
481
+ width: 100%;
482
+ height: 100%;
483
+ object-fit: cover;
484
+ object-position: 56% 50%;
485
+ mix-blend-mode: multiply;
486
  }
487
 
488
  .experience-view {
489
+ min-height: calc(100vh - 174px);
490
+ padding: 0 24px 48px;
491
  }
492
 
493
  .path-header {
494
+ max-width: 980px;
495
+ margin: -14px auto 36px;
496
+ text-align: center;
497
  }
498
 
499
  .path-header h1 {
500
+ font-size: clamp(3rem, 5vw, 5.3rem);
501
+ line-height: 1.05;
502
  }
503
 
504
  #clearing-progress {
505
+ margin: 18px 0 4px;
506
+ color: var(--ink-soft);
507
+ font-size: 1rem;
508
+ letter-spacing: 0.08em;
509
+ }
510
+
511
+ .art-style-label {
512
+ min-height: 20px;
513
+ margin: 0 0 10px;
514
+ color: var(--moss-dark);
515
+ font-family: var(--serif);
516
+ font-size: 1rem;
517
  }
518
 
519
  .progress-dots {
520
+ display: flex;
521
+ justify-content: center;
522
+ gap: 18px;
523
+ min-height: 18px;
524
  }
525
 
526
  .progress-dot {
527
+ width: 14px;
528
+ height: 14px;
529
+ border-radius: 50%;
530
+ background: rgba(143, 161, 132, 0.24);
531
+ transition:
532
+ transform 180ms ease,
533
+ background 180ms ease;
534
  }
535
 
536
  .progress-dot.is-complete {
537
+ background: rgba(143, 161, 132, 0.7);
538
  }
539
 
540
  .progress-dot.is-current {
541
+ transform: scale(1.1);
542
+ background: var(--moss-dark);
543
+ }
544
+
545
+ .soundscape-player {
546
+ display: flex;
547
+ align-items: center;
548
+ justify-content: space-between;
549
+ gap: 28px;
550
+ width: min(100%, 900px);
551
+ margin: -12px auto 30px;
552
+ padding: 16px 20px;
553
+ border: 1px solid rgba(83, 104, 61, 0.3);
554
+ border-radius: 8px;
555
+ background: rgba(251, 248, 237, 0.58);
556
+ box-shadow: 0 8px 30px rgba(63, 86, 52, 0.06);
557
+ }
558
+
559
+ .soundscape-note {
560
+ margin: 5px 0 0;
561
+ color: var(--ink-soft);
562
+ font-family: var(--serif);
563
+ font-size: 1rem;
564
+ }
565
+
566
+ .soundscape-player audio {
567
+ width: min(420px, 48vw);
568
+ height: 42px;
569
+ accent-color: var(--moss);
570
  }
571
 
572
  .clearing {
573
+ display: grid;
574
+ grid-template-columns: minmax(460px, 1.05fr) minmax(430px, 0.95fr);
575
+ gap: clamp(52px, 7vw, 118px);
576
+ align-items: center;
577
+ width: min(100%, 1240px);
578
+ margin: 0 auto;
579
  }
580
 
581
  .clearing-art {
582
+ position: relative;
583
+ min-height: 610px;
584
+ margin: 0;
585
  }
586
 
587
  .image-wash {
588
+ position: absolute;
589
+ inset: 10% 2% 2%;
590
+ border-radius: 48% 52% 55% 45%;
591
+ background:
592
+ radial-gradient(
593
+ circle at 43% 45%,
594
+ rgba(182, 197, 190, 0.45),
595
+ transparent 43%
596
+ ),
597
+ radial-gradient(
598
+ circle at 48% 76%,
599
+ rgba(121, 141, 100, 0.2),
600
+ transparent 45%
601
+ );
602
+ filter: blur(13px);
603
  }
604
 
605
  .clearing-art img {
606
+ position: absolute;
607
+ z-index: 1;
608
+ inset: 0;
609
+ width: 100%;
610
+ height: 100%;
611
+ border-radius: 44% 48% 45% 50%;
612
+ object-fit: cover;
613
+ mix-blend-mode: multiply;
614
+ mask-image: radial-gradient(
615
+ ellipse 66% 76% at center,
616
+ black 54%,
617
+ transparent 100%
618
+ );
619
  }
620
 
621
  .clearing-copy {
622
+ max-width: 570px;
623
+ padding: 18px 0 32px;
624
  }
625
 
 
626
  .section-label {
627
+ margin: 0;
628
+ color: var(--moss-dark);
629
+ font-size: 0.79rem;
630
+ font-weight: 650;
631
+ letter-spacing: 0.2em;
632
+ text-transform: uppercase;
633
+ }
634
+
635
+ .scene-title {
636
+ font: 600 14px "Avenir Next", Arial, sans-serif;
637
+ letter-spacing: 0.16em;
638
+ text-transform: uppercase;
639
+ color: #3f5634;
640
+ margin: 0 0 6px;
641
+ }
642
+
643
+ .scene-intro {
644
+ font: italic 19px/1.5 "Iowan Old Style", Georgia, serif;
645
+ color: #4c574f;
646
+ margin: 0 0 18px;
647
  }
648
 
649
+ .narration {
650
+ font: 21px/1.7 "Iowan Old Style", Georgia, serif;
651
+ color: #243c31;
652
+ margin: 0 0 28px;
 
 
653
  }
654
 
655
+ .narration p {
656
+ margin: 0 0 14px;
657
+ }
658
+
659
+ .narration p:last-child {
660
+ margin-bottom: 0;
661
  }
662
 
663
  .carried-question,
664
  .spell-section {
665
+ padding: 26px 2px;
666
+ border-top: 2px solid var(--line);
667
  }
668
 
669
  #reflection,
670
  .spell {
671
+ margin: 10px 0 0;
672
+ font-family: var(--serif);
673
+ font-size: clamp(1.35rem, 2.1vw, 2rem);
674
+ line-height: 1.28;
675
  }
676
 
677
  .spell-section {
678
+ padding-bottom: 20px;
679
  }
680
 
681
  .spell {
682
+ margin-bottom: 14px;
683
  }
684
 
685
  .copy-button {
686
+ display: inline-flex;
687
+ align-items: center;
688
+ gap: 9px;
689
+ min-height: 42px;
690
+ padding: 8px 17px;
691
+ border: 1.4px solid var(--moss-dark);
692
+ border-radius: 5px;
693
+ background: rgba(251, 248, 237, 0.55);
694
+ cursor: pointer;
695
  }
696
 
697
  .copy-button svg {
698
+ width: 20px;
699
+ height: 20px;
700
+ fill: none;
701
+ stroke: currentColor;
702
+ stroke-width: 1.6;
703
  }
704
 
705
  .path-actions {
706
+ display: grid;
707
+ gap: 10px;
708
+ margin-top: 18px;
709
  }
710
 
711
  .save-button {
712
+ background: #6b7048;
713
  }
714
 
715
  .text-button {
716
+ justify-self: center;
717
+ padding: 10px 18px;
718
+ border: 0;
719
+ color: var(--moss-dark);
720
+ background: transparent;
721
+ font-size: 1.02rem;
722
+ cursor: pointer;
723
  }
724
 
725
  .text-button:hover {
726
+ text-decoration: underline;
727
+ text-underline-offset: 4px;
728
  }
729
 
730
  footer {
731
+ width: min(100% - 40px, 1380px);
732
+ margin: 0 auto;
733
+ padding: 16px 0 24px;
734
+ color: rgba(54, 65, 58, 0.78);
735
+ font-size: 0.78rem;
736
+ text-align: center;
737
  }
738
 
739
  .fireflies {
740
+ position: fixed;
741
+ z-index: 4;
742
+ inset: 0;
743
+ pointer-events: none;
744
  }
745
 
746
  .fireflies i {
747
+ position: absolute;
748
+ width: 5px;
749
+ height: 5px;
750
+ border-radius: 50%;
751
+ background: rgba(244, 199, 92, 0.8);
752
+ box-shadow: 0 0 12px rgba(244, 199, 92, 0.85);
753
+ animation: drift 8s ease-in-out infinite;
754
  }
755
 
756
  .fireflies i:nth-child(1) {
757
+ top: 31%;
758
+ left: 78%;
759
  }
760
 
761
  .fireflies i:nth-child(2) {
762
+ top: 65%;
763
+ left: 69%;
764
+ animation-delay: -2s;
765
  }
766
 
767
  .fireflies i:nth-child(3) {
768
+ top: 43%;
769
+ left: 91%;
770
+ animation-delay: -4s;
771
  }
772
 
773
  .fireflies i:nth-child(4) {
774
+ top: 76%;
775
+ left: 85%;
776
+ animation-delay: -6s;
777
  }
778
 
779
  .fireflies i:nth-child(5) {
780
+ top: 56%;
781
+ left: 53%;
782
+ animation-delay: -3s;
783
  }
784
 
785
  [hidden] {
786
+ display: none !important;
787
  }
788
 
789
  button:focus-visible,
790
  input:focus-visible,
791
+ select:focus-visible,
792
  textarea:focus-visible {
793
+ outline: 3px solid rgba(228, 183, 94, 0.72);
794
+ outline-offset: 4px;
795
  }
796
 
797
  @keyframes drift {
798
+ 0%,
799
+ 100% {
800
+ opacity: 0.35;
801
+ transform: translate(0, 0);
802
+ }
803
 
804
+ 45% {
805
+ opacity: 1;
806
+ transform: translate(9px, -14px);
807
+ }
808
  }
809
 
810
  @keyframes breathe {
811
+ 0%,
812
+ 100% {
813
+ transform: scale(0.84);
814
+ opacity: 0.62;
815
+ }
816
 
817
+ 50% {
818
+ transform: scale(1.2);
819
+ opacity: 1;
820
+ }
821
  }
822
 
823
+ @keyframes stepIn {
824
+ from {
825
+ transform: translateY(8px);
826
+ opacity: 0;
827
+ }
828
 
829
+ to {
830
+ transform: translateY(0);
831
+ opacity: 1;
832
+ }
 
 
 
833
  }
834
 
835
+ @media (max-width: 1040px) {
836
+ .entry-view {
837
+ grid-template-columns: minmax(400px, 0.9fr) 1.1fr;
838
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
839
 
840
+ .entry-art {
841
+ margin-left: -150px;
842
+ }
843
 
844
+ .clearing {
845
+ gap: 48px;
846
+ }
847
+ }
848
 
849
+ @media (max-height: 820px) and (min-width: 761px) {
850
+ .site-header {
851
+ height: 72px;
852
+ }
853
+
854
+ .brand {
855
+ gap: 11px;
856
+ font-size: 1.08rem;
857
+ }
858
+
859
+ .brand-mark {
860
+ width: 40px;
861
+ height: 40px;
862
+ }
863
+
864
+ .entry-view {
865
+ min-height: calc(100vh - 112px);
866
+ }
867
+
868
+ .entry-copy {
869
+ padding-top: 12px;
870
+ padding-bottom: 18px;
871
+ }
872
+
873
+ .entry-copy h1 {
874
+ font-size: clamp(3.8rem, 5.8vw, 5rem);
875
+ line-height: 0.8;
876
+ }
877
+
878
+ .intro {
879
+ margin: 16px 0 13px;
880
+ font-size: 1.12rem;
881
+ line-height: 1.38;
882
+ }
883
+
884
+ #forest-form {
885
+ gap: 5px;
886
+ }
887
+
888
+ #forest-form label {
889
+ margin-top: 6px;
890
+ font-size: 0.87rem;
891
+ }
892
+
893
+ #forest-form input {
894
+ height: 46px;
895
+ }
896
+
897
+ #forest-form textarea {
898
+ min-height: 64px;
899
+ padding-top: 11px;
900
+ padding-bottom: 11px;
901
+ }
902
+
903
+ .grow-button {
904
+ min-height: 48px;
905
+ margin-top: 7px;
906
+ font-size: 1.35rem;
907
+ }
908
+
909
+ .status-region {
910
+ min-height: 20px;
911
+ margin-top: 8px;
912
+ font-size: 0.85rem;
913
+ }
914
+
915
+ .entry-art {
916
+ min-height: 570px;
917
+ }
918
+
919
+ .experience-view {
920
+ min-height: calc(100vh - 112px);
921
+ padding-bottom: 6px;
922
+ }
923
+
924
+ .path-header {
925
+ margin: -18px auto 12px;
926
+ }
927
+
928
+ .path-header h1 {
929
+ font-size: clamp(2.8rem, 4.2vw, 3.5rem);
930
+ }
931
+
932
+ #clearing-progress {
933
+ margin: 8px 0 6px;
934
+ font-size: 0.82rem;
935
+ }
936
+
937
+ .art-style-label {
938
+ margin-bottom: 5px;
939
+ font-size: 0.88rem;
940
+ }
941
+
942
+ .soundscape-player {
943
+ margin-bottom: 12px;
944
+ padding: 9px 14px;
945
+ }
946
+
947
+ .progress-dots {
948
+ gap: 14px;
949
+ min-height: 12px;
950
+ }
951
+
952
+ .progress-dot {
953
+ width: 11px;
954
+ height: 11px;
955
+ }
956
+
957
+ .clearing {
958
+ grid-template-columns: minmax(390px, 0.9fr) minmax(520px, 1.1fr);
959
+ gap: 42px;
960
+ }
961
+
962
+ .clearing-art {
963
+ min-height: 475px;
964
+ }
965
+
966
+ .clearing-copy {
967
+ max-width: 640px;
968
+ padding: 0 0 8px;
969
+ }
970
+
971
+ .scene-title {
972
+ margin: 7px 0 4px;
973
+ font-size: 12px;
974
+ }
975
+
976
+ .scene-intro {
977
+ margin-bottom: 12px;
978
+ font-size: 16px;
979
+ line-height: 1.4;
980
+ }
981
+
982
+ .narration {
983
+ margin-bottom: 15px;
984
+ font-size: 17px;
985
+ line-height: 1.55;
986
+ }
987
+
988
+ .carried-question,
989
+ .spell-section {
990
+ padding: 10px 2px;
991
+ }
992
+
993
+ #reflection,
994
+ .spell {
995
+ margin-top: 6px;
996
+ font-size: 1.2rem;
997
+ }
998
+
999
+ .spell {
1000
+ margin-bottom: 8px;
1001
+ }
1002
+
1003
+ .copy-button {
1004
+ min-height: 36px;
1005
+ padding: 6px 14px;
1006
+ }
1007
+
1008
+ .path-actions {
1009
+ gap: 4px;
1010
+ margin-top: 8px;
1011
+ }
1012
+
1013
+ .path-actions .primary-button {
1014
+ min-height: 45px;
1015
+ padding-top: 8px;
1016
+ padding-bottom: 8px;
1017
+ font-size: 1.25rem;
1018
+ }
1019
+
1020
+ .text-button {
1021
+ padding-top: 5px;
1022
+ padding-bottom: 5px;
1023
+ font-size: 0.9rem;
1024
+ }
1025
+
1026
+ footer {
1027
+ padding-top: 7px;
1028
+ padding-bottom: 9px;
1029
+ font-size: 0.7rem;
1030
+ }
1031
  }
1032
 
1033
  @media (max-width: 760px) {
1034
+ .site-header,
1035
+ .view {
1036
+ width: min(calc(100% - 36px), 720px);
1037
+ }
1038
+
1039
+ .step-tracker {
1040
+ grid-template-columns: 1fr;
1041
+ }
1042
+
1043
+ .step-tracker li:not(.is-current) {
1044
+ display: none;
1045
+ }
1046
+
1047
+ .choice-grid.two-column,
1048
+ .choice-grid.three-column {
1049
+ grid-template-columns: 1fr;
1050
+ }
1051
+
1052
+ .step-actions,
1053
+ .split-actions {
1054
+ flex-direction: column-reverse;
1055
+ align-items: stretch;
1056
+ }
1057
+
1058
+ .split-actions .grow-button {
1059
+ width: 100%;
1060
+ }
1061
+
1062
+ .brand {
1063
+ gap: 10px;
1064
+ font-size: 1.05rem;
1065
+ }
1066
+
1067
+ .brand-mark {
1068
+ width: 38px;
1069
+ height: 38px;
1070
+ }
1071
+
1072
+ .view {
1073
+ width: min(calc(100% - 36px), 720px);
1074
+ }
1075
+
1076
+ .entry-view {
1077
+ display: flex;
1078
+ flex-direction: column;
1079
+ min-height: auto;
1080
+ }
1081
+
1082
+ .entry-copy {
1083
+ width: 100%;
1084
+ min-width: 0;
1085
+ max-width: none;
1086
+ padding: 34px 0 24px;
1087
+ }
1088
+
1089
+ .entry-copy h1 {
1090
+ max-width: 100%;
1091
+ font-size: clamp(2.8rem, 11.5vw, 3.5rem);
1092
+ letter-spacing: -0.055em;
1093
+ }
1094
+
1095
+ #forest-form,
1096
+ #forest-form input,
1097
+ #forest-form select,
1098
+ #forest-form textarea {
1099
+ min-width: 0;
1100
+ max-width: 100%;
1101
+ }
1102
+
1103
+ .intro {
1104
+ margin: 28px 0 30px;
1105
+ font-size: 1.2rem;
1106
+ }
1107
+
1108
+ .entry-art {
1109
+ order: -1;
1110
+ align-self: auto;
1111
+ width: calc(100% + 36px);
1112
+ min-height: 310px;
1113
+ margin: -26px -18px -65px;
1114
+ opacity: 0.92;
1115
+ }
1116
+
1117
+ .entry-art::after {
1118
+ background:
1119
+ linear-gradient(0deg, var(--paper) 0%, transparent 42%),
1120
+ linear-gradient(90deg, var(--paper) 0%, transparent 14%);
1121
+ }
1122
+
1123
+ .entry-art img {
1124
+ object-position: 66% 52%;
1125
+ }
1126
+
1127
+ .grow-button {
1128
+ font-size: 1.48rem;
1129
+ }
1130
+
1131
+ .experience-view {
1132
+ padding: 0 0 32px;
1133
+ }
1134
+
1135
+ .path-header {
1136
+ margin: 10px auto 24px;
1137
+ }
1138
+
1139
+ .path-header h1 {
1140
+ font-size: clamp(2.75rem, 13vw, 4.5rem);
1141
+ }
1142
+
1143
+ .soundscape-player {
1144
+ align-items: stretch;
1145
+ flex-direction: column;
1146
+ gap: 12px;
1147
+ margin: -10px auto 24px;
1148
+ }
1149
+
1150
+ .soundscape-player audio {
1151
+ width: 100%;
1152
+ }
1153
+
1154
+ .clearing {
1155
+ display: block;
1156
+ }
1157
+
1158
+ .clearing-art {
1159
+ min-height: 390px;
1160
+ margin: -6px -10px 4px;
1161
+ }
1162
+
1163
+ .clearing-copy {
1164
+ max-width: none;
1165
+ padding: 8px 0 20px;
1166
+ }
1167
+
1168
+ .scene-title {
1169
+ font-size: 13px;
1170
+ }
1171
+
1172
+ .scene-intro {
1173
+ font-size: 17px;
1174
+ }
1175
+
1176
+ .narration {
1177
+ font-size: 19px;
1178
+ line-height: 1.65;
1179
+ }
1180
+
1181
+ #reflection,
1182
+ .spell {
1183
+ font-size: 1.55rem;
1184
+ }
1185
+
1186
+ footer {
1187
+ padding-bottom: 22px;
1188
+ }
1189
  }
1190
 
1191
  @media (prefers-reduced-motion: reduce) {
1192
+ *,
1193
+ *::before,
1194
+ *::after {
1195
+ scroll-behavior: auto !important;
1196
+ animation-duration: 0.01ms !important;
1197
+ animation-iteration-count: 1 !important;
1198
+ transition-duration: 0.01ms !important;
1199
+ }
1200
+
1201
+ .fireflies {
1202
+ display: none;
1203
+ }
1204
  }
requirements.txt CHANGED
@@ -1,4 +1,5 @@
1
  gradio==6.16.0
 
2
  httpx==0.28.1
3
  pillow==12.2.0
4
  pydantic==2.12.5
 
1
  gradio==6.16.0
2
+ huggingface-hub==0.36.2
3
  httpx==0.28.1
4
  pillow==12.2.0
5
  pydantic==2.12.5
src/compliment_forest/backends/image.py CHANGED
@@ -2,15 +2,94 @@ from __future__ import annotations
2
 
3
  import base64
4
  import hashlib
 
5
  import io
6
  import random
 
 
7
  from pathlib import Path
8
- from typing import Protocol
9
 
 
 
10
  from PIL import Image, ImageDraw, ImageFilter, ImageOps
11
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
  STYLE_PREFIX = (
13
- "cmprst_forest, soft watercolor storybook illustration, loose wet-on-wet washes, "
 
14
  "visible cold-press paper grain, no hard outlines, soft feathered edges, "
15
  )
16
  STYLE_SUFFIX = (
@@ -23,11 +102,32 @@ NEGATIVE_PROMPT = (
23
 
24
 
25
  class ImageBackend(Protocol):
26
- def generate(self, prompt: str, seed: int) -> str: ...
 
27
 
 
 
 
 
 
28
 
29
- def compose_flux_prompt(creature_prompt: str) -> str:
30
- return f"{STYLE_PREFIX}{creature_prompt.strip()}{STYLE_SUFFIX}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
 
32
 
33
  def image_to_data_uri(image: Image.Image) -> str:
@@ -37,8 +137,31 @@ def image_to_data_uri(image: Image.Image) -> str:
37
  return f"data:image/png;base64,{encoded}"
38
 
39
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
  class DemoImageBackend:
41
- """Creates stable local artwork for tests and UI development."""
42
 
43
  def __init__(
44
  self,
@@ -48,46 +171,17 @@ class DemoImageBackend:
48
  ) -> None:
49
  self.width = width
50
  self.height = height
51
- self.asset_dir = (
52
- Path(asset_dir)
53
- if asset_dir is not None
54
- else Path(__file__).resolve().parents[3] / "frontend" / "assets"
55
- )
56
-
57
- def _curated_asset(self, prompt: str, seed: int) -> Image.Image | None:
58
- prompt_lower = prompt.casefold()
59
- names = {
60
- "owl": "owl.png",
61
- "snail": "snail.png",
62
- "deer": "deer.png",
63
- "wren": "wren.png",
64
- "fox": "fox-path.png",
65
- }
66
- filename = next(
67
- (asset for keyword, asset in names.items() if keyword in prompt_lower),
68
- "fox-path.png",
69
- )
70
- path = self.asset_dir / filename
71
- if not path.exists():
72
- return None
73
- with Image.open(path) as source:
74
- image = ImageOps.fit(
75
- source.convert("RGB"),
76
- (self.width, self.height),
77
- method=Image.Resampling.LANCZOS,
78
- )
79
- corner = image.getpixel((0, 0))
80
- variation = seed % 3
81
- image.putpixel((0, 0), tuple(min(255, channel + variation) for channel in corner))
82
- return image
83
-
84
- def generate(self, prompt: str, seed: int) -> str:
85
- curated = self._curated_asset(prompt, seed)
86
- if curated is not None:
87
- return image_to_data_uri(curated)
88
 
 
 
 
 
 
 
 
89
  prompt_seed = int.from_bytes(
90
- hashlib.sha256(prompt.encode("utf-8")).digest()[:4],
91
  byteorder="big",
92
  )
93
  rng = random.Random(seed ^ prompt_seed)
@@ -101,6 +195,12 @@ class DemoImageBackend:
101
  (225, 184, 101, 22),
102
  (132, 172, 181, 18),
103
  ]
 
 
 
 
 
 
104
  for _ in range(100):
105
  color = rng.choice(palette)
106
  x = rng.randint(-100, self.width)
@@ -215,13 +315,18 @@ class FluxImageBackend:
215
  self._pipeline = pipeline
216
  return pipeline
217
 
218
- def generate(self, prompt: str, seed: int) -> str:
 
 
 
 
 
219
  import torch
220
 
221
  pipeline = self._load()
222
  generator = torch.Generator(device="cpu").manual_seed(seed)
223
  image = pipeline(
224
- prompt=compose_flux_prompt(prompt),
225
  negative_prompt=NEGATIVE_PROMPT,
226
  width=self.width,
227
  height=self.height,
@@ -229,4 +334,122 @@ class FluxImageBackend:
229
  guidance_scale=3.5,
230
  generator=generator,
231
  ).images[0]
232
- return image_to_data_uri(image)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
 
3
  import base64
4
  import hashlib
5
+ import hmac
6
  import io
7
  import random
8
+ from collections.abc import Callable
9
+ from dataclasses import dataclass
10
  from pathlib import Path
11
+ from typing import Any, Protocol
12
 
13
+ import httpx
14
+ from huggingface_hub import InferenceClient
15
  from PIL import Image, ImageDraw, ImageFilter, ImageOps
16
 
17
+ from compliment_forest.schema import ForestStyle
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class StyleProfile:
22
+ label: str
23
+ trigger: str
24
+ prefix: str
25
+ suffix: str
26
+ negative_prompt: str
27
+
28
+
29
+ _COMMON_NEGATIVE = (
30
+ "text, letters, watermark, logo, frame, collage, duplicate animal, extra limbs, "
31
+ "deformed face, distorted hands, photorealistic stock photo, 3d render"
32
+ )
33
+
34
+ STYLE_PROFILES: dict[str, StyleProfile] = {
35
+ "watercolor": StyleProfile(
36
+ label="Watercolor Storybook",
37
+ trigger="cmprst_watercolor",
38
+ prefix=(
39
+ "cmprst_watercolor, soft watercolor storybook illustration, loose wet-on-wet "
40
+ "washes, visible cold-press paper grain, feathered edges, "
41
+ ),
42
+ suffix=", warm dappled light, muted natural palette, generous negative space",
43
+ negative_prompt=f"hard outlines, neon, oversaturated, {_COMMON_NEGATIVE}",
44
+ ),
45
+ "paper_cut": StyleProfile(
46
+ label="Layered Paper Cut",
47
+ trigger="cmprst_papercut",
48
+ prefix=(
49
+ "cmprst_papercut, handcrafted layered paper-cut illustration, deckled cut "
50
+ "edges, overlapping botanical silhouettes, tactile paper fibers, "
51
+ ),
52
+ suffix=", soft dimensional shadows, moss and ochre palette, clean composition",
53
+ negative_prompt=f"paint splashes, glossy plastic, neon, {_COMMON_NEGATIVE}",
54
+ ),
55
+ "moonlit_gouache": StyleProfile(
56
+ label="Moonlit Gouache",
57
+ trigger="cmprst_moonlit",
58
+ prefix=(
59
+ "cmprst_moonlit, moonlit gouache storybook painting, opaque velvety brushwork, "
60
+ "deep indigo woodland, silver rim light, "
61
+ ),
62
+ suffix=", quiet luminous atmosphere, restrained jewel tones, simple composition",
63
+ negative_prompt=f"daylight, washed-out contrast, digital gradients, {_COMMON_NEGATIVE}",
64
+ ),
65
+ "botanical_ink": StyleProfile(
66
+ label="Botanical Ink Wash",
67
+ trigger="cmprst_inkwash",
68
+ prefix=(
69
+ "cmprst_inkwash, botanical ink-wash illustration, expressive sepia and moss "
70
+ "brush lines, diluted ink blooms, cream washi paper, "
71
+ ),
72
+ suffix=", sparse wildflower details, calm asymmetry, airy negative space",
73
+ negative_prompt=f"thick cartoon outlines, saturated blocks, neon, {_COMMON_NEGATIVE}",
74
+ ),
75
+ }
76
+
77
+ STYLE_LORA_V1_IDS = {
78
+ "watercolor": "thangvip/compliment-forest-watercolor-flux-lora",
79
+ "paper_cut": "thangvip/compliment-forest-paper-cut-flux-lora",
80
+ "moonlit_gouache": "thangvip/compliment-forest-moonlit-gouache-flux-lora",
81
+ "botanical_ink": "thangvip/compliment-forest-botanical-ink-flux-lora",
82
+ }
83
+
84
+ STYLE_LORA_IDS = {
85
+ style: f"{model_id}-v2"
86
+ for style, model_id in STYLE_LORA_V1_IDS.items()
87
+ }
88
+ STYLE_ADAPTER_WEIGHT = 0.45
89
+
90
  STYLE_PREFIX = (
91
+ "cmprst_forest, cmprst_watercolor, soft watercolor storybook illustration, "
92
+ "loose wet-on-wet washes, "
93
  "visible cold-press paper grain, no hard outlines, soft feathered edges, "
94
  )
95
  STYLE_SUFFIX = (
 
102
 
103
 
104
  class ImageBackend(Protocol):
105
+ def generate(self, prompt: str, seed: int, style: ForestStyle) -> str: ...
106
+
107
 
108
+ def resolve_style(style: ForestStyle, seed: int) -> str:
109
+ if style != "surprise":
110
+ return style
111
+ style_ids = tuple(STYLE_PROFILES)
112
+ return style_ids[seed % len(style_ids)]
113
 
114
+
115
+ def compose_flux_prompt(
116
+ creature_prompt: str,
117
+ style: ForestStyle = "watercolor",
118
+ *,
119
+ seed: int = 3407,
120
+ ) -> str:
121
+ resolved = resolve_style(style, seed)
122
+ profile = STYLE_PROFILES[resolved]
123
+ subject = creature_prompt.strip()
124
+ style_language = profile.prefix.removeprefix(f"{profile.trigger}, ").rstrip(", ")
125
+ return (
126
+ f"{profile.trigger}, primary subject: {subject}. "
127
+ "Preserve this exact subject, action, and setting as the clear focal point. "
128
+ "No text, lettering, signature, logo, or watermark anywhere. "
129
+ f"{style_language}{profile.suffix}"
130
+ )
131
 
132
 
133
  def image_to_data_uri(image: Image.Image) -> str:
 
137
  return f"data:image/png;base64,{encoded}"
138
 
139
 
140
+ def clean_generated_image(image: Image.Image) -> Image.Image:
141
+ """Trim common synthetic signature zones while preserving output dimensions."""
142
+ width, height = image.size
143
+ crop = image.crop(
144
+ (
145
+ round(width * 0.035),
146
+ round(height * 0.02),
147
+ round(width * 0.965),
148
+ round(height * 0.935),
149
+ )
150
+ )
151
+ return ImageOps.fit(
152
+ crop,
153
+ image.size,
154
+ method=Image.Resampling.LANCZOS,
155
+ )
156
+
157
+
158
+ def sign_modal_request(key: str, prompt: str, seed: int, style: str) -> str:
159
+ message = f"{prompt}\n{seed}\n{style}".encode()
160
+ return hmac.new(key.encode(), message, hashlib.sha256).hexdigest()
161
+
162
+
163
  class DemoImageBackend:
164
+ """Creates deterministic procedural placeholders for offline development."""
165
 
166
  def __init__(
167
  self,
 
171
  ) -> None:
172
  self.width = width
173
  self.height = height
174
+ self.asset_dir = Path(asset_dir) if asset_dir is not None else None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
175
 
176
+ def generate(
177
+ self,
178
+ prompt: str,
179
+ seed: int,
180
+ style: ForestStyle = "surprise",
181
+ ) -> str:
182
+ resolved_style = resolve_style(style, seed)
183
  prompt_seed = int.from_bytes(
184
+ hashlib.sha256(f"{resolved_style}:{prompt}".encode()).digest()[:4],
185
  byteorder="big",
186
  )
187
  rng = random.Random(seed ^ prompt_seed)
 
195
  (225, 184, 101, 22),
196
  (132, 172, 181, 18),
197
  ]
198
+ if resolved_style == "moonlit_gouache":
199
+ palette = [(36, 49, 92, 42), (70, 82, 124, 36), (194, 184, 151, 26)]
200
+ elif resolved_style == "paper_cut":
201
+ palette = [(83, 104, 61, 40), (205, 151, 96, 34), (201, 143, 131, 28)]
202
+ elif resolved_style == "botanical_ink":
203
+ palette = [(73, 79, 55, 32), (120, 96, 67, 28), (148, 161, 132, 20)]
204
  for _ in range(100):
205
  color = rng.choice(palette)
206
  x = rng.randint(-100, self.width)
 
315
  self._pipeline = pipeline
316
  return pipeline
317
 
318
+ def generate(
319
+ self,
320
+ prompt: str,
321
+ seed: int,
322
+ style: ForestStyle = "watercolor",
323
+ ) -> str:
324
  import torch
325
 
326
  pipeline = self._load()
327
  generator = torch.Generator(device="cpu").manual_seed(seed)
328
  image = pipeline(
329
+ prompt=compose_flux_prompt(prompt, style, seed=seed),
330
  negative_prompt=NEGATIVE_PROMPT,
331
  width=self.width,
332
  height=self.height,
 
334
  guidance_scale=3.5,
335
  generator=generator,
336
  ).images[0]
337
+ return image_to_data_uri(clean_generated_image(image.convert("RGB")))
338
+
339
+
340
+ class HfInferenceImageBackend:
341
+ """Fresh FLUX.1-schnell generation through Hugging Face Inference Providers."""
342
+
343
+ def __init__(
344
+ self,
345
+ model: str = "black-forest-labs/FLUX.1-schnell",
346
+ *,
347
+ client: Any | None = None,
348
+ provider: str = "auto",
349
+ width: int = 768,
350
+ height: int = 768,
351
+ steps: int = 4,
352
+ timeout: float = 180,
353
+ ) -> None:
354
+ self.model = model
355
+ self.client = client or InferenceClient(provider=provider, timeout=timeout)
356
+ self.width = width
357
+ self.height = height
358
+ self.steps = steps
359
+
360
+ def generate(
361
+ self,
362
+ prompt: str,
363
+ seed: int,
364
+ style: ForestStyle = "surprise",
365
+ ) -> str:
366
+ resolved = resolve_style(style, seed)
367
+ profile = STYLE_PROFILES[resolved]
368
+ image = self.client.text_to_image(
369
+ compose_flux_prompt(prompt, resolved, seed=seed),
370
+ model=self.model,
371
+ negative_prompt=profile.negative_prompt,
372
+ width=self.width,
373
+ height=self.height,
374
+ num_inference_steps=self.steps,
375
+ guidance_scale=0.0,
376
+ seed=seed,
377
+ )
378
+ return image_to_data_uri(clean_generated_image(image.convert("RGB")))
379
+
380
+
381
+ class ZeroGpuImageBackend:
382
+ """Route resolved styles to a Space GPU function with a hosted fallback."""
383
+
384
+ def __init__(
385
+ self,
386
+ generator: Callable[[str, int, str], str],
387
+ *,
388
+ fallback: ImageBackend | None = None,
389
+ ) -> None:
390
+ self.generator = generator
391
+ self.fallback = fallback
392
+
393
+ def generate(
394
+ self,
395
+ prompt: str,
396
+ seed: int,
397
+ style: ForestStyle = "surprise",
398
+ ) -> str:
399
+ resolved = resolve_style(style, seed)
400
+ try:
401
+ return self.generator(prompt, seed, resolved)
402
+ except Exception:
403
+ if self.fallback is None:
404
+ raise
405
+ return self.fallback.generate(prompt, seed, resolved)
406
+
407
+
408
+ class ModalImageBackend:
409
+ """Call the private-token Modal service that hosts the trained adapters."""
410
+
411
+ def __init__(
412
+ self,
413
+ endpoint: str,
414
+ signing_key: str,
415
+ *,
416
+ client: Any | None = None,
417
+ fallback: ImageBackend | None = None,
418
+ timeout: float = 600,
419
+ ) -> None:
420
+ self.endpoint = endpoint
421
+ self.signing_key = signing_key
422
+ self.client = client or httpx.Client(timeout=timeout, follow_redirects=True)
423
+ self.fallback = fallback
424
+
425
+ def generate(
426
+ self,
427
+ prompt: str,
428
+ seed: int,
429
+ style: ForestStyle = "surprise",
430
+ ) -> str:
431
+ resolved = resolve_style(style, seed)
432
+ try:
433
+ response = self.client.post(
434
+ self.endpoint,
435
+ json={
436
+ "prompt": prompt,
437
+ "seed": seed,
438
+ "style": resolved,
439
+ "signature": sign_modal_request(
440
+ self.signing_key,
441
+ prompt,
442
+ seed,
443
+ resolved,
444
+ ),
445
+ },
446
+ )
447
+ response.raise_for_status()
448
+ image = response.json()["image"]
449
+ if not isinstance(image, str) or not image.startswith("data:image/png;base64,"):
450
+ raise ValueError("Modal image response is not a PNG data URI")
451
+ return image
452
+ except Exception:
453
+ if self.fallback is None:
454
+ raise
455
+ return self.fallback.generate(prompt, seed, resolved)
src/compliment_forest/backends/music.py ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import hashlib
5
+ import hmac
6
+ from collections.abc import Mapping
7
+ from typing import Any, Protocol
8
+
9
+ import httpx
10
+
11
+
12
+ class MusicBackend(Protocol):
13
+ def generate(self, prompt: str, seed: int) -> str: ...
14
+
15
+
16
+ _STYLE_INSTRUMENTS = {
17
+ "watercolor": "felt piano, breathy woodwinds, and soft brushed percussion",
18
+ "paper_cut": "gentle plucked strings, marimba, and quiet hand percussion",
19
+ "moonlit_gouache": "warm synth pads, celesta, and low soft strings in a moonlit mood",
20
+ "botanical_ink": "breathy flute, sparse harp, and soft bowed strings",
21
+ }
22
+
23
+
24
+ def build_music_prompt(
25
+ forest: Mapping[str, object],
26
+ style: str,
27
+ ) -> str:
28
+ strengths = forest.get("proposed_strengths")
29
+ themes = ""
30
+ if isinstance(strengths, list):
31
+ themes = ", ".join(str(item) for item in strengths[:4])
32
+ instruments = _STYLE_INSTRUMENTS.get(
33
+ style,
34
+ "felt piano, soft strings, and airy ambient textures",
35
+ )
36
+ theme_text = themes or "care, choice, and one gentle next step"
37
+ return (
38
+ "A 16-second instrumental storybook ambient soundscape with no vocals or lyrics. "
39
+ "Sparse, calm, low dynamic range, no sudden impacts, alarms, or dramatic climax. "
40
+ "The harmony begins slightly unresolved and settles into warmth, leaving room for "
41
+ f"reading. Use {instruments}. Emotional themes: {theme_text}. "
42
+ "Do not imitate any artist."
43
+ )
44
+
45
+
46
+ def sign_music_request(key: str, prompt: str, seed: int) -> str:
47
+ message = f"{prompt}\n{seed}".encode()
48
+ return hmac.new(key.encode(), message, hashlib.sha256).hexdigest()
49
+
50
+
51
+ def validate_wav_data_uri(uri: str) -> str:
52
+ prefix = "data:audio/wav;base64,"
53
+ if not uri.startswith(prefix):
54
+ raise ValueError("music response is not a WAV data URI")
55
+ try:
56
+ payload = base64.b64decode(uri.removeprefix(prefix), validate=True)
57
+ except ValueError as error:
58
+ raise ValueError("music response contains invalid base64") from error
59
+ if not payload.startswith(b"RIFF") or payload[8:12] != b"WAVE":
60
+ raise ValueError("music response does not contain WAV bytes")
61
+ return uri
62
+
63
+
64
+ class NoMusicBackend:
65
+ def generate(self, prompt: str, seed: int) -> str:
66
+ return ""
67
+
68
+
69
+ class ModalMusicBackend:
70
+ """Call the private-token Modal service hosting MusicGen-small."""
71
+
72
+ def __init__(
73
+ self,
74
+ endpoint: str,
75
+ signing_key: str,
76
+ *,
77
+ client: Any | None = None,
78
+ timeout: float = 240,
79
+ ) -> None:
80
+ self.endpoint = endpoint
81
+ self.signing_key = signing_key
82
+ self.client = client or httpx.Client(timeout=timeout)
83
+
84
+ def generate(self, prompt: str, seed: int) -> str:
85
+ response = self.client.post(
86
+ self.endpoint,
87
+ json={
88
+ "prompt": prompt,
89
+ "seed": seed,
90
+ "signature": sign_music_request(
91
+ self.signing_key,
92
+ prompt,
93
+ seed,
94
+ ),
95
+ },
96
+ )
97
+ response.raise_for_status()
98
+ audio = response.json()["audio"]
99
+ if not isinstance(audio, str):
100
+ raise ValueError("Modal music response did not contain audio")
101
+ return validate_wav_data_uri(audio)
src/compliment_forest/backends/text.py CHANGED
@@ -1,97 +1,284 @@
1
  from __future__ import annotations
2
 
 
 
3
  import json
4
- from typing import Protocol
 
5
  from urllib.parse import urlparse
6
 
7
  import httpx
 
8
 
9
- from compliment_forest.prompts import author_messages, critic_messages
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
 
11
 
12
  class TextBackend(Protocol):
 
 
 
 
 
 
 
 
 
 
 
 
13
  def author(
14
  self,
15
  name: str,
16
  situation: str,
17
  *,
 
18
  feedback: dict[str, str] | None = None,
19
  original: dict[str, object] | None = None,
 
20
  ) -> str: ...
21
 
22
- def critic(self, name: str, situation: str, forest: dict[str, object]) -> str: ...
 
 
 
 
 
 
 
 
23
 
24
 
25
  class DemoTextBackend:
26
- """Deterministic development backend with the same model contract."""
27
 
28
- _CLEARINGS = (
 
 
 
29
  (
30
- "The Patient Fox",
31
- "patience under pressure",
32
- "a gentle russet fox sitting in a mossy clearing, soft kind eyes",
33
- "I am allowed to learn.",
 
 
 
 
 
 
 
 
34
  ),
35
  (
36
- "The Listening Owl",
37
- "careful perspective",
38
- "a round tawny owl resting on a low branch, attentive kind eyes",
39
- "I can listen before I leap.",
 
 
 
 
 
 
 
 
40
  ),
41
  (
42
- "The Brave Snail",
43
- "quiet courage",
44
- "a tiny snail crossing a fern frond, softly glowing spiral shell",
45
- "I make progress at my pace.",
 
 
 
 
 
 
 
 
46
  ),
47
  (
48
- "The Steady Deer",
49
- "steadiness through change",
50
- "a small deer standing in morning mist, calm gentle expression",
51
- "I can meet one moment.",
 
 
 
 
 
 
 
 
 
 
 
52
  ),
53
  (
54
- "The Clear-Voiced Wren",
55
- "honest self-expression",
56
- "a tiny wren singing beside dusty rose wildflowers",
57
- "I can speak gently and clearly.",
 
 
 
 
 
 
 
 
 
 
 
58
  ),
59
  )
60
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
61
  def author(
62
  self,
63
  name: str,
64
  situation: str,
65
  *,
 
66
  feedback: dict[str, str] | None = None,
67
  original: dict[str, object] | None = None,
 
68
  ) -> str:
69
- clearings = []
70
- for creature, strength, image_prompt, spell in self._CLEARINGS:
71
- clearings.append(
72
- {
73
- "creature": creature,
74
- "strength": strength,
75
- "line": (
76
- f"{situation.rstrip('.')} may feel uncertain. Your {strength} "
77
- "lets you respond without pretending the hard part is easy."
78
- ),
79
- "reflection": (
80
- "What becomes possible when you ask for one honest next step "
81
- "instead of a perfect answer?"
82
- ),
83
- "spell": spell,
84
- "image_prompt": image_prompt,
85
- }
86
- )
87
- payload = {
88
- "forest_title": f"{name}'s Path Through the New",
89
- "proposed_strengths": [item[1] for item in self._CLEARINGS],
90
- "clearings": clearings,
91
- }
92
- return json.dumps(payload)
 
 
 
 
 
 
 
 
 
 
 
 
 
93
 
94
- def critic(self, name: str, situation: str, forest: dict[str, object]) -> str:
 
 
 
 
 
 
 
 
95
  count = min(len(forest.get("clearings", [])), 5)
96
  return json.dumps(
97
  {
@@ -116,37 +303,469 @@ class LlamaCppTextBackend:
116
  raise ValueError("llama.cpp base URL must resolve to the local machine")
117
  self.base_url = base_url.rstrip("/")
118
  self.model = model
119
- self.client = httpx.Client(timeout=timeout)
120
 
121
- def _complete(self, messages: list[dict[str, str]], max_tokens: int) -> str:
 
 
 
 
 
 
 
122
  response = self.client.post(
123
  f"{self.base_url}/v1/chat/completions",
124
  json={
125
  "model": self.model,
126
  "messages": messages,
127
- "temperature": 0.35,
128
  "top_p": 0.9,
129
  "max_tokens": max_tokens,
 
130
  "response_format": {"type": "json_object"},
131
- "chat_template_kwargs": {"enable_thinking": False},
132
  },
133
  )
134
  response.raise_for_status()
135
  payload = response.json()
136
  return payload["choices"][0]["message"]["content"]
137
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
138
  def author(
139
  self,
140
  name: str,
141
  situation: str,
142
  *,
 
143
  feedback: dict[str, str] | None = None,
144
  original: dict[str, object] | None = None,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
145
  ) -> str:
146
  return self._complete(
147
- author_messages(name, situation, feedback=feedback, original=original),
148
- max_tokens=1700,
 
 
149
  )
150
 
151
- def critic(self, name: str, situation: str, forest: dict[str, object]) -> str:
152
- return self._complete(critic_messages(name, situation, forest), max_tokens=800)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  from __future__ import annotations
2
 
3
+ import hashlib
4
+ import hmac
5
  import json
6
+ from collections.abc import Callable
7
+ from typing import Any, Protocol
8
  from urllib.parse import urlparse
9
 
10
  import httpx
11
+ from huggingface_hub import InferenceClient
12
 
13
+ from compliment_forest.prompts import (
14
+ author_messages,
15
+ critic_messages,
16
+ intake_messages,
17
+ planner_messages,
18
+ )
19
+
20
+
21
+ def sign_modal_text_request(
22
+ key: str,
23
+ messages: list[dict[str, str]],
24
+ *,
25
+ max_new_tokens: int,
26
+ temperature: float,
27
+ top_p: float,
28
+ seed: int,
29
+ enable_thinking: bool,
30
+ ) -> str:
31
+ """HMAC the load-bearing request fields. Mirrors serve_minicpm.sign_text_request."""
32
+ body = "\n".join(f"{m['role']}\x1f{m['content']}" for m in messages)
33
+ payload = f"{body}\n{max_new_tokens}\n{temperature}\n{top_p}\n{seed}\n{int(enable_thinking)}"
34
+ return hmac.new(key.encode(), payload.encode(), hashlib.sha256).hexdigest()
35
+
36
+
37
+ _DEMO_INTAKE_QUESTIONS: tuple[dict[str, object], ...] = (
38
+ {
39
+ "question": "Which part of this feels loudest right now?",
40
+ "options": [
41
+ "What might go wrong",
42
+ "What other people will think",
43
+ "The unknown right after this",
44
+ ],
45
+ },
46
+ {
47
+ "question": "When does this feel hardest?",
48
+ "options": [
49
+ "When I have time alone with it",
50
+ "When I'm around the people involved",
51
+ "Right before I have to act on it",
52
+ ],
53
+ },
54
+ {
55
+ "question": "Who else feels part of this for you?",
56
+ "options": [
57
+ "Just me",
58
+ "One specific person",
59
+ "A group I belong to",
60
+ ],
61
+ },
62
+ {
63
+ "question": "What feels most at stake if this does not go well?",
64
+ "options": [
65
+ "How I see myself",
66
+ "How others see me",
67
+ "Time or chances I cannot get back",
68
+ ],
69
+ },
70
+ {
71
+ "question": "What would a small win here look like?",
72
+ "options": [
73
+ "Just getting through it",
74
+ "Doing one part well",
75
+ "Knowing I was honest about how I felt",
76
+ ],
77
+ },
78
+ )
79
 
80
 
81
  class TextBackend(Protocol):
82
+ def next_intake_question(
83
+ self,
84
+ name: str,
85
+ situation: str,
86
+ history: list[dict[str, str]],
87
+ *,
88
+ rejected_questions: list[str] | None = None,
89
+ seed: int = 3407,
90
+ ) -> str: ...
91
+
92
+ def plan(self, name: str, situation: str, *, seed: int = 3407) -> str: ...
93
+
94
  def author(
95
  self,
96
  name: str,
97
  situation: str,
98
  *,
99
+ plan: dict[str, object],
100
  feedback: dict[str, str] | None = None,
101
  original: dict[str, object] | None = None,
102
+ seed: int = 3407,
103
  ) -> str: ...
104
 
105
+ def critic(
106
+ self,
107
+ name: str,
108
+ situation: str,
109
+ forest: dict[str, object],
110
+ *,
111
+ plan: dict[str, object],
112
+ seed: int = 3407,
113
+ ) -> str: ...
114
 
115
 
116
  class DemoTextBackend:
117
+ """Deterministic development backend with the storytelling model contract."""
118
 
119
+ # Each narration uses {situation} for at least one direct reference so the
120
+ # demo backend can satisfy the orchestrator's grounding and source-phrase
121
+ # checks without relying on the previous deterministic fallback path.
122
+ _CHAPTERS = (
123
  (
124
+ "arrive",
125
+ "The Threshold in Mist",
126
+ "The path opens quietly, asking nothing of you yet.",
127
+ "honesty about uncertainty",
128
+ (
129
+ "{situation} sounds painful because it touches something that matters"
130
+ " to you.\n\nYou do not need to argue with the feeling before looking"
131
+ " at the problem."
132
+ ),
133
+ "Which part of this hurts most right now?",
134
+ "I can name the part that hurts.",
135
+ "a quiet path reaching a misty threshold with a soft opening ahead",
136
  ),
137
  (
138
+ "steady",
139
+ "The Lantern Beside the Map",
140
+ "A small light is already keeping pace beside the path.",
141
+ "careful attention",
142
+ (
143
+ "A worried thought can feel like a fact when it repeats often."
144
+ "\n\nIt can help to ask what happened, then separate it from what"
145
+ " fear predicts will happen next."
146
+ ),
147
+ "What is a fact here, and what is still a prediction?",
148
+ "I can separate facts from predictions.",
149
+ "a small lantern beside an unfolded map on a quiet wooden table",
150
  ),
151
  (
152
+ "widen",
153
+ "The Window and the Horizon",
154
+ "The path lifts, and the room around the worry begins to feel taller.",
155
+ "room for more than one outcome",
156
+ (
157
+ "One hard moment can have several explanations. It may show a skill"
158
+ " to practice, a question to ask, or a standard that was too harsh."
159
+ "\n\nYou can compare those options before choosing the worst one."
160
+ ),
161
+ "Which explanation fits the facts best?",
162
+ "I can consider more than one explanation.",
163
+ "an open window looking toward a broad horizon after light rain",
164
  ),
165
  (
166
+ "step",
167
+ "The First Stepping Stone",
168
+ "The horizon settles back into the path beneath the feet.",
169
+ "permission to move without certainty",
170
+ (
171
+ "You could write down the smallest question this situation raises."
172
+ "\n\nThen choose one safe way to get more information before making"
173
+ " a larger decision."
174
+ ),
175
+ "What question would give you the most useful information?",
176
+ "I can take one useful next step.",
177
+ (
178
+ "an adult seen from behind pausing before the first stepping stone "
179
+ "across a shallow stream"
180
+ ),
181
  ),
182
  (
183
+ "carry",
184
+ "The Quiet Companion",
185
+ (
186
+ "The walk begins to turn back toward the ordinary day, "
187
+ "but it does not turn back alone."
188
+ ),
189
+ "a simple plan",
190
+ (
191
+ "A simple rule can make the next hard moment easier to handle:"
192
+ " name the fact, name the fear, then choose one useful action."
193
+ "\n\nYou do not need to solve the whole problem at once."
194
+ ),
195
+ "Which part of that rule would help you first?",
196
+ "I can name one fact and one next step.",
197
+ "a small fox-shaped silhouette walking quietly beside an adult at sunset",
198
  ),
199
  )
200
 
201
+ def next_intake_question(
202
+ self,
203
+ name: str,
204
+ situation: str,
205
+ history: list[dict[str, str]],
206
+ *,
207
+ rejected_questions: list[str] | None = None,
208
+ seed: int = 3407,
209
+ ) -> str:
210
+ index = min(len(history), len(_DEMO_INTAKE_QUESTIONS) - 1)
211
+ payload = dict(_DEMO_INTAKE_QUESTIONS[index])
212
+ payload["rationale"] = "deterministic demo intake question"
213
+ return json.dumps(payload)
214
+
215
+ def plan(self, name: str, situation: str, *, seed: int = 3407) -> str:
216
+ return json.dumps(
217
+ {
218
+ "faithful_summary": f"{name} is carrying this situation: {situation}",
219
+ "fact_anchors": [{"source_phrase": situation, "meaning": situation}],
220
+ "central_uncertainty": "What will happen next",
221
+ "desired_direction": "Meet the next part with care and agency",
222
+ }
223
+ )
224
+
225
  def author(
226
  self,
227
  name: str,
228
  situation: str,
229
  *,
230
+ plan: dict[str, object],
231
  feedback: dict[str, str] | None = None,
232
  original: dict[str, object] | None = None,
233
+ seed: int = 3407,
234
  ) -> str:
235
+ anchors = plan.get("fact_anchors") or []
236
+ source_phrase = situation
237
+ if isinstance(anchors, list) and anchors:
238
+ first = anchors[0]
239
+ if isinstance(first, dict):
240
+ source_phrase = str(first.get("source_phrase") or situation)
241
+ situation_phrase = f"“{source_phrase}”" if len(source_phrase) <= 80 else source_phrase
242
+ clearings = [
243
+ {
244
+ "arc_role": arc_role,
245
+ "source_phrase": source_phrase,
246
+ "scene_title": scene_title,
247
+ "scene_intro": scene_intro,
248
+ "strength": strength,
249
+ "narration": narration.format(situation=situation_phrase),
250
+ "reflection": reflection,
251
+ "spell": spell,
252
+ "image_prompt": image_prompt,
253
+ }
254
+ for (
255
+ arc_role,
256
+ scene_title,
257
+ scene_intro,
258
+ strength,
259
+ narration,
260
+ reflection,
261
+ spell,
262
+ image_prompt,
263
+ ) in self._CHAPTERS
264
+ ]
265
+ return json.dumps(
266
+ {
267
+ "forest_title": f"{name}'s Path Through the New",
268
+ "proposed_strengths": [chapter[3] for chapter in self._CHAPTERS[:5]],
269
+ "clearings": clearings,
270
+ }
271
+ )
272
 
273
+ def critic(
274
+ self,
275
+ name: str,
276
+ situation: str,
277
+ forest: dict[str, object],
278
+ *,
279
+ plan: dict[str, object],
280
+ seed: int = 3407,
281
+ ) -> str:
282
  count = min(len(forest.get("clearings", [])), 5)
283
  return json.dumps(
284
  {
 
303
  raise ValueError("llama.cpp base URL must resolve to the local machine")
304
  self.base_url = base_url.rstrip("/")
305
  self.model = model
306
+ self.client = httpx.Client(timeout=timeout, follow_redirects=True)
307
 
308
+ def _complete(
309
+ self,
310
+ messages: list[dict[str, str]],
311
+ max_tokens: int,
312
+ *,
313
+ seed: int,
314
+ temperature: float,
315
+ ) -> str:
316
  response = self.client.post(
317
  f"{self.base_url}/v1/chat/completions",
318
  json={
319
  "model": self.model,
320
  "messages": messages,
321
+ "temperature": temperature,
322
  "top_p": 0.9,
323
  "max_tokens": max_tokens,
324
+ "seed": seed,
325
  "response_format": {"type": "json_object"},
326
+ "chat_template_kwargs": {"enable_thinking": True},
327
  },
328
  )
329
  response.raise_for_status()
330
  payload = response.json()
331
  return payload["choices"][0]["message"]["content"]
332
 
333
+ def next_intake_question(
334
+ self,
335
+ name: str,
336
+ situation: str,
337
+ history: list[dict[str, str]],
338
+ *,
339
+ rejected_questions: list[str] | None = None,
340
+ seed: int = 3407,
341
+ ) -> str:
342
+ return self._complete(
343
+ intake_messages(
344
+ name,
345
+ situation,
346
+ history=history,
347
+ rejected_questions=rejected_questions,
348
+ seed=seed,
349
+ ),
350
+ max_tokens=8192,
351
+ seed=seed,
352
+ temperature=0.4,
353
+ )
354
+
355
+ def plan(
356
+ self,
357
+ name: str,
358
+ situation: str,
359
+ *,
360
+ seed: int = 3407,
361
+ ) -> str:
362
+ return self._complete(
363
+ planner_messages(name, situation, seed=seed),
364
+ max_tokens=8192,
365
+ seed=seed,
366
+ temperature=0.2,
367
+ )
368
+
369
  def author(
370
  self,
371
  name: str,
372
  situation: str,
373
  *,
374
+ plan: dict[str, object],
375
  feedback: dict[str, str] | None = None,
376
  original: dict[str, object] | None = None,
377
+ seed: int = 3407,
378
+ ) -> str:
379
+ return self._complete(
380
+ author_messages(
381
+ name,
382
+ situation,
383
+ plan=plan,
384
+ feedback=feedback,
385
+ original=original,
386
+ seed=seed,
387
+ ),
388
+ max_tokens=8192,
389
+ seed=seed,
390
+ temperature=0.65,
391
+ )
392
+
393
+ def critic(
394
+ self,
395
+ name: str,
396
+ situation: str,
397
+ forest: dict[str, object],
398
+ *,
399
+ plan: dict[str, object],
400
+ seed: int = 3407,
401
  ) -> str:
402
  return self._complete(
403
+ critic_messages(name, situation, forest, plan=plan, seed=seed),
404
+ max_tokens=8192,
405
+ seed=seed,
406
+ temperature=0.15,
407
  )
408
 
409
+
410
+ class HfInferenceTextBackend:
411
+ """Hosted chat completion backend for the development Space."""
412
+
413
+ def __init__(
414
+ self,
415
+ model: str = "openbmb/MiniCPM4.1-8B",
416
+ *,
417
+ client: Any | None = None,
418
+ provider: str = "auto",
419
+ timeout: float = 120,
420
+ ) -> None:
421
+ self.model = model
422
+ self.client = client or InferenceClient(provider=provider, timeout=timeout)
423
+
424
+ @staticmethod
425
+ def _content(response: Any) -> str:
426
+ content = response.choices[0].message.content
427
+ if not isinstance(content, str):
428
+ raise ValueError("hosted model response did not contain text")
429
+ return content
430
+
431
+ def next_intake_question(
432
+ self,
433
+ name: str,
434
+ situation: str,
435
+ history: list[dict[str, str]],
436
+ *,
437
+ rejected_questions: list[str] | None = None,
438
+ seed: int = 3407,
439
+ ) -> str:
440
+ response = self.client.chat_completion(
441
+ model=self.model,
442
+ messages=intake_messages(
443
+ name,
444
+ situation,
445
+ history=history,
446
+ rejected_questions=rejected_questions,
447
+ seed=seed,
448
+ ),
449
+ max_tokens=8192,
450
+ temperature=0.4,
451
+ top_p=0.9,
452
+ seed=seed,
453
+ response_format={"type": "json_object"},
454
+ chat_template_kwargs={"enable_thinking": True},
455
+ )
456
+ return self._content(response)
457
+
458
+ def plan(
459
+ self,
460
+ name: str,
461
+ situation: str,
462
+ *,
463
+ seed: int = 3407,
464
+ ) -> str:
465
+ response = self.client.chat_completion(
466
+ model=self.model,
467
+ messages=planner_messages(name, situation, seed=seed),
468
+ max_tokens=8192,
469
+ temperature=0.2,
470
+ top_p=0.9,
471
+ seed=seed,
472
+ response_format={"type": "json_object"},
473
+ chat_template_kwargs={"enable_thinking": True},
474
+ )
475
+ return self._content(response)
476
+
477
+ def author(
478
+ self,
479
+ name: str,
480
+ situation: str,
481
+ *,
482
+ plan: dict[str, object],
483
+ feedback: dict[str, str] | None = None,
484
+ original: dict[str, object] | None = None,
485
+ seed: int = 3407,
486
+ ) -> str:
487
+ response = self.client.chat_completion(
488
+ model=self.model,
489
+ messages=author_messages(
490
+ name,
491
+ situation,
492
+ plan=plan,
493
+ feedback=feedback,
494
+ original=original,
495
+ seed=seed,
496
+ ),
497
+ max_tokens=8192,
498
+ temperature=0.82,
499
+ top_p=0.92,
500
+ seed=seed,
501
+ response_format={"type": "json_object"},
502
+ chat_template_kwargs={"enable_thinking": True},
503
+ )
504
+ return self._content(response)
505
+
506
+ def critic(
507
+ self,
508
+ name: str,
509
+ situation: str,
510
+ forest: dict[str, object],
511
+ *,
512
+ plan: dict[str, object],
513
+ seed: int = 3407,
514
+ ) -> str:
515
+ response = self.client.chat_completion(
516
+ model=self.model,
517
+ messages=critic_messages(name, situation, forest, plan=plan, seed=seed),
518
+ max_tokens=8192,
519
+ temperature=0.15,
520
+ top_p=0.9,
521
+ seed=seed,
522
+ response_format={"type": "json_object"},
523
+ chat_template_kwargs={"enable_thinking": True},
524
+ )
525
+ return self._content(response)
526
+
527
+
528
+ class TransformersTextBackend:
529
+ """Routes prompts through an externally provided GPU generator closure."""
530
+
531
+ def __init__(
532
+ self,
533
+ model: str = "openbmb/MiniCPM4.1-8B",
534
+ *,
535
+ generator: Callable[[list[dict[str, str]], dict[str, object]], str],
536
+ ) -> None:
537
+ self.model = model
538
+ self._generate = generator
539
+
540
+ def _call(
541
+ self,
542
+ messages: list[dict[str, str]],
543
+ *,
544
+ max_new_tokens: int,
545
+ temperature: float,
546
+ top_p: float,
547
+ seed: int,
548
+ ) -> str:
549
+ params: dict[str, object] = {
550
+ "max_new_tokens": max_new_tokens,
551
+ "temperature": temperature,
552
+ "top_p": top_p,
553
+ "seed": seed,
554
+ "chat_template_kwargs": {"enable_thinking": True},
555
+ }
556
+ return self._generate(messages, params)
557
+
558
+ def next_intake_question(
559
+ self,
560
+ name: str,
561
+ situation: str,
562
+ history: list[dict[str, str]],
563
+ *,
564
+ rejected_questions: list[str] | None = None,
565
+ seed: int = 3407,
566
+ ) -> str:
567
+ return self._call(
568
+ intake_messages(
569
+ name,
570
+ situation,
571
+ history=history,
572
+ rejected_questions=rejected_questions,
573
+ seed=seed,
574
+ ),
575
+ max_new_tokens=8192,
576
+ temperature=0.4,
577
+ top_p=0.9,
578
+ seed=seed,
579
+ )
580
+
581
+ def plan(self, name: str, situation: str, *, seed: int = 3407) -> str:
582
+ return self._call(
583
+ planner_messages(name, situation, seed=seed),
584
+ max_new_tokens=8192,
585
+ temperature=0.2,
586
+ top_p=0.9,
587
+ seed=seed,
588
+ )
589
+
590
+ def author(
591
+ self,
592
+ name: str,
593
+ situation: str,
594
+ *,
595
+ plan: dict[str, object],
596
+ feedback: dict[str, str] | None = None,
597
+ original: dict[str, object] | None = None,
598
+ seed: int = 3407,
599
+ ) -> str:
600
+ return self._call(
601
+ author_messages(
602
+ name,
603
+ situation,
604
+ plan=plan,
605
+ feedback=feedback,
606
+ original=original,
607
+ seed=seed,
608
+ ),
609
+ max_new_tokens=8192,
610
+ temperature=0.82,
611
+ top_p=0.92,
612
+ seed=seed,
613
+ )
614
+
615
+ def critic(
616
+ self,
617
+ name: str,
618
+ situation: str,
619
+ forest: dict[str, object],
620
+ *,
621
+ plan: dict[str, object],
622
+ seed: int = 3407,
623
+ ) -> str:
624
+ return self._call(
625
+ critic_messages(name, situation, forest, plan=plan, seed=seed),
626
+ max_new_tokens=8192,
627
+ temperature=0.15,
628
+ top_p=0.9,
629
+ seed=seed,
630
+ )
631
+
632
+
633
+ class ModalTextBackend:
634
+ """Call the private-token Modal service that hosts MiniCPM4.1-8B."""
635
+
636
+ def __init__(
637
+ self,
638
+ endpoint: str,
639
+ signing_key: str,
640
+ *,
641
+ client: Any | None = None,
642
+ timeout: float = 600,
643
+ ) -> None:
644
+ if urlparse(endpoint).scheme != "https":
645
+ raise ValueError("modal text endpoint must use HTTPS")
646
+ self.endpoint = endpoint
647
+ self.signing_key = signing_key
648
+ self.client = client or httpx.Client(timeout=timeout, follow_redirects=True)
649
+
650
+ def _call(
651
+ self,
652
+ messages: list[dict[str, str]],
653
+ *,
654
+ max_new_tokens: int,
655
+ temperature: float,
656
+ top_p: float,
657
+ seed: int,
658
+ enable_thinking: bool = True,
659
+ ) -> str:
660
+ signature = sign_modal_text_request(
661
+ self.signing_key,
662
+ messages,
663
+ max_new_tokens=max_new_tokens,
664
+ temperature=temperature,
665
+ top_p=top_p,
666
+ seed=seed,
667
+ enable_thinking=enable_thinking,
668
+ )
669
+ try:
670
+ response = self.client.post(
671
+ self.endpoint,
672
+ json={
673
+ "messages": messages,
674
+ "max_new_tokens": max_new_tokens,
675
+ "temperature": temperature,
676
+ "top_p": top_p,
677
+ "seed": seed,
678
+ "enable_thinking": enable_thinking,
679
+ "signature": signature,
680
+ },
681
+ )
682
+ response.raise_for_status()
683
+ except httpx.HTTPStatusError as error:
684
+ raise ValueError(
685
+ f"Modal text request failed with HTTP {error.response.status_code}"
686
+ ) from error
687
+ except httpx.RequestError as error:
688
+ raise ValueError(f"Modal text request failed: {error}") from error
689
+ payload = response.json()
690
+ content = payload.get("content")
691
+ if not isinstance(content, str):
692
+ raise ValueError("Modal text response did not contain a string content")
693
+ return content
694
+
695
+ def next_intake_question(
696
+ self,
697
+ name: str,
698
+ situation: str,
699
+ history: list[dict[str, str]],
700
+ *,
701
+ rejected_questions: list[str] | None = None,
702
+ seed: int = 3407,
703
+ ) -> str:
704
+ return self._call(
705
+ intake_messages(
706
+ name,
707
+ situation,
708
+ history=history,
709
+ rejected_questions=rejected_questions,
710
+ seed=seed,
711
+ ),
712
+ max_new_tokens=2048,
713
+ temperature=0.4,
714
+ top_p=0.9,
715
+ seed=seed,
716
+ enable_thinking=False,
717
+ )
718
+
719
+ def plan(self, name: str, situation: str, *, seed: int = 3407) -> str:
720
+ return self._call(
721
+ planner_messages(name, situation, seed=seed),
722
+ max_new_tokens=8192,
723
+ temperature=0.2,
724
+ top_p=0.9,
725
+ seed=seed,
726
+ enable_thinking=False,
727
+ )
728
+
729
+ def author(
730
+ self,
731
+ name: str,
732
+ situation: str,
733
+ *,
734
+ plan: dict[str, object],
735
+ feedback: dict[str, str] | None = None,
736
+ original: dict[str, object] | None = None,
737
+ seed: int = 3407,
738
+ ) -> str:
739
+ return self._call(
740
+ author_messages(
741
+ name,
742
+ situation,
743
+ plan=plan,
744
+ feedback=feedback,
745
+ original=original,
746
+ seed=seed,
747
+ ),
748
+ max_new_tokens=8192,
749
+ temperature=0.82,
750
+ top_p=0.92,
751
+ seed=seed,
752
+ enable_thinking=False,
753
+ )
754
+
755
+ def critic(
756
+ self,
757
+ name: str,
758
+ situation: str,
759
+ forest: dict[str, object],
760
+ *,
761
+ plan: dict[str, object],
762
+ seed: int = 3407,
763
+ ) -> str:
764
+ return self._call(
765
+ critic_messages(name, situation, forest, plan=plan, seed=seed),
766
+ max_new_tokens=8192,
767
+ temperature=0.15,
768
+ top_p=0.9,
769
+ seed=seed,
770
+ enable_thinking=False,
771
+ )
src/compliment_forest/orchestrator.py CHANGED
@@ -1,119 +1,648 @@
1
  from __future__ import annotations
2
 
3
  import json
 
4
  from collections.abc import Iterator
5
- from contextlib import suppress
6
- from typing import Any
 
7
 
8
  from pydantic import ValidationError
9
 
 
 
10
  from .backends.text import TextBackend
 
 
 
 
 
 
 
 
 
 
 
 
11
  from .safety import guard_input
12
- from .schema import CriticDecision, ForestDraft, StreamEvent
 
 
 
 
 
 
 
 
13
  from .trace import TraceRecorder
14
 
15
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
  def parse_json_object(raw: str) -> dict[str, object]:
17
- text = raw.strip()
 
 
 
 
 
 
 
 
18
  if text.startswith("```"):
19
  text = text.removeprefix("```json").removeprefix("```")
20
  text = text.removesuffix("```").strip()
21
  try:
22
  value = json.loads(text)
23
  except json.JSONDecodeError:
24
- start = text.find("{")
25
- end = text.rfind("}")
26
- if start < 0 or end <= start:
27
- raise
28
- value = json.loads(text[start : end + 1])
29
  if not isinstance(value, dict):
30
  raise ValueError("model output must be a JSON object")
31
  return value
32
 
33
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
  class ForestOrchestrator:
35
  def __init__(
36
  self,
37
  text_backend: TextBackend,
38
- image_backend: object,
 
39
  trace_recorder: TraceRecorder | None = None,
40
  ) -> None:
41
  self.text_backend = text_backend
42
  self.image_backend = image_backend
 
43
  self.trace_recorder = trace_recorder
44
 
 
 
 
45
  def _trace(self, stage: str, **record: Any) -> None:
46
  if self.trace_recorder is not None:
47
  self.trace_recorder.append({"stage": stage, **record})
48
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49
  def _author(
50
  self,
51
  name: str,
52
  situation: str,
53
  *,
 
54
  feedback: dict[str, str] | None = None,
55
  original: dict[str, object] | None = None,
 
56
  attempts: int = 3,
57
  ) -> ForestDraft:
58
  last_error: Exception | None = None
59
- for _ in range(attempts):
60
  try:
61
  raw = self.text_backend.author(
62
  name,
63
  situation,
 
64
  feedback=feedback,
65
  original=original,
 
 
 
 
 
 
66
  )
67
- return ForestDraft.model_validate(parse_json_object(raw))
68
  except (json.JSONDecodeError, ValidationError, ValueError) as error:
69
  last_error = error
70
- raise ValueError("author could not produce a valid forest") from last_error
 
 
71
 
72
  def _critic(
73
  self,
74
  name: str,
75
  situation: str,
76
  forest: ForestDraft,
 
 
 
 
77
  ) -> CriticDecision:
78
- try:
79
- raw = self.text_backend.critic(name, situation, forest.model_dump())
80
- decision = CriticDecision.model_validate(parse_json_object(raw))
81
- except (json.JSONDecodeError, ValidationError, ValueError):
82
- decision = CriticDecision(
83
- keep_indices=list(range(min(len(forest.clearings), 6))),
84
- revise_indices=[],
85
- reasons={},
86
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
87
  valid = [index for index in decision.keep_indices if index < len(forest.clearings)]
88
  if not valid:
89
- valid = list(range(min(len(forest.clearings), 6)))
 
 
90
  return decision.model_copy(update={"keep_indices": valid})
91
 
92
  @staticmethod
93
- def _survivor_indices(forest: ForestDraft, decision: CriticDecision) -> list[int]:
 
 
 
 
 
94
  survivors: list[int] = []
95
  strengths: set[str] = set()
96
- for index in decision.keep_indices:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
97
  strength = forest.clearings[index].strength.casefold()
98
- if strength not in strengths:
 
 
 
 
99
  strengths.add(strength)
100
  survivors.append(index)
101
- if len(forest.clearings) >= 4:
102
- for index, clearing in enumerate(forest.clearings):
103
- if len(survivors) >= 4:
104
- break
105
- strength = clearing.strength.casefold()
106
- if index not in survivors and strength not in strengths:
107
- strengths.add(strength)
108
- survivors.append(index)
 
 
 
 
 
 
 
 
109
  return survivors[:6]
110
 
111
- def generate(self, name: str, situation: str, seed: int = 3407) -> Iterator[StreamEvent]:
112
- guard = guard_input(name, situation)
 
 
 
 
 
 
 
 
 
 
113
  self._trace(
114
  "guard",
115
  name=name,
116
- situation=situation,
117
  allowed=guard.allowed,
118
  category=guard.category,
119
  )
@@ -122,56 +651,244 @@ class ForestOrchestrator:
122
  yield StreamEvent(type=event_type, message=guard.message)
123
  return
124
 
125
- yield StreamEvent(type="status", message="Listening for the strengths in this moment.")
126
- try:
127
- forest = self._author(name.strip(), situation.strip())
128
- except ValueError as error:
129
- self._trace("author_error", error=str(error))
130
- yield StreamEvent(type="error", message=str(error))
131
- return
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
132
  self._trace(
133
  "author",
134
  name=name,
135
- situation=situation,
136
- model="MiniCPM5-1B",
137
  output=forest.model_dump(),
138
  )
139
 
140
- yield StreamEvent(type="status", message="A careful owl is checking every clearing.")
141
- decision = self._critic(name.strip(), situation.strip(), forest)
 
 
 
 
 
 
 
 
 
142
  self._trace(
143
  "critic",
144
  name=name,
145
- situation=situation,
146
- model="MiniCPM5-1B",
147
  input=forest.model_dump(),
148
  output=decision.model_dump(),
149
  )
150
 
151
- revision_indices = sorted(set(decision.revise_indices) & set(decision.keep_indices))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
152
  if revision_indices:
153
- feedback = {
154
- str(index): decision.reasons.get(str(index), "Make this more situation-specific.")
155
- for index in revision_indices
156
- }
157
- with suppress(ValueError):
158
- forest = self._author(
159
- name.strip(),
160
- situation.strip(),
161
- feedback=feedback,
162
- original=forest.model_dump(),
163
- attempts=1,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
164
  )
165
- self._trace(
166
- "revision",
167
- name=name,
168
- situation=situation,
169
- model="MiniCPM5-1B",
170
- feedback=feedback,
171
- output=forest.model_dump(),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
172
  )
173
-
174
- survivor_indices = self._survivor_indices(forest, decision)
175
  self._trace(
176
  "selection",
177
  survivor_indices=survivor_indices,
@@ -187,41 +904,105 @@ class ForestOrchestrator:
187
  "forest_title": forest.forest_title,
188
  "clearing_count": len(survivor_indices),
189
  "seed": seed,
 
 
190
  },
191
  )
192
 
193
- for offset, index in enumerate(survivor_indices):
194
- clearing = forest.clearings[index]
195
- clearing_seed = seed + offset
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
196
  try:
197
- image = self.image_backend.generate(clearing.image_prompt, clearing_seed)
198
- image_error = ""
199
- except Exception as error: # Image failure must not discard the written clearing.
200
- image = ""
201
- image_error = str(error)
 
 
 
 
 
 
 
 
 
202
  self._trace(
203
- "image",
204
- model="FLUX.1-dev + Compliment Forest LoRA",
205
- clearing_index=offset,
206
- prompt=clearing.image_prompt,
207
- seed=clearing_seed,
208
- succeeded=not image_error,
209
- error=image_error,
210
  )
211
- yield StreamEvent(
212
- type="clearing",
213
- data={
214
- "index": offset,
215
- "clearing": clearing.model_dump(),
216
- "image": image,
217
- "image_error": image_error,
218
- "seed": clearing_seed,
219
- },
220
  )
221
 
222
- yield StreamEvent(
223
- type="complete",
224
- message="Your forest is ready.",
225
- data={"clearing_count": len(survivor_indices)},
226
- )
227
- self._trace("complete", clearing_count=len(survivor_indices), seed=seed)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  from __future__ import annotations
2
 
3
  import json
4
+ import re
5
  from collections.abc import Iterator
6
+ from concurrent.futures import Future, ThreadPoolExecutor
7
+ from difflib import SequenceMatcher
8
+ from typing import Any, cast
9
 
10
  from pydantic import ValidationError
11
 
12
+ from .backends.image import ImageBackend, resolve_style
13
+ from .backends.music import MusicBackend, build_music_prompt
14
  from .backends.text import TextBackend
15
+ from .prompts import RECOVERY_INTAKE_QUESTIONS
16
+ from .quality import (
17
+ content_quality_issues,
18
+ distinct_indices,
19
+ duplicate_fields,
20
+ invalid_fact_anchor_indices,
21
+ is_situation_grounded,
22
+ repeated_source_phrase_indices,
23
+ source_phrase_in_situation,
24
+ unsupported_specificity,
25
+ valid_arc_indices,
26
+ )
27
  from .safety import guard_input
28
+ from .schema import (
29
+ CriticDecision,
30
+ ForestDraft,
31
+ ForestStyle,
32
+ IntakeQuestion,
33
+ IntakeTurn,
34
+ SituationPlan,
35
+ StreamEvent,
36
+ )
37
  from .trace import TraceRecorder
38
 
39
 
40
+ def build_guided_situation(
41
+ situation: str,
42
+ intake: list[IntakeTurn] | None = None,
43
+ ) -> str:
44
+ """Append clarifying Q&A pairs as user-provided personalization context."""
45
+ clean = situation.strip()
46
+ if not intake:
47
+ return clean
48
+ lines = [
49
+ f"- The forest asked: {turn.question}\n You answered: {turn.answer}" for turn in intake
50
+ ]
51
+ return (
52
+ f"{clean}\n\n"
53
+ "Clarifying conversation between the forest and the user. Treat these as the user's "
54
+ "own words; weave them into the encouragement without listing them mechanically.\n"
55
+ + "\n".join(lines)
56
+ )
57
+
58
+
59
+ _THINK_BLOCK = re.compile(r"<think\b[^>]*>.*?</think>", re.DOTALL | re.IGNORECASE)
60
+
61
+
62
  def parse_json_object(raw: str) -> dict[str, object]:
63
+ """Extract the first balanced JSON object from a model response.
64
+
65
+ Tolerates: code fences, leading prose, trailing prose, <think> blocks
66
+ that MiniCPM4.1 emits even with enable_thinking=False, and nested
67
+ objects. Skips characters inside string literals so braces in strings
68
+ do not confuse the scanner. Raises ValueError if no top-level object
69
+ parses.
70
+ """
71
+ text = _THINK_BLOCK.sub("", raw).strip()
72
  if text.startswith("```"):
73
  text = text.removeprefix("```json").removeprefix("```")
74
  text = text.removesuffix("```").strip()
75
  try:
76
  value = json.loads(text)
77
  except json.JSONDecodeError:
78
+ value = _scan_first_json_object(text)
 
 
 
 
79
  if not isinstance(value, dict):
80
  raise ValueError("model output must be a JSON object")
81
  return value
82
 
83
 
84
+ def _scan_first_json_object(text: str) -> dict[str, object]:
85
+ """Walk the string and return the first balanced { ... } that parses.
86
+
87
+ Falls back to unescaping Python-style \\" and \\' if the model emitted
88
+ JSON inside a Python string literal (MiniCPM4.1 does this sometimes).
89
+ """
90
+ last_error: Exception | None = None
91
+ for candidate in _candidate_texts(text):
92
+ i = 0
93
+ while i < len(candidate):
94
+ if candidate[i] != "{":
95
+ i += 1
96
+ continue
97
+ depth = 0
98
+ in_string = False
99
+ escape = False
100
+ for j in range(i, len(candidate)):
101
+ ch = candidate[j]
102
+ if escape:
103
+ escape = False
104
+ continue
105
+ if ch == "\\":
106
+ escape = True
107
+ continue
108
+ if ch == '"':
109
+ in_string = not in_string
110
+ continue
111
+ if in_string:
112
+ continue
113
+ if ch == "{":
114
+ depth += 1
115
+ elif ch == "}":
116
+ depth -= 1
117
+ if depth == 0:
118
+ try:
119
+ return json.loads(candidate[i : j + 1])
120
+ except json.JSONDecodeError as error:
121
+ last_error = error
122
+ break
123
+ i += 1
124
+ snippet = text[:400].replace("\n", " ")
125
+ raise ValueError(
126
+ f"no balanced JSON object found in model output; got: {snippet!r}"
127
+ ) from last_error
128
+
129
+
130
+ def _candidate_texts(text: str) -> list[str]:
131
+ """Try raw text, then safely decode one model-added string-escape layer."""
132
+ candidates = [text]
133
+ if any(marker in text for marker in (r"\"", r"\n", r"\r", r"\t")):
134
+ escaped = text.replace(r"\'", "'")
135
+ escaped = escaped.replace("\r", r"\r").replace("\n", r"\n").replace("\t", r"\t")
136
+ try:
137
+ decoded = json.loads(f'"{escaped}"')
138
+ except json.JSONDecodeError:
139
+ pass
140
+ else:
141
+ if isinstance(decoded, str):
142
+ candidates.append(decoded)
143
+ if r"\"" in text:
144
+ candidates.append(text.replace(r"\'", "'").replace(r"\"", '"'))
145
+ return list(dict.fromkeys(candidates))
146
+
147
+
148
+ def _question_repeats(candidate: str, previous_questions: list[str]) -> bool:
149
+ normalized_candidate = " ".join(re.sub(r"[^\w\s]", " ", candidate.casefold()).split())
150
+ for previous in previous_questions:
151
+ normalized_previous = " ".join(re.sub(r"[^\w\s]", " ", previous.casefold()).split())
152
+ if normalized_candidate == normalized_previous:
153
+ return True
154
+ if (
155
+ normalized_candidate
156
+ and normalized_previous
157
+ and SequenceMatcher(
158
+ None,
159
+ normalized_candidate,
160
+ normalized_previous,
161
+ ).ratio()
162
+ >= 0.72
163
+ ):
164
+ return True
165
+ candidate_tokens = set(normalized_candidate.split())
166
+ previous_tokens = set(normalized_previous.split())
167
+ shorter_length = min(len(candidate_tokens), len(previous_tokens))
168
+ if shorter_length >= 5 and len(candidate_tokens & previous_tokens) / shorter_length >= 0.8:
169
+ return True
170
+ return False
171
+
172
+
173
+ def normalize_plan_payload(
174
+ payload: dict[str, object],
175
+ *,
176
+ name: str,
177
+ situation: str,
178
+ ) -> dict[str, object]:
179
+ """Repair simple fact-anchor shapes before strict plan validation."""
180
+ normalized = dict(payload)
181
+ if "faithful_summary" not in normalized:
182
+ summary = f"{name.strip()} shared: {situation.strip()}"
183
+ if len(summary) < 12:
184
+ summary += "."
185
+ normalized["faithful_summary"] = summary[:500]
186
+ if "central_uncertainty" not in normalized:
187
+ normalized["central_uncertainty"] = "How this situation will unfold"
188
+ if "desired_direction" not in normalized:
189
+ normalized["desired_direction"] = "Meet this situation with care and one grounded next step"
190
+
191
+ anchors = normalized.get("fact_anchors")
192
+ if not isinstance(anchors, list):
193
+ return normalized
194
+
195
+ repaired: list[object] = []
196
+ for anchor in anchors[:4]:
197
+ if isinstance(anchor, str) and anchor.strip():
198
+ source = anchor.strip()
199
+ meaning = source if len(source) >= 3 else f"User stated {source}"
200
+ repaired.append({"source_phrase": source, "meaning": meaning})
201
+ else:
202
+ repaired.append(anchor)
203
+ normalized["fact_anchors"] = repaired
204
+ return normalized
205
+
206
+
207
+ def normalize_critic_payload(
208
+ payload: dict[str, object],
209
+ forest: ForestDraft,
210
+ ) -> dict[str, object]:
211
+ """Keep only critic references that point to actual clearings."""
212
+ normalized = dict(payload)
213
+ clearing_count = len(forest.clearings)
214
+
215
+ def valid_indices(value: object) -> list[int]:
216
+ if not isinstance(value, list):
217
+ return []
218
+ result: list[int] = []
219
+ for item in value:
220
+ if (
221
+ isinstance(item, int)
222
+ and not isinstance(item, bool)
223
+ and 0 <= item < clearing_count
224
+ and item not in result
225
+ ):
226
+ result.append(item)
227
+ return result
228
+
229
+ keep_indices = valid_indices(normalized.get("keep_indices"))
230
+ if not keep_indices:
231
+ keep_indices = valid_arc_indices(forest.clearings)
232
+ keep_indices = keep_indices or list(range(clearing_count))
233
+ normalized["keep_indices"] = keep_indices
234
+ normalized["revise_indices"] = valid_indices(normalized.get("revise_indices"))
235
+
236
+ reasons = normalized.get("reasons")
237
+ valid_reasons: dict[str, str] = {}
238
+ if isinstance(reasons, dict):
239
+ for index in range(clearing_count):
240
+ reason = reasons.get(str(index))
241
+ if isinstance(reason, str):
242
+ valid_reasons[str(index)] = reason
243
+ normalized["reasons"] = valid_reasons
244
+ # Scores are advisory and are not consumed by selection or revision logic.
245
+ normalized["scores"] = []
246
+ return normalized
247
+
248
+
249
+ def parse_critic_payload(raw: str) -> dict[str, object]:
250
+ """Parse critic JSON, recovering index arrays when free-text prose is malformed."""
251
+ try:
252
+ return parse_json_object(raw)
253
+ except ValueError as parse_error:
254
+ text = _THINK_BLOCK.sub("", raw).replace(r"\"", '"')
255
+ recovered: dict[str, object] = {}
256
+ for key in ("keep_indices", "revise_indices"):
257
+ match = re.search(rf'"{key}"\s*:\s*\[([^\]]*)\]', text)
258
+ if match:
259
+ recovered[key] = [int(value) for value in re.findall(r"-?\d+", match.group(1))]
260
+ if recovered:
261
+ return recovered
262
+ raise parse_error
263
+
264
+
265
+ def normalize_forest_payload(
266
+ payload: dict[str, object],
267
+ *,
268
+ name: str,
269
+ plan: SituationPlan,
270
+ ) -> dict[str, object]:
271
+ """Repair common synonymous keys while preserving strict clearing validation."""
272
+ normalized = dict(payload)
273
+ if "forest_title" not in normalized:
274
+ normalized["forest_title"] = f"{name.strip()}'s Compliment Forest"
275
+
276
+ chapters = normalized.pop("chapters", None)
277
+ if "clearings" not in normalized and isinstance(chapters, list):
278
+ normalized["clearings"] = chapters
279
+
280
+ clearings = normalized.get("clearings")
281
+ if isinstance(clearings, list):
282
+ arc_roles = ("arrive", "steady", "widen", "step", "carry")
283
+ source_phrases = [anchor.source_phrase for anchor in plan.fact_anchors]
284
+ default_source_phrase = source_phrases[0]
285
+ repaired_clearings: list[object] = []
286
+ for index, clearing in enumerate(clearings):
287
+ if not isinstance(clearing, dict):
288
+ repaired_clearings.append(clearing)
289
+ continue
290
+ repaired = dict(clearing)
291
+ repaired["arc_role"] = arc_roles[min(index, len(arc_roles) - 1)]
292
+ if repaired.get("source_phrase") not in source_phrases:
293
+ repaired["source_phrase"] = default_source_phrase
294
+ if repaired["arc_role"] == "step":
295
+ narration = repaired.get("narration")
296
+ if isinstance(narration, str):
297
+ repaired["narration"] = re.sub(
298
+ r"(?i)(^|\n\n)you (?:choose|decide|plan) to ",
299
+ lambda match: f"{match.group(1)}You could ",
300
+ narration,
301
+ )
302
+ if "image_prompt" not in repaired:
303
+ scene_title = str(repaired.get("scene_title") or "").strip()
304
+ scene_intro = str(repaired.get("scene_intro") or "").strip()
305
+ image_prompt = ". ".join(part for part in (scene_title, scene_intro) if part)
306
+ if image_prompt:
307
+ repaired["image_prompt"] = image_prompt[:300]
308
+ repaired_clearings.append(repaired)
309
+ clearings = repaired_clearings
310
+ normalized["clearings"] = clearings
311
+
312
+ if isinstance(clearings, list):
313
+ strengths: list[str] = []
314
+ seen: set[str] = set()
315
+
316
+ proposed = normalized.get("proposed_strengths")
317
+ if isinstance(proposed, list):
318
+ for strength in proposed:
319
+ if not isinstance(strength, str) or not strength.strip():
320
+ continue
321
+ clean = strength.strip()
322
+ key = clean.casefold()
323
+ if key not in seen:
324
+ seen.add(key)
325
+ strengths.append(clean)
326
+
327
+ for clearing in clearings:
328
+ if not isinstance(clearing, dict):
329
+ continue
330
+ strength = clearing.get("strength")
331
+ if not isinstance(strength, str) or not strength.strip():
332
+ continue
333
+ clean = strength.strip()
334
+ key = clean.casefold()
335
+ if key not in seen:
336
+ seen.add(key)
337
+ strengths.append(clean)
338
+ if strengths:
339
+ normalized["proposed_strengths"] = strengths[:6]
340
+ return normalized
341
+
342
+
343
+ def merge_targeted_revision(
344
+ original: ForestDraft,
345
+ revised: ForestDraft,
346
+ revision_indices: list[int],
347
+ ) -> ForestDraft:
348
+ """Replace only chapters that received feedback."""
349
+
350
+ clearings = list(original.clearings)
351
+ for index in revision_indices:
352
+ if index < len(clearings) and index < len(revised.clearings):
353
+ clearings[index] = revised.clearings[index]
354
+ return original.model_copy(
355
+ update={
356
+ "clearings": clearings,
357
+ "proposed_strengths": [clearing.strength for clearing in clearings],
358
+ }
359
+ )
360
+
361
+
362
  class ForestOrchestrator:
363
  def __init__(
364
  self,
365
  text_backend: TextBackend,
366
+ image_backend: ImageBackend,
367
+ music_backend: MusicBackend | None = None,
368
  trace_recorder: TraceRecorder | None = None,
369
  ) -> None:
370
  self.text_backend = text_backend
371
  self.image_backend = image_backend
372
+ self.music_backend = music_backend
373
  self.trace_recorder = trace_recorder
374
 
375
+ def _text_model_name(self) -> str:
376
+ return str(getattr(self.text_backend, "model", self.text_backend.__class__.__name__))
377
+
378
  def _trace(self, stage: str, **record: Any) -> None:
379
  if self.trace_recorder is not None:
380
  self.trace_recorder.append({"stage": stage, **record})
381
 
382
+ def next_intake_question(
383
+ self,
384
+ name: str,
385
+ situation: str,
386
+ history: list[IntakeTurn],
387
+ *,
388
+ seed: int = 3407,
389
+ attempts: int = 3,
390
+ ) -> IntakeQuestion:
391
+ history_payload = [{"question": turn.question, "answer": turn.answer} for turn in history]
392
+ previous_questions = [turn.question for turn in history]
393
+ rejected_questions: list[str] = []
394
+ last_error: Exception | None = None
395
+ for attempt in range(attempts):
396
+ candidate_question = ""
397
+ try:
398
+ raw = self.text_backend.next_intake_question(
399
+ name,
400
+ situation,
401
+ history_payload,
402
+ rejected_questions=rejected_questions,
403
+ seed=seed + attempt,
404
+ )
405
+ payload = parse_json_object(raw)
406
+ raw_question = payload.get("question")
407
+ if isinstance(raw_question, str):
408
+ candidate_question = raw_question.strip()
409
+ question = IntakeQuestion.model_validate(payload)
410
+ if _question_repeats(
411
+ question.question,
412
+ previous_questions + rejected_questions,
413
+ ):
414
+ raise ValueError("model repeated a prior intake question")
415
+ self._trace(
416
+ "intake_turn",
417
+ turn_index=len(history),
418
+ history=history_payload,
419
+ output=question.model_dump(),
420
+ attempts=attempt + 1,
421
+ fallback=False,
422
+ model=self._text_model_name(),
423
+ )
424
+ return question
425
+ except (json.JSONDecodeError, ValidationError, ValueError) as error:
426
+ last_error = error
427
+ if candidate_question and not _question_repeats(
428
+ candidate_question,
429
+ rejected_questions,
430
+ ):
431
+ rejected_questions.append(candidate_question)
432
+
433
+ recovery = self._recovery_intake_question(history)
434
+ self._trace(
435
+ "intake_turn_failed",
436
+ turn_index=len(history),
437
+ history=history_payload,
438
+ attempts=attempts,
439
+ error=str(last_error) if last_error else "",
440
+ recovered=True,
441
+ model=self._text_model_name(),
442
+ )
443
+ self._trace(
444
+ "intake_turn",
445
+ turn_index=len(history),
446
+ history=history_payload,
447
+ output=recovery.model_dump(),
448
+ attempts=attempts,
449
+ fallback=True,
450
+ error=str(last_error) if last_error else "",
451
+ model=self._text_model_name(),
452
+ )
453
+ return recovery
454
+
455
+ @staticmethod
456
+ def _recovery_intake_question(history: list[IntakeTurn]) -> IntakeQuestion:
457
+ previous_questions = [turn.question for turn in history]
458
+ start = min(len(history), len(RECOVERY_INTAKE_QUESTIONS) - 1)
459
+ for offset in range(len(RECOVERY_INTAKE_QUESTIONS)):
460
+ index = (start + offset) % len(RECOVERY_INTAKE_QUESTIONS)
461
+ question = IntakeQuestion.model_validate(RECOVERY_INTAKE_QUESTIONS[index])
462
+ if not _question_repeats(question.question, previous_questions):
463
+ return question
464
+ raise ValueError("no distinct recovery intake question is available")
465
+
466
+ def _plan(
467
+ self,
468
+ name: str,
469
+ situation: str,
470
+ *,
471
+ seed: int,
472
+ attempts: int = 3,
473
+ ) -> SituationPlan:
474
+ last_error: Exception | None = None
475
+ for attempt in range(attempts):
476
+ try:
477
+ raw = self.text_backend.plan(
478
+ name,
479
+ situation,
480
+ seed=seed + attempt,
481
+ )
482
+ payload = normalize_plan_payload(
483
+ parse_json_object(raw),
484
+ name=name,
485
+ situation=situation,
486
+ )
487
+ plan = SituationPlan.model_validate(payload)
488
+ if invalid_fact_anchor_indices(plan, situation):
489
+ raise ValueError("planner used a source phrase outside the situation")
490
+ return plan
491
+ except (json.JSONDecodeError, ValidationError, ValueError) as error:
492
+ last_error = error
493
+ raise ValueError(
494
+ f"planner could not produce a valid plan after {attempts} attempts: {last_error}"
495
+ ) from last_error
496
+
497
  def _author(
498
  self,
499
  name: str,
500
  situation: str,
501
  *,
502
+ plan: SituationPlan,
503
  feedback: dict[str, str] | None = None,
504
  original: dict[str, object] | None = None,
505
+ seed: int = 3407,
506
  attempts: int = 3,
507
  ) -> ForestDraft:
508
  last_error: Exception | None = None
509
+ for attempt in range(attempts):
510
  try:
511
  raw = self.text_backend.author(
512
  name,
513
  situation,
514
+ plan=plan.model_dump(),
515
  feedback=feedback,
516
  original=original,
517
+ seed=seed + attempt,
518
+ )
519
+ payload = normalize_forest_payload(
520
+ parse_json_object(raw),
521
+ name=name,
522
+ plan=plan,
523
  )
524
+ return ForestDraft.model_validate(payload)
525
  except (json.JSONDecodeError, ValidationError, ValueError) as error:
526
  last_error = error
527
+ raise ValueError(
528
+ f"author could not produce a valid forest after {attempts} attempts: {last_error}"
529
+ ) from last_error
530
 
531
  def _critic(
532
  self,
533
  name: str,
534
  situation: str,
535
  forest: ForestDraft,
536
+ *,
537
+ plan: SituationPlan,
538
+ seed: int,
539
+ attempts: int = 2,
540
  ) -> CriticDecision:
541
+ last_error: Exception | None = None
542
+ for attempt in range(attempts):
543
+ try:
544
+ raw = self.text_backend.critic(
545
+ name,
546
+ situation,
547
+ forest.model_dump(),
548
+ plan=plan.model_dump(),
549
+ seed=seed + attempt,
550
+ )
551
+ payload = normalize_critic_payload(
552
+ parse_critic_payload(raw),
553
+ forest,
554
+ )
555
+ decision = CriticDecision.model_validate(payload)
556
+ break
557
+ except (json.JSONDecodeError, ValidationError, ValueError) as error:
558
+ last_error = error
559
+ else:
560
+ raise ValueError(
561
+ f"critic could not produce a valid decision after {attempts} attempts: {last_error}"
562
+ ) from last_error
563
  valid = [index for index in decision.keep_indices if index < len(forest.clearings)]
564
  if not valid:
565
+ raise ValueError(
566
+ "critic kept no valid clearings; cannot proceed without grounded survivors"
567
+ )
568
  return decision.model_copy(update={"keep_indices": valid})
569
 
570
  @staticmethod
571
+ def _survivor_indices(
572
+ forest: ForestDraft,
573
+ decision: CriticDecision,
574
+ situation: str,
575
+ rejections: dict[int, list[str]] | None = None,
576
+ ) -> list[int]:
577
  survivors: list[int] = []
578
  strengths: set[str] = set()
579
+ repeated_sources = repeated_source_phrase_indices(forest.clearings)
580
+
581
+ def reject(index: int, *reasons: str) -> None:
582
+ if rejections is not None:
583
+ rejections.setdefault(index, []).extend(reasons)
584
+
585
+ def append_if_distinct(index: int) -> None:
586
+ clearing = forest.clearings[index]
587
+ if not source_phrase_in_situation(clearing.source_phrase, situation):
588
+ reject(index, "invalid_source_phrase")
589
+ return
590
+ generated_text = " ".join((clearing.strength, clearing.narration, clearing.reflection))
591
+ unsupported = sorted(
592
+ unsupported_specificity(generated_text, situation) - {"unsupported_direct_claim"}
593
+ )
594
+ if unsupported:
595
+ reject(index, *unsupported)
596
+ return
597
+ quality_issues = sorted(content_quality_issues(clearing))
598
+ if quality_issues:
599
+ reject(index, *quality_issues)
600
+ return
601
+ if index in repeated_sources:
602
+ reject(index, "repeated_source_phrase")
603
+ return
604
  strength = forest.clearings[index].strength.casefold()
605
+ if strength in strengths:
606
+ reject(index, "duplicate_strength")
607
+ return
608
+ candidate = distinct_indices(forest.clearings, [*survivors, index])
609
+ if len(candidate) == len(survivors) + 1:
610
  strengths.add(strength)
611
  survivors.append(index)
612
+ else:
613
+ reject(index, "duplicate_prose")
614
+
615
+ ordered = valid_arc_indices(forest.clearings)
616
+ keep = set(decision.keep_indices)
617
+ for index in ordered:
618
+ if index in keep:
619
+ append_if_distinct(index)
620
+ for index in ordered:
621
+ if len(survivors) >= 5:
622
+ break
623
+ if index not in survivors:
624
+ append_if_distinct(index)
625
+ for index in decision.keep_indices:
626
+ if index not in ordered:
627
+ append_if_distinct(index)
628
  return survivors[:6]
629
 
630
+ def generate(
631
+ self,
632
+ name: str,
633
+ situation: str,
634
+ seed: int = 3407,
635
+ style: ForestStyle = "surprise",
636
+ *,
637
+ model_situation: str | None = None,
638
+ ) -> Iterator[StreamEvent]:
639
+ user_situation = situation.strip()
640
+ generation_situation = (model_situation or situation).strip()
641
+ guard = guard_input(name, user_situation)
642
  self._trace(
643
  "guard",
644
  name=name,
645
+ situation=user_situation,
646
  allowed=guard.allowed,
647
  category=guard.category,
648
  )
 
651
  yield StreamEvent(type=event_type, message=guard.message)
652
  return
653
 
654
+ resolved_style = cast(ForestStyle, resolve_style(style, seed))
655
+ yield StreamEvent(
656
+ type="status",
657
+ message="Listening carefully for what is known and what is uncertain.",
658
+ )
659
+ plan = self._plan(
660
+ name.strip(),
661
+ generation_situation,
662
+ seed=seed,
663
+ )
664
+ self._trace(
665
+ "plan",
666
+ name=name,
667
+ situation=user_situation,
668
+ model=self._text_model_name(),
669
+ output=plan.model_dump(),
670
+ )
671
+
672
+ yield StreamEvent(type="status", message="Shaping those facts into a gentle path.")
673
+ forest = self._author(
674
+ name.strip(),
675
+ generation_situation,
676
+ plan=plan,
677
+ seed=seed + 1,
678
+ )
679
  self._trace(
680
  "author",
681
  name=name,
682
+ situation=user_situation,
683
+ model=self._text_model_name(),
684
  output=forest.model_dump(),
685
  )
686
 
687
+ yield StreamEvent(
688
+ type="status",
689
+ message="A careful owl is checking every clearing.",
690
+ )
691
+ decision = self._critic(
692
+ name.strip(),
693
+ generation_situation,
694
+ forest,
695
+ plan=plan,
696
+ seed=seed + 2,
697
+ )
698
  self._trace(
699
  "critic",
700
  name=name,
701
+ situation=user_situation,
702
+ model=self._text_model_name(),
703
  input=forest.model_dump(),
704
  output=decision.model_dump(),
705
  )
706
 
707
+ repeated = duplicate_fields(forest.clearings, decision.keep_indices)
708
+ repeated_sources = repeated_source_phrase_indices(forest.clearings)
709
+ grounded_indices = {
710
+ index
711
+ for index in decision.keep_indices
712
+ if is_situation_grounded(forest.clearings[index].narration, generation_situation)
713
+ }
714
+ ungrounded = set(decision.keep_indices) if not grounded_indices else set()
715
+ invalid_sources = {
716
+ index
717
+ for index in decision.keep_indices
718
+ if not source_phrase_in_situation(
719
+ forest.clearings[index].source_phrase,
720
+ generation_situation,
721
+ )
722
+ }
723
+ unsupported = {
724
+ index: sorted(
725
+ unsupported_specificity(
726
+ " ".join(
727
+ (
728
+ forest.clearings[index].strength,
729
+ forest.clearings[index].narration,
730
+ forest.clearings[index].reflection,
731
+ )
732
+ ),
733
+ generation_situation,
734
+ )
735
+ )
736
+ for index in decision.keep_indices
737
+ }
738
+ unsupported = {index: issues for index, issues in unsupported.items() if issues}
739
+ content_issues = {
740
+ index: sorted(content_quality_issues(forest.clearings[index]))
741
+ for index in decision.keep_indices
742
+ }
743
+ content_issues = {index: issues for index, issues in content_issues.items() if issues}
744
+ revision_indices = sorted(
745
+ (set(decision.revise_indices) & set(decision.keep_indices))
746
+ | set(repeated)
747
+ | ungrounded
748
+ | invalid_sources
749
+ | set(unsupported)
750
+ | set(content_issues)
751
+ | repeated_sources
752
+ )
753
  if revision_indices:
754
+ feedback: dict[str, str] = {}
755
+ for index in revision_indices:
756
+ reasons: list[str] = []
757
+ critic_reason = decision.reasons.get(str(index))
758
+ if critic_reason:
759
+ reasons.append(critic_reason)
760
+ fields = repeated.get(index)
761
+ if fields:
762
+ reasons.append(
763
+ "Use substantially different "
764
+ + ", ".join(fields)
765
+ + " wording and sentence structure."
766
+ )
767
+ if index in ungrounded:
768
+ reasons.append(
769
+ "Ground the line in a concrete detail from the user's situation."
770
+ )
771
+ if index in invalid_sources:
772
+ reasons.append("Copy source_phrase exactly from the validated fact plan.")
773
+ if index in unsupported:
774
+ reasons.append(
775
+ "Remove unsupported specifics: " + ", ".join(unsupported[index]) + "."
776
+ )
777
+ if index in content_issues:
778
+ if "abstract_language" in content_issues[index]:
779
+ reasons.append(
780
+ "Replace abstract stock language with plain words about the "
781
+ "user's actual concern."
782
+ )
783
+ if "missing_practical_step" in content_issues[index]:
784
+ reasons.append(
785
+ "Replace the narration with one small, specific, low-risk action "
786
+ "about the user's concrete concern. Start with You could, Try, "
787
+ "or One option is, then use a practical verb such as ask, check, "
788
+ "compare, identify, list, practice, read, review, study, or write. "
789
+ "Do not use walking, breathing, or taking a step as the action."
790
+ )
791
+ if index in repeated_sources:
792
+ reasons.append(
793
+ "Do not repeat the user's full sentence; refer to the concern in "
794
+ "new, shorter words."
795
+ )
796
+ feedback[str(index)] = " ".join(reasons) or ("Make this more situation-specific.")
797
+ self._trace(
798
+ "duplicate_gate",
799
+ repeated_fields={str(index): fields for index, fields in repeated.items()},
800
+ fully_ungrounded=bool(ungrounded),
801
+ invalid_source_indices=sorted(invalid_sources),
802
+ unsupported_specificity={
803
+ str(index): issues for index, issues in unsupported.items()
804
+ },
805
+ content_quality={str(index): issues for index, issues in content_issues.items()},
806
+ repeated_source_phrase_indices=sorted(repeated_sources),
807
+ )
808
+ revised_forest = self._author(
809
+ name.strip(),
810
+ generation_situation,
811
+ plan=plan,
812
+ feedback=feedback,
813
+ original=forest.model_dump(),
814
+ seed=seed + 1001,
815
+ attempts=2,
816
+ )
817
+ forest = merge_targeted_revision(
818
+ forest,
819
+ revised_forest,
820
+ revision_indices,
821
+ )
822
+ self._trace(
823
+ "revision",
824
+ name=name,
825
+ situation=user_situation,
826
+ model=self._text_model_name(),
827
+ feedback=feedback,
828
+ output=forest.model_dump(),
829
+ )
830
+
831
+ survivor_rejections: dict[int, list[str]] = {}
832
+ survivor_indices = self._survivor_indices(
833
+ forest,
834
+ decision,
835
+ generation_situation,
836
+ survivor_rejections,
837
+ )
838
+ required_roles = {"arrive", "steady", "widen", "step", "carry"}
839
+ survivor_roles = {forest.clearings[index].arc_role for index in survivor_indices}
840
+ if len(survivor_indices) < 5 or not required_roles.issubset(survivor_roles):
841
+ regeneration_feedback = {
842
+ "whole_forest": (
843
+ "Start over with a fresh five-chapter forest. The repaired draft still "
844
+ "failed deterministic checks. Use plain, concrete language; mention "
845
+ "the user's full concern only once; include realistic options in widen; "
846
+ "make every narration discuss the actual concern rather than forest "
847
+ "movement; give one small optional action in step using ask, check, "
848
+ "compare, identify, list, practice, read, review, study, or write; and "
849
+ "end carry with a literal plan. Rejections: "
850
+ f"{survivor_rejections}"
851
  )
852
+ }
853
+ forest = self._author(
854
+ name.strip(),
855
+ generation_situation,
856
+ plan=plan,
857
+ feedback=regeneration_feedback,
858
+ original=forest.model_dump(),
859
+ seed=seed + 2001,
860
+ attempts=2,
861
+ )
862
+ decision = CriticDecision(
863
+ keep_indices=valid_arc_indices(forest.clearings)
864
+ or list(range(len(forest.clearings))),
865
+ revise_indices=[],
866
+ reasons={},
867
+ )
868
+ self._trace(
869
+ "regeneration",
870
+ rejected_roles=sorted(required_roles - survivor_roles),
871
+ rejection_reasons={
872
+ str(index): reasons for index, reasons in survivor_rejections.items()
873
+ },
874
+ feedback=regeneration_feedback,
875
+ output=forest.model_dump(),
876
+ )
877
+ survivor_rejections = {}
878
+ survivor_indices = self._survivor_indices(
879
+ forest,
880
+ decision,
881
+ generation_situation,
882
+ survivor_rejections,
883
+ )
884
+ survivor_roles = {forest.clearings[index].arc_role for index in survivor_indices}
885
+ if len(survivor_indices) < 5 or not required_roles.issubset(survivor_roles):
886
+ raise ValueError(
887
+ "forest remained repetitive, abstract, or insufficiently grounded "
888
+ "after targeted repair and one full regeneration "
889
+ f"(got {sorted(survivor_roles)}, need {sorted(required_roles)}; "
890
+ f"rejections={survivor_rejections})"
891
  )
 
 
892
  self._trace(
893
  "selection",
894
  survivor_indices=survivor_indices,
 
904
  "forest_title": forest.forest_title,
905
  "clearing_count": len(survivor_indices),
906
  "seed": seed,
907
+ "style": resolved_style,
908
+ "requested_style": style,
909
  },
910
  )
911
 
912
+ music_executor: ThreadPoolExecutor | None = None
913
+ music_future: Future[str] | None = None
914
+ soundscape_emitted = False
915
+ if self.music_backend is not None:
916
+ music_prompt = build_music_prompt(forest.model_dump(), resolved_style)
917
+ music_executor = ThreadPoolExecutor(
918
+ max_workers=1,
919
+ thread_name_prefix="forest-music",
920
+ )
921
+ music_future = music_executor.submit(
922
+ self.music_backend.generate,
923
+ music_prompt,
924
+ seed + 10_000,
925
+ )
926
+
927
+ def soundscape_event(*, wait: bool) -> StreamEvent | None:
928
+ nonlocal soundscape_emitted
929
+ if soundscape_emitted or music_future is None or (not wait and not music_future.done()):
930
+ return None
931
  try:
932
+ audio = music_future.result()
933
+ except Exception as error:
934
+ self._trace(
935
+ "soundscape",
936
+ model="facebook/musicgen-small",
937
+ seed=seed + 10_000,
938
+ succeeded=False,
939
+ error=str(error),
940
+ )
941
+ soundscape_emitted = True
942
+ return None
943
+ soundscape_emitted = True
944
+ if not audio:
945
+ return None
946
  self._trace(
947
+ "soundscape",
948
+ model="facebook/musicgen-small",
949
+ seed=seed + 10_000,
950
+ succeeded=True,
951
+ error="",
 
 
952
  )
953
+ return StreamEvent(
954
+ type="soundscape",
955
+ data={"audio": audio, "seed": seed + 10_000},
 
 
 
 
 
 
956
  )
957
 
958
+ try:
959
+ for offset, index in enumerate(survivor_indices):
960
+ clearing = forest.clearings[index]
961
+ clearing_seed = seed + offset
962
+ try:
963
+ image = self.image_backend.generate(
964
+ clearing.image_prompt,
965
+ clearing_seed,
966
+ resolved_style,
967
+ )
968
+ image_error = ""
969
+ except Exception as error:
970
+ image = ""
971
+ image_error = str(error)
972
+ self._trace(
973
+ "image",
974
+ model="FLUX.1-dev + Compliment Forest LoRA",
975
+ clearing_index=offset,
976
+ prompt=clearing.image_prompt,
977
+ seed=clearing_seed,
978
+ style=resolved_style,
979
+ succeeded=not image_error,
980
+ error=image_error,
981
+ )
982
+ yield StreamEvent(
983
+ type="clearing",
984
+ data={
985
+ "index": offset,
986
+ "clearing": clearing.model_dump(),
987
+ "image": image,
988
+ "image_error": image_error,
989
+ "seed": clearing_seed,
990
+ "style": resolved_style,
991
+ },
992
+ )
993
+ event = soundscape_event(wait=False)
994
+ if event is not None:
995
+ yield event
996
+
997
+ event = soundscape_event(wait=True)
998
+ if event is not None:
999
+ yield event
1000
+ yield StreamEvent(
1001
+ type="complete",
1002
+ message="Your forest is ready.",
1003
+ data={"clearing_count": len(survivor_indices)},
1004
+ )
1005
+ self._trace("complete", clearing_count=len(survivor_indices), seed=seed)
1006
+ finally:
1007
+ if music_executor is not None:
1008
+ music_executor.shutdown(wait=False, cancel_futures=True)
src/compliment_forest/prompts.py CHANGED
@@ -2,41 +2,280 @@ from __future__ import annotations
2
 
3
  import json
4
 
5
- AUTHOR_SYSTEM = """You are the author of The Compliment Forest.
6
- Return exactly one JSON object matching the supplied schema. Write warm, concise encouragement
7
- that names concrete details from the situation. Acknowledge difficulty without diagnosis,
8
- guarantees, hollow praise, or toxic positivity. Propose 3-6 distinct strengths and draft one
9
- clearing per strength. Spells are first-person, present-tense, and at most 12 words.
10
- The image_prompt describes only one friendly forest creature and its simple pose or setting."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
 
12
  CRITIC_SYSTEM = """You are the quality gate for The Compliment Forest.
13
  Return exactly one JSON object with keep_indices, revise_indices, reasons, and optional scores.
14
- Judge situation-specificity, warmth, non-genericness, non-toxic-positivity, and redundancy.
15
- Keep the strongest 4-6 distinct clearings when the input supports them. Request revision only
16
- where one grounded rewrite can repair the draft. Never create new prose."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
 
18
 
19
  def author_messages(
20
  name: str,
21
  situation: str,
22
  *,
 
23
  feedback: dict[str, str] | None = None,
24
  original: dict[str, object] | None = None,
 
25
  ) -> list[dict[str, str]]:
26
  request: dict[str, object] = {
27
  "name": name,
28
  "situation": situation,
 
 
29
  "schema": {
30
  "forest_title": "string",
31
  "proposed_strengths": ["3-6 distinct strings"],
32
  "clearings": [
33
  {
34
- "creature": "string",
35
- "strength": "string",
36
- "line": "specific grounded encouragement",
37
- "reflection": "agency-inviting question",
 
 
 
 
 
38
  "spell": "short first-person present-tense mantra",
39
- "image_prompt": "creature only, no style words or text",
40
  }
41
  ],
42
  },
@@ -55,29 +294,27 @@ def critic_messages(
55
  name: str,
56
  situation: str,
57
  forest: dict[str, object],
 
 
 
58
  ) -> list[dict[str, str]]:
 
 
59
  request = {
60
  "name": name,
61
  "situation": situation,
 
 
62
  "draft": forest,
 
63
  "output_schema": {
64
- "keep_indices": ["zero-based indices, max 6"],
65
- "revise_indices": ["subset needing one rewrite"],
66
- "reasons": {"index": "brief actionable reason"},
67
- "scores": [
68
- {
69
- "index": 0,
70
- "specificity": "1-5",
71
- "warmth": "1-5",
72
- "non_genericness": "1-5",
73
- "non_toxic_positivity": "1-5",
74
- "reason": "brief reason",
75
- }
76
- ],
77
  },
78
  }
79
  return [
80
  {"role": "system", "content": CRITIC_SYSTEM},
81
  {"role": "user", "content": json.dumps(request, ensure_ascii=False)},
82
  ]
83
-
 
2
 
3
  import json
4
 
5
+ PLANNER_SYSTEM = """You make a faithful evidence plan for The Compliment Forest.
6
+ Return exactly one JSON object matching the supplied schema. Use only facts stated in the
7
+ user's situation. Every fact anchor must copy an exact, contiguous source_phrase from the
8
+ situation. If the situation includes guided choices for personalization, treat them as
9
+ user-provided preferences for emotional focus, tone, and imagery, not as completed biography.
10
+ Return 1-4 fact_anchors objects, never strings. Every object must contain both source_phrase
11
+ and meaning. Do not copy schema descriptions into the output.
12
+ Do not add completed actions, memories, counts, dates, people, employers, places, interviews,
13
+ applications, relationships, or plans that the user did not state. A fear is an uncertainty,
14
+ not a fact. Keep the summary conservative and concise."""
15
+
16
+ AUTHOR_SYSTEM = """You are the storyteller of The Compliment Forest.
17
+ Return exactly one JSON object matching the supplied schema. Write a five-chapter walk in
18
+ second person that meets the user inside their worry and walks with them. Every chapter
19
+ must read as continuous narrative, not as cue cards or bullets.
20
+ The top-level key must be clearings, never chapters. proposed_strengths is required and
21
+ must list the distinct strength value from each clearing.
22
+ Never omit source_phrase or image_prompt from a clearing. Every spell must begin with
23
+ "I", "I am", or "I'm". Do not copy schema descriptions into the output.
24
+
25
+ Every chapter must include:
26
+ - scene_title: the name of where they are now (a place, a small figure, a moment). It
27
+ need not be an animal. Do not default every scene to an animal.
28
+ - scene_intro: one or two sentences that bridge the journey. Chapter 1 opens the journey
29
+ by acknowledging what the user is carrying. Chapters 2-5 must pick up from the previous
30
+ chapter's closing feeling or image.
31
+ - narration: two short paragraphs of second-person prose that help with the actual worry.
32
+ The encouragement is embedded inside the prose. Do not write a label or a list. Use
33
+ familiar, literal words. Keep most sentences to 20 words or fewer.
34
+ - strength: a short noun phrase naming what the chapter quietly honors in the user. It must
35
+ be unique across chapters and must echo something the user actually said or chose.
36
+ - reflection: one short question that helps the user notice, choose, or plan.
37
+ - spell: a first-person, present-tense mantra of at most 12 words.
38
+
39
+ Chapter arc roles and jobs in order:
40
+ - arrive: Name the feeling and concrete concern once. Do not solve it yet.
41
+ - steady: Separate what is known from what fear predicts. Do not dismiss the fear.
42
+ - widen: Offer two or three realistic options or ways to view the problem.
43
+ - step: Give one small, specific, low-risk action the user could try.
44
+ - carry: Summarize a simple plan or decision rule the user can remember.
45
+ The required order is arrive, steady, widen, step, carry.
46
+
47
+ Non-negotiable content rules:
48
+ - Keep forest movement and scenery out of narration. Put walking, trees, streams,
49
+ breathing, light, and symbolic action in scene_intro or image_prompt.
50
+ - Narration and reflection must discuss the user's actual concern in plain, literal words.
51
+ Every narration must use at least one concrete term from the situation.
52
+ - The step narration must name an action, not merely ask the user to take a step. Use an
53
+ optional frame plus a practical verb such as ask, check, compare, identify, list, practice,
54
+ read, review, study, or write. For a test-score worry, a valid kind of action is to review
55
+ one missed question and note which topic needs practice. Adapt the action to the situation;
56
+ do not copy this example when it does not fit.
57
+ - Carry must state a literal rule the user can remember, such as what to do when the worry
58
+ returns. Do not end only with calm, peace, light, a path, or a breath.
59
+
60
+ Advice in widen, step, and carry must preserve agency. Use "could", "might", "one option
61
+ is", or "consider" instead of commands. Make the action fit facts the user supplied. Do not
62
+ invent a resource, person, deadline, place, result, or past action. The goal is useful support,
63
+ not therapy language or a motivational speech.
64
+
65
+ The carry chapter closes the walk gently and is required. Give every chapter a distinct
66
+ sentence structure, emotional angle, scene title, narration, reflection, and spell. Do not
67
+ begin every chapter with the user's situation.
68
+ Do not quote or closely paraphrase the full situation more than once across the forest.
69
+ The source_phrase belongs in its metadata field;
70
+ do not force it into the narration. Avoid vague stock phrases about leaving space around a
71
+ worry, keeping your pace, staying with what is known, or an unsettled path. Always
72
+ silently repair spelling and grammar without calling attention to it. Acknowledge difficulty without
73
+ diagnosis, guarantees, hollow praise, or toxic positivity.
74
+ Never claim unsupported completed actions, memories, counts, dates, people, employers,
75
+ interviews, applications, relationships, or places. If the situation does
76
+ not state a past action, do not write "you have", "you did", "you asked", "you spoke", "you
77
+ sent", "you remember", or similar biography. Never turn a suggestion into present-tense
78
+ biography such as "you open", "you keep", "you say", or "you read". Frame an option with
79
+ "could", "might", "one option is", or a reflection question. Each chapter must copy one
80
+ exact source_phrase from the validated fact plan into the source_phrase field.
81
+ The forest_title must include the user's name.
82
+ Use the clarifying conversation when present to shape emotional focus, voice, and
83
+ imagery, but do not list the answers back at the user. The image_prompt describes one
84
+ coherent storybook scene; include no text, logo, artist name, or copyrighted character.
85
+
86
+ When revision_feedback is present, revise only the chapters named by numeric index.
87
+ Preserve every chapter that has no revision feedback exactly, field for field. If feedback
88
+ uses the key whole_forest, start over and write a fresh complete forest."""
89
 
90
  CRITIC_SYSTEM = """You are the quality gate for The Compliment Forest.
91
  Return exactly one JSON object with keep_indices, revise_indices, reasons, and optional scores.
92
+ Judge factual faithfulness, ordered story progression, situation-specificity, warmth,
93
+ non-genericness, non-toxic-positivity, narrative_continuity (does scene_intro bridge from the
94
+ previous chapter?), redundancy, abstract language, and practical usefulness. Revise stock
95
+ phrases about uncertainty, possibility, pace, or paths when they do not explain the user's
96
+ actual problem. The step chapter must contain a small practical next step framed as an option.
97
+ The widen chapter should offer realistic choices, and carry should leave a simple plan or
98
+ decision rule. Any claim not supported by the situation or validated fact plan must be revised.
99
+ Keep the required arrive, steady, widen, step, and carry roles
100
+ when they are faithful and distinct. Request revision only where one grounded rewrite can
101
+ repair the draft. Never create new prose. Refer to each chapter by its scene_title.
102
+ Do not put quotation marks inside reason strings. Use only indices listed in
103
+ valid_indices. Omit scores rather than returning an incomplete score object."""
104
+
105
+ INTAKE_SYSTEM = """You are interviewing the user about THEIR situation so the forest can
106
+ respond later with grounded, specific encouragement. Read the user's situation and every
107
+ prior question/answer. Produce exactly one JSON object matching the supplied schema.
108
+
109
+ The question must:
110
+ - Be one short sentence in second-person.
111
+ - Follow the request's focus_dimension and probe a NEW part of the situation.
112
+ - Never repeat or closely paraphrase any prior or rejected question.
113
+ - Echo or quote a concrete detail the user actually wrote so the question cannot feel
114
+ generic. If their situation is very short, ask about the feeling underneath rather
115
+ than fabricating facts.
116
+
117
+ Do NOT ask about the forest's tone, voice, art style, imagery, soundtrack, or how the
118
+ encouragement should sound. Those are picked separately by the user. Stay entirely
119
+ focused on understanding the user's problem.
120
+
121
+ Provide 3-4 short, distinct multiple-choice options. Each option must read as a
122
+ plausible answer the user themself might give, in their own emotional register — NOT
123
+ generic taxonomy labels like 'Anxious' or 'Hopeful'. Options should be specific to the
124
+ user's situation when possible.
125
+
126
+ Keep the entire response under 900 characters. Use normal JSON quotes, not
127
+ backslash-escaped quotes, and do not wrap the JSON object in a string. Set the trace
128
+ field to an empty string: "rationale": "". Stop immediately after the closing brace.
129
+
130
+ Do not repeat any prior question or option. Do not diagnose, advise, or guarantee
131
+ outcomes. Do not invent biography about the user."""
132
+
133
+ INTAKE_FOCUS_DIMENSIONS = (
134
+ "what specifically triggers or shapes the worry",
135
+ "what feels most at stake",
136
+ "when it feels harder or easier",
137
+ "what they have already tried or what support would help",
138
+ "what better or a small win would look like",
139
+ )
140
+
141
+ RECOVERY_INTAKE_QUESTIONS: tuple[dict[str, object], ...] = (
142
+ {
143
+ "question": "Which part of this feels loudest right now?",
144
+ "options": [
145
+ "What might go wrong",
146
+ "What other people will think",
147
+ "The unknown right after this",
148
+ ],
149
+ "rationale": "",
150
+ },
151
+ {
152
+ "question": "What feels most at stake for you here?",
153
+ "options": [
154
+ "How I see myself",
155
+ "How other people see me",
156
+ "A chance I do not want to lose",
157
+ ],
158
+ "rationale": "",
159
+ },
160
+ {
161
+ "question": "When does this feel hardest?",
162
+ "options": [
163
+ "When I have time alone with it",
164
+ "When I am around the people involved",
165
+ "Right before I have to act",
166
+ ],
167
+ "rationale": "",
168
+ },
169
+ {
170
+ "question": "What kind of support would help most right now?",
171
+ "options": [
172
+ "Someone listening without fixing it",
173
+ "A clearer next step",
174
+ "More time and room to think",
175
+ ],
176
+ "rationale": "",
177
+ },
178
+ {
179
+ "question": "What would a small win here look like?",
180
+ "options": [
181
+ "Just getting through it",
182
+ "Doing one part well",
183
+ "Feeling more honest about what I need",
184
+ ],
185
+ "rationale": "",
186
+ },
187
+ )
188
+
189
+
190
+ def intake_messages(
191
+ name: str,
192
+ situation: str,
193
+ history: list[dict[str, str]] | None = None,
194
+ *,
195
+ rejected_questions: list[str] | None = None,
196
+ seed: int = 3407,
197
+ ) -> list[dict[str, str]]:
198
+ history_payload = list(history or [])
199
+ focus_index = min(len(history_payload), len(INTAKE_FOCUS_DIMENSIONS) - 1)
200
+ request = {
201
+ "name": name,
202
+ "situation": situation,
203
+ "history": history_payload,
204
+ "rejected_questions": list(rejected_questions or []),
205
+ "focus_dimension": INTAKE_FOCUS_DIMENSIONS[focus_index],
206
+ "turn_index": len(history_payload),
207
+ "total_turns": 5,
208
+ "seed": seed,
209
+ "schema": {
210
+ "question": "one short second-person question",
211
+ "options": ["3-4 distinct short multiple-choice answers"],
212
+ "rationale": "optional short note for trace",
213
+ },
214
+ }
215
+ return [
216
+ {"role": "system", "content": INTAKE_SYSTEM},
217
+ {"role": "user", "content": json.dumps(request, ensure_ascii=False)},
218
+ ]
219
+
220
+
221
+ def planner_messages(
222
+ name: str,
223
+ situation: str,
224
+ *,
225
+ seed: int = 3407,
226
+ ) -> list[dict[str, str]]:
227
+ request = {
228
+ "name": name,
229
+ "situation": situation,
230
+ "seed": seed,
231
+ "schema": {
232
+ "faithful_summary": "conservative paraphrase using only stated facts",
233
+ "fact_anchors": [
234
+ {
235
+ "source_phrase": "exact contiguous text copied from situation",
236
+ "meaning": "conservative meaning of that phrase",
237
+ }
238
+ ],
239
+ "central_uncertainty": "what is not known or feared",
240
+ "desired_direction": "what the user appears to want, without guarantees",
241
+ },
242
+ }
243
+ return [
244
+ {"role": "system", "content": PLANNER_SYSTEM},
245
+ {"role": "user", "content": json.dumps(request, ensure_ascii=False)},
246
+ ]
247
 
248
 
249
  def author_messages(
250
  name: str,
251
  situation: str,
252
  *,
253
+ plan: dict[str, object],
254
  feedback: dict[str, str] | None = None,
255
  original: dict[str, object] | None = None,
256
+ seed: int = 3407,
257
  ) -> list[dict[str, str]]:
258
  request: dict[str, object] = {
259
  "name": name,
260
  "situation": situation,
261
+ "validated_fact_plan": plan,
262
+ "seed": seed,
263
  "schema": {
264
  "forest_title": "string",
265
  "proposed_strengths": ["3-6 distinct strings"],
266
  "clearings": [
267
  {
268
+ "arc_role": "arrive | steady | widen | step | carry",
269
+ "source_phrase": "exact source phrase from validated_fact_plan",
270
+ "scene_title": "short symbolic scene title (place, figure, or moment)",
271
+ "scene_intro": "one or two sentences bridging from the previous chapter",
272
+ "narration": (
273
+ "two short plain-language paragraphs performing the arc role's job"
274
+ ),
275
+ "strength": "short noun phrase the chapter quietly honors",
276
+ "reflection": "short question that helps the user notice, choose, or plan",
277
  "spell": "short first-person present-tense mantra",
278
+ "image_prompt": "one coherent scene, no style words or text",
279
  }
280
  ],
281
  },
 
294
  name: str,
295
  situation: str,
296
  forest: dict[str, object],
297
+ *,
298
+ plan: dict[str, object],
299
+ seed: int = 3407,
300
  ) -> list[dict[str, str]]:
301
+ clearings = forest.get("clearings")
302
+ clearing_count = len(clearings) if isinstance(clearings, list) else 0
303
  request = {
304
  "name": name,
305
  "situation": situation,
306
+ "validated_fact_plan": plan,
307
+ "seed": seed,
308
  "draft": forest,
309
+ "valid_indices": list(range(clearing_count)),
310
  "output_schema": {
311
+ "keep_indices": ["unique values from valid_indices"],
312
+ "revise_indices": ["subset of keep_indices needing one rewrite"],
313
+ "reasons": {"index": "brief actionable reason without quotation marks"},
314
+ "scores": [],
 
 
 
 
 
 
 
 
 
315
  },
316
  }
317
  return [
318
  {"role": "system", "content": CRITIC_SYSTEM},
319
  {"role": "user", "content": json.dumps(request, ensure_ascii=False)},
320
  ]
 
src/compliment_forest/quality.py CHANGED
@@ -1,8 +1,157 @@
1
  from __future__ import annotations
2
 
3
  import re
 
 
 
4
 
5
  _WORD_PATTERN = re.compile(r"[A-Za-z][A-Za-z'-]{2,}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
  _STOP_WORDS = {
7
  "about",
8
  "after",
@@ -52,3 +201,201 @@ def groundedness_score(line: str, situation: str) -> int:
52
  def is_situation_grounded(line: str, situation: str) -> bool:
53
  return groundedness_score(line, situation) >= 1
54
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  from __future__ import annotations
2
 
3
  import re
4
+ from collections.abc import Sequence
5
+
6
+ from .schema import Clearing, SituationPlan
7
 
8
  _WORD_PATTERN = re.compile(r"[A-Za-z][A-Za-z'-]{2,}")
9
+ _DUPLICATE_WORD_PATTERN = re.compile(r"[A-Za-z][A-Za-z'-]*")
10
+ _DUPLICATE_FIELDS = ("scene_intro", "narration", "reflection", "spell")
11
+ _ARC_ORDER = ("arrive", "steady", "widen", "step", "carry")
12
+ _ABSTRACT_STOCK_PHRASES = (
13
+ "leave space around the worry",
14
+ "more than one future remains possible",
15
+ "the whole path is already settled",
16
+ "the whole path is settled",
17
+ "keep your own pace",
18
+ "return to what is known",
19
+ "stay with what is known",
20
+ "treating its forecast as a settled fact",
21
+ "what deserves attention now",
22
+ "when the worry grows loud",
23
+ )
24
+ _PRACTICAL_ACTION_PATTERN = re.compile(
25
+ r"\b(?:could|might|can|consider|try|one option is(?: to)?|"
26
+ r"(?:one|a) small step is(?: to)?)\b"
27
+ r"(?:\W+\w+){0,5}\W+"
28
+ r"(?:ask|break|check|choos(?:e|ing)|compar(?:e|ing)|contact|"
29
+ r"focus|identif(?:y|ying)|list|look|not(?:e|ing)|pick|practic(?:e|ing)|"
30
+ r"read|review|schedul(?:e|ing)|start|stud(?:y|ying)|talk|test|"
31
+ r"tackl(?:e|ing)|revisit|writ(?:e|ing))\b",
32
+ re.IGNORECASE,
33
+ )
34
+ _NUMBER_WORDS = {
35
+ "zero",
36
+ "two",
37
+ "three",
38
+ "four",
39
+ "five",
40
+ "six",
41
+ "seven",
42
+ "eight",
43
+ "nine",
44
+ "ten",
45
+ "eleven",
46
+ "twelve",
47
+ }
48
+ _DATE_WORDS = {
49
+ "monday",
50
+ "tuesday",
51
+ "wednesday",
52
+ "thursday",
53
+ "friday",
54
+ "saturday",
55
+ "sunday",
56
+ "january",
57
+ "february",
58
+ "march",
59
+ "april",
60
+ "june",
61
+ "july",
62
+ "august",
63
+ "september",
64
+ "october",
65
+ "november",
66
+ "december",
67
+ }
68
+ _UNSUPPORTED_DETAIL_PHRASES = {
69
+ "application",
70
+ "applications",
71
+ "cafe",
72
+ "coffee break",
73
+ "coffee breaks",
74
+ "company",
75
+ "cover letter",
76
+ "cover letters",
77
+ "hiring manager",
78
+ "hiring managers",
79
+ "interview",
80
+ "interviews",
81
+ "mentor",
82
+ "old boss",
83
+ "team meeting",
84
+ "team meetings",
85
+ }
86
+ _ACTION_FORMS = {
87
+ "apply": {"applied", "apply"},
88
+ "ask": {"asked", "ask"},
89
+ "book": {"booked", "book"},
90
+ "complete": {"completed", "complete"},
91
+ "draft": {"drafted", "draft"},
92
+ "finish": {"finished", "finish"},
93
+ "fix": {"fixed", "fix"},
94
+ "include": {"included", "include"},
95
+ "make": {"made", "make"},
96
+ "meet": {"met", "meet"},
97
+ "notice": {"noticed", "notice"},
98
+ "practice": {"practiced", "practised", "practice", "practise"},
99
+ "prepare": {"prepared", "prepare"},
100
+ "remember": {"remembered", "remember"},
101
+ "research": {"researched", "research"},
102
+ "revise": {"revised", "revise"},
103
+ "schedule": {"scheduled", "schedule"},
104
+ "send": {"sent", "send"},
105
+ "show": {"showed", "shown", "show"},
106
+ "speak": {"spoke", "spoken", "speak"},
107
+ "start": {"started", "start"},
108
+ "talk": {"talked", "talk"},
109
+ "write": {"wrote", "written", "write"},
110
+ }
111
+ _ACTION_LOOKUP = {form: root for root, forms in _ACTION_FORMS.items() for form in forms}
112
+ _PAST_ACTION_LOOKUP = {
113
+ form: root
114
+ for root, forms in _ACTION_FORMS.items()
115
+ for form in forms
116
+ if form not in {root, "practise"}
117
+ }
118
+ _PERFECT_CLAIM_PATTERN = re.compile(
119
+ r"\byou(?:'ve| have| had)\s+(?:already\s+)?"
120
+ r"(?P<verb>" + "|".join(sorted(_ACTION_LOOKUP, key=len, reverse=True)) + r")\b",
121
+ re.IGNORECASE,
122
+ )
123
+ _SIMPLE_PAST_CLAIM_PATTERN = re.compile(
124
+ r"\byou\s+(?P<verb>"
125
+ + "|".join(sorted(_PAST_ACTION_LOOKUP, key=len, reverse=True))
126
+ + r")\b",
127
+ re.IGNORECASE,
128
+ )
129
+ _DIRECT_CLAIM_PATTERN = re.compile(
130
+ r"(?:^|[.!?]\s+)[\"'“”]?\s*you\s+(?P<verb>[a-z][a-z'-]*)\b",
131
+ re.IGNORECASE,
132
+ )
133
+ _NON_BIOGRAPHICAL_YOU_VERBS = {
134
+ "are",
135
+ "can",
136
+ "could",
137
+ "deserve",
138
+ "do",
139
+ "don't",
140
+ "fear",
141
+ "feel",
142
+ "hope",
143
+ "know",
144
+ "matter",
145
+ "may",
146
+ "might",
147
+ "need",
148
+ "seem",
149
+ "should",
150
+ "want",
151
+ "wonder",
152
+ "would",
153
+ "worry",
154
+ }
155
  _STOP_WORDS = {
156
  "about",
157
  "after",
 
201
  def is_situation_grounded(line: str, situation: str) -> bool:
202
  return groundedness_score(line, situation) >= 1
203
 
204
+
205
+ def _normalized_phrase(text: str) -> str:
206
+ return " ".join(_DUPLICATE_WORD_PATTERN.findall(text.casefold()))
207
+
208
+
209
+ def source_phrase_in_situation(source_phrase: str, situation: str) -> bool:
210
+ source = _normalized_phrase(source_phrase)
211
+ return bool(source) and source in _normalized_phrase(situation)
212
+
213
+
214
+ def invalid_fact_anchor_indices(
215
+ plan: SituationPlan,
216
+ situation: str,
217
+ ) -> list[int]:
218
+ return [
219
+ index
220
+ for index, anchor in enumerate(plan.fact_anchors)
221
+ if not source_phrase_in_situation(anchor.source_phrase, situation)
222
+ ]
223
+
224
+
225
+ def _number_tokens(text: str) -> set[str]:
226
+ return {
227
+ token
228
+ for token in re.findall(r"\b(?:\d+|[a-z]+)\b", text.casefold())
229
+ if token.isdigit() or token in _NUMBER_WORDS
230
+ }
231
+
232
+
233
+ def _date_tokens(text: str) -> set[str]:
234
+ tokens = set(re.findall(r"\b[a-z]+\b", text.casefold()))
235
+ result = tokens & _DATE_WORDS
236
+ result.update(re.findall(r"\b\d{1,2}(?::\d{2})?\s*(?:a\.?m\.?|p\.?m\.?)\b", text.casefold()))
237
+ return result
238
+
239
+
240
+ def _action_roots(text: str) -> set[str]:
241
+ tokens = set(re.findall(r"\b[a-z]+\b", text.casefold()))
242
+ return {root for form, root in _ACTION_LOOKUP.items() if form in tokens}
243
+
244
+
245
+ def _match_is_in_question(text: str, start: int) -> bool:
246
+ sentence_start = max(
247
+ text.rfind(".", 0, start),
248
+ text.rfind("!", 0, start),
249
+ text.rfind("?", 0, start),
250
+ )
251
+ sentence_end_candidates = [
252
+ position
253
+ for punctuation in ".!?"
254
+ if (position := text.find(punctuation, start)) >= 0
255
+ ]
256
+ if not sentence_end_candidates:
257
+ return False
258
+ sentence_end = min(sentence_end_candidates)
259
+ return text[sentence_start + 1 : sentence_end + 1].strip().endswith("?")
260
+
261
+
262
+ def unsupported_specificity(text: str, situation: str) -> set[str]:
263
+ """Find concrete claims in generated prose that the user did not provide."""
264
+
265
+ issues: set[str] = set()
266
+ if _number_tokens(text) - _number_tokens(situation):
267
+ issues.add("invented_number")
268
+ if _date_tokens(text) - _date_tokens(situation):
269
+ issues.add("invented_date")
270
+
271
+ normalized_text = _normalized_phrase(text)
272
+ normalized_situation = _normalized_phrase(situation)
273
+ if any(
274
+ phrase in normalized_text and phrase not in normalized_situation
275
+ for phrase in _UNSUPPORTED_DETAIL_PHRASES
276
+ ):
277
+ issues.add("unsupported_detail")
278
+
279
+ situation_actions = _action_roots(situation)
280
+ past_claims = (
281
+ (_PERFECT_CLAIM_PATTERN, _ACTION_LOOKUP),
282
+ (_SIMPLE_PAST_CLAIM_PATTERN, _PAST_ACTION_LOOKUP),
283
+ )
284
+ for pattern, lookup in past_claims:
285
+ for match in pattern.finditer(text):
286
+ if _match_is_in_question(text, match.start()):
287
+ continue
288
+ root = lookup[match.group("verb").casefold()]
289
+ if root not in situation_actions:
290
+ issues.add("unsupported_past_claim")
291
+ break
292
+ if "unsupported_past_claim" in issues:
293
+ break
294
+
295
+ situation_tokens = set(re.findall(r"\b[a-z][a-z'-]*\b", situation.casefold()))
296
+ for match in _DIRECT_CLAIM_PATTERN.finditer(text):
297
+ verb = match.group("verb").casefold()
298
+ if verb not in _NON_BIOGRAPHICAL_YOU_VERBS and verb not in situation_tokens:
299
+ issues.add("unsupported_direct_claim")
300
+ break
301
+ return issues
302
+
303
+
304
+ def content_quality_issues(clearing: Clearing) -> set[str]:
305
+ """Report stock abstraction and missing practical help in a clearing."""
306
+
307
+ issues: set[str] = set()
308
+ normalized = _normalized_phrase(
309
+ " ".join((clearing.scene_intro, clearing.narration, clearing.reflection))
310
+ )
311
+ if any(phrase in normalized for phrase in _ABSTRACT_STOCK_PHRASES):
312
+ issues.add("abstract_language")
313
+ if clearing.arc_role == "step" and (
314
+ "abstract_language" in issues
315
+ or not _PRACTICAL_ACTION_PATTERN.search(clearing.narration)
316
+ ):
317
+ issues.add("missing_practical_step")
318
+ return issues
319
+
320
+
321
+ def repeated_source_phrase_indices(
322
+ clearings: Sequence[Clearing],
323
+ *,
324
+ minimum_words: int = 5,
325
+ ) -> set[int]:
326
+ """Flag later narrations that repeat the same long source phrase."""
327
+
328
+ seen: set[str] = set()
329
+ repeated: set[int] = set()
330
+ for index, clearing in enumerate(clearings):
331
+ source = _normalized_phrase(clearing.source_phrase)
332
+ if len(source.split()) < minimum_words:
333
+ continue
334
+ if source not in _normalized_phrase(clearing.narration):
335
+ continue
336
+ if source in seen:
337
+ repeated.add(index)
338
+ seen.add(source)
339
+ return repeated
340
+
341
+
342
+ def valid_arc_indices(clearings: Sequence[Clearing]) -> list[int]:
343
+ """Return the first clearing for each arc role in narrative order."""
344
+
345
+ by_role: dict[str, int] = {}
346
+ for index, clearing in enumerate(clearings):
347
+ by_role.setdefault(clearing.arc_role, index)
348
+ return [by_role[role] for role in _ARC_ORDER if role in by_role]
349
+
350
+
351
+ def _normalized_tokens(text: str) -> set[str]:
352
+ return {token.casefold().strip("'") for token in _DUPLICATE_WORD_PATTERN.findall(text)}
353
+
354
+
355
+ def _token_containment(left: str, right: str) -> float:
356
+ left_tokens = _normalized_tokens(left)
357
+ right_tokens = _normalized_tokens(right)
358
+ if not left_tokens or not right_tokens:
359
+ return 0
360
+ return len(left_tokens & right_tokens) / min(len(left_tokens), len(right_tokens))
361
+
362
+
363
+ def duplicate_fields(
364
+ clearings: Sequence[Clearing],
365
+ indices: Sequence[int] | None = None,
366
+ *,
367
+ threshold: float = 0.78,
368
+ ) -> dict[int, list[str]]:
369
+ """Report fields in later clearings that substantially repeat an earlier one."""
370
+
371
+ candidate_indices = list(indices) if indices is not None else list(range(len(clearings)))
372
+ duplicates: dict[int, list[str]] = {}
373
+ for position, index in enumerate(candidate_indices):
374
+ for prior_index in candidate_indices[:position]:
375
+ for field in _DUPLICATE_FIELDS:
376
+ if field in duplicates.get(index, []):
377
+ continue
378
+ left = getattr(clearings[prior_index], field)
379
+ right = getattr(clearings[index], field)
380
+ if _token_containment(left, right) >= threshold:
381
+ duplicates.setdefault(index, []).append(field)
382
+ return duplicates
383
+
384
+
385
+ def distinct_indices(
386
+ clearings: Sequence[Clearing],
387
+ indices: Sequence[int],
388
+ *,
389
+ threshold: float = 0.78,
390
+ ) -> list[int]:
391
+ """Keep the earliest clearing from each group of repetitive prose."""
392
+
393
+ selected: list[int] = []
394
+ for index in indices:
395
+ if not duplicate_fields(
396
+ clearings,
397
+ [*selected, index],
398
+ threshold=threshold,
399
+ ).get(index):
400
+ selected.append(index)
401
+ return selected