docs(codex): publish Build Small submission package
#4
by thangvip - opened
- README.md +138 -21
- app.py +5 -5
- docs/build-small-hackathon-article.md +215 -0
- docs/demo-script.md +112 -0
- docs/social-post.md +47 -0
- frontend/app.js +479 -84
- frontend/index.html +289 -130
- frontend/styles.css +962 -624
- requirements.txt +1 -0
- src/compliment_forest/backends/image.py +270 -47
- src/compliment_forest/backends/music.py +101 -0
- src/compliment_forest/backends/text.py +677 -58
- src/compliment_forest/orchestrator.py +882 -101
- src/compliment_forest/prompts.py +265 -28
- src/compliment_forest/quality.py +347 -0
README.md
CHANGED
|
@@ -10,43 +10,160 @@ app_file: app.py
|
|
| 10 |
fullWidth: true
|
| 11 |
header: mini
|
| 12 |
pinned: true
|
| 13 |
-
|
|
|
|
| 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 |
-
-
|
| 25 |
-
-
|
|
|
|
| 26 |
- llama.cpp
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
---
|
| 28 |
|
| 29 |
# The Compliment Forest
|
| 30 |
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
|
|
|
| 34 |
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 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 |
-
##
|
| 42 |
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
- Watercolor data: `build-small-hackathon/compliment-forest-watercolor`
|
| 48 |
-
- Linked-model traces: `build-small-hackathon/compliment-forest-traces`
|
| 49 |
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
|
|
|
| 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
|
| 15 |
-
const
|
| 16 |
-
const
|
| 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
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
}
|
| 58 |
|
| 59 |
-
function
|
| 60 |
-
|
| 61 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 208 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 215 |
line = word;
|
| 216 |
-
lineCount += 1;
|
| 217 |
-
if (lineCount >= maxLines) return y + lineCount * lineHeight;
|
| 218 |
} else {
|
| 219 |
line = next;
|
| 220 |
}
|
| 221 |
}
|
| 222 |
-
if (line
|
| 223 |
-
|
| 224 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 225 |
}
|
| 226 |
-
return y +
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 245 |
-
const
|
|
|
|
|
|
|
|
|
|
| 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 =
|
| 261 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 262 |
context.font = '22px "Avenir Next", Arial, sans-serif';
|
| 263 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 264 |
context.textAlign = "left";
|
| 265 |
|
| 266 |
-
for (
|
| 267 |
-
const item =
|
| 268 |
-
const top = 205 + index * rowHeight;
|
| 269 |
try {
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 277 |
} catch {
|
| 278 |
context.fillStyle = "#d8ddc8";
|
| 279 |
context.fillRect(80, top, 350, 280);
|
| 280 |
}
|
| 281 |
|
| 282 |
context.fillStyle = "#3f5634";
|
| 283 |
-
context.font =
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 287 |
context.fillStyle = "#243c31";
|
| 288 |
-
context.font =
|
| 289 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 =
|
| 297 |
context.fillStyle = "#3f5634";
|
| 298 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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, """);
|
| 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 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 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 |
-
<
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
<img id="clearing-image" src="" alt="" />
|
| 96 |
-
</figure>
|
| 97 |
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 102 |
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
</section>
|
| 107 |
|
| 108 |
-
<section class="
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 118 |
</section>
|
|
|
|
| 119 |
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 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 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
|
|
|
|
|
|
| 15 |
}
|
| 16 |
|
| 17 |
* {
|
| 18 |
-
|
| 19 |
}
|
| 20 |
|
| 21 |
html {
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
}
|
| 26 |
|
| 27 |
body {
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
}
|
| 39 |
|
| 40 |
button,
|
| 41 |
input,
|
|
|
|
| 42 |
textarea {
|
| 43 |
-
|
| 44 |
}
|
| 45 |
|
| 46 |
button {
|
| 47 |
-
|
| 48 |
}
|
| 49 |
|
| 50 |
.paper-grain {
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
}
|
| 58 |
|
| 59 |
.site-header {
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
}
|
| 66 |
|
| 67 |
.brand {
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
}
|
| 78 |
|
| 79 |
.brand-mark {
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
}
|
| 89 |
|
| 90 |
.brand-mark ellipse {
|
| 91 |
-
|
| 92 |
-
|
| 93 |
}
|
| 94 |
|
| 95 |
.view {
|
| 96 |
-
|
| 97 |
-
|
| 98 |
}
|
| 99 |
|
| 100 |
.entry-view {
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
}
|
| 106 |
|
| 107 |
.entry-copy {
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
}
|
| 112 |
|
| 113 |
.entry-copy h1,
|
| 114 |
.path-header h1 {
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
}
|
| 121 |
|
| 122 |
.entry-copy h1 {
|
| 123 |
-
|
| 124 |
-
|
| 125 |
}
|
| 126 |
|
| 127 |
.intro {
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
}
|
| 134 |
|
| 135 |
#forest-form {
|
| 136 |
-
|
| 137 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
}
|
| 139 |
|
| 140 |
#forest-form label {
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
}
|
| 146 |
|
| 147 |
#forest-form input,
|
|
|
|
| 148 |
#forest-form textarea {
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
}
|
| 162 |
|
| 163 |
#forest-form input {
|
| 164 |
-
|
| 165 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 166 |
}
|
| 167 |
|
| 168 |
#forest-form textarea {
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
}
|
| 174 |
|
| 175 |
#forest-form input::placeholder,
|
| 176 |
#forest-form textarea::placeholder {
|
| 177 |
-
|
| 178 |
}
|
| 179 |
|
| 180 |
#forest-form input:focus,
|
|
|
|
| 181 |
#forest-form textarea:focus {
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
}
|
| 186 |
|
| 187 |
.primary-button {
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
}
|
| 207 |
|
| 208 |
.primary-button svg {
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
}
|
| 217 |
|
| 218 |
.primary-button:hover:not(:disabled) {
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
}
|
| 223 |
|
| 224 |
.primary-button:disabled {
|
| 225 |
-
|
| 226 |
-
|
| 227 |
}
|
| 228 |
|
| 229 |
.grow-button {
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 234 |
}
|
| 235 |
|
| 236 |
.status-region {
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
}
|
| 243 |
|
| 244 |
.status-region:not(:empty)::before {
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
}
|
| 255 |
|
| 256 |
.status-region.is-error {
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
}
|
| 263 |
|
| 264 |
.entry-art {
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
}
|
| 271 |
|
| 272 |
.entry-art::after {
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
}
|
| 281 |
|
| 282 |
.entry-art img {
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
}
|
| 289 |
|
| 290 |
.experience-view {
|
| 291 |
-
|
| 292 |
-
|
| 293 |
}
|
| 294 |
|
| 295 |
.path-header {
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
}
|
| 300 |
|
| 301 |
.path-header h1 {
|
| 302 |
-
|
| 303 |
-
|
| 304 |
}
|
| 305 |
|
| 306 |
#clearing-progress {
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 311 |
}
|
| 312 |
|
| 313 |
.progress-dots {
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
}
|
| 319 |
|
| 320 |
.progress-dot {
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
}
|
| 329 |
|
| 330 |
.progress-dot.is-complete {
|
| 331 |
-
|
| 332 |
}
|
| 333 |
|
| 334 |
.progress-dot.is-current {
|
| 335 |
-
|
| 336 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 337 |
}
|
| 338 |
|
| 339 |
.clearing {
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
}
|
| 347 |
|
| 348 |
.clearing-art {
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
}
|
| 353 |
|
| 354 |
.image-wash {
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 362 |
}
|
| 363 |
|
| 364 |
.clearing-art img {
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 374 |
}
|
| 375 |
|
| 376 |
.clearing-copy {
|
| 377 |
-
|
| 378 |
-
|
| 379 |
}
|
| 380 |
|
| 381 |
-
.creature,
|
| 382 |
.section-label {
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 389 |
}
|
| 390 |
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
font-weight: 400;
|
| 396 |
-
line-height: 1.1;
|
| 397 |
}
|
| 398 |
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
}
|
| 406 |
|
| 407 |
.carried-question,
|
| 408 |
.spell-section {
|
| 409 |
-
|
| 410 |
-
|
| 411 |
}
|
| 412 |
|
| 413 |
#reflection,
|
| 414 |
.spell {
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
|
| 419 |
}
|
| 420 |
|
| 421 |
.spell-section {
|
| 422 |
-
|
| 423 |
}
|
| 424 |
|
| 425 |
.spell {
|
| 426 |
-
|
| 427 |
}
|
| 428 |
|
| 429 |
.copy-button {
|
| 430 |
-
|
| 431 |
-
|
| 432 |
-
|
| 433 |
-
|
| 434 |
-
|
| 435 |
-
|
| 436 |
-
|
| 437 |
-
|
| 438 |
-
|
| 439 |
}
|
| 440 |
|
| 441 |
.copy-button svg {
|
| 442 |
-
|
| 443 |
-
|
| 444 |
-
|
| 445 |
-
|
| 446 |
-
|
| 447 |
}
|
| 448 |
|
| 449 |
.path-actions {
|
| 450 |
-
|
| 451 |
-
|
| 452 |
-
|
| 453 |
}
|
| 454 |
|
| 455 |
.save-button {
|
| 456 |
-
|
| 457 |
}
|
| 458 |
|
| 459 |
.text-button {
|
| 460 |
-
|
| 461 |
-
|
| 462 |
-
|
| 463 |
-
|
| 464 |
-
|
| 465 |
-
|
| 466 |
-
|
| 467 |
}
|
| 468 |
|
| 469 |
.text-button:hover {
|
| 470 |
-
|
| 471 |
-
|
| 472 |
}
|
| 473 |
|
| 474 |
footer {
|
| 475 |
-
|
| 476 |
-
|
| 477 |
-
|
| 478 |
-
|
| 479 |
-
|
| 480 |
-
|
| 481 |
}
|
| 482 |
|
| 483 |
.fireflies {
|
| 484 |
-
|
| 485 |
-
|
| 486 |
-
|
| 487 |
-
|
| 488 |
}
|
| 489 |
|
| 490 |
.fireflies i {
|
| 491 |
-
|
| 492 |
-
|
| 493 |
-
|
| 494 |
-
|
| 495 |
-
|
| 496 |
-
|
| 497 |
-
|
| 498 |
}
|
| 499 |
|
| 500 |
.fireflies i:nth-child(1) {
|
| 501 |
-
|
| 502 |
-
|
| 503 |
}
|
| 504 |
|
| 505 |
.fireflies i:nth-child(2) {
|
| 506 |
-
|
| 507 |
-
|
| 508 |
-
|
| 509 |
}
|
| 510 |
|
| 511 |
.fireflies i:nth-child(3) {
|
| 512 |
-
|
| 513 |
-
|
| 514 |
-
|
| 515 |
}
|
| 516 |
|
| 517 |
.fireflies i:nth-child(4) {
|
| 518 |
-
|
| 519 |
-
|
| 520 |
-
|
| 521 |
}
|
| 522 |
|
| 523 |
.fireflies i:nth-child(5) {
|
| 524 |
-
|
| 525 |
-
|
| 526 |
-
|
| 527 |
}
|
| 528 |
|
| 529 |
[hidden] {
|
| 530 |
-
|
| 531 |
}
|
| 532 |
|
| 533 |
button:focus-visible,
|
| 534 |
input:focus-visible,
|
|
|
|
| 535 |
textarea:focus-visible {
|
| 536 |
-
|
| 537 |
-
|
| 538 |
}
|
| 539 |
|
| 540 |
@keyframes drift {
|
| 541 |
-
|
| 542 |
-
|
| 543 |
-
|
| 544 |
-
|
| 545 |
-
|
| 546 |
|
| 547 |
-
|
| 548 |
-
|
| 549 |
-
|
| 550 |
-
|
| 551 |
}
|
| 552 |
|
| 553 |
@keyframes breathe {
|
| 554 |
-
|
| 555 |
-
|
| 556 |
-
|
| 557 |
-
|
| 558 |
-
|
| 559 |
|
| 560 |
-
|
| 561 |
-
|
| 562 |
-
|
| 563 |
-
|
| 564 |
}
|
| 565 |
|
| 566 |
-
@
|
| 567 |
-
|
| 568 |
-
|
| 569 |
-
|
|
|
|
| 570 |
|
| 571 |
-
|
| 572 |
-
|
| 573 |
-
|
| 574 |
-
|
| 575 |
-
.clearing {
|
| 576 |
-
gap: 48px;
|
| 577 |
-
}
|
| 578 |
}
|
| 579 |
|
| 580 |
-
@media (max-
|
| 581 |
-
|
| 582 |
-
|
| 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 |
-
|
| 656 |
-
|
| 657 |
-
|
| 658 |
|
| 659 |
-
|
| 660 |
-
|
| 661 |
-
|
|
|
|
| 662 |
|
| 663 |
-
|
| 664 |
-
|
| 665 |
-
|
| 666 |
-
|
| 667 |
-
|
| 668 |
-
|
| 669 |
-
|
| 670 |
-
|
| 671 |
-
|
| 672 |
-
|
| 673 |
-
|
| 674 |
-
|
| 675 |
-
|
| 676 |
-
|
| 677 |
-
|
| 678 |
-
|
| 679 |
-
|
| 680 |
-
|
| 681 |
-
|
| 682 |
-
|
| 683 |
-
|
| 684 |
-
|
| 685 |
-
|
| 686 |
-
|
| 687 |
-
|
| 688 |
-
|
| 689 |
-
|
| 690 |
-
|
| 691 |
-
|
| 692 |
-
|
| 693 |
-
|
| 694 |
-
|
| 695 |
-
|
| 696 |
-
|
| 697 |
-
|
| 698 |
-
|
| 699 |
-
|
| 700 |
-
|
| 701 |
-
|
| 702 |
-
|
| 703 |
-
|
| 704 |
-
|
| 705 |
-
|
| 706 |
-
|
| 707 |
-
|
| 708 |
-
|
| 709 |
-
|
| 710 |
-
|
| 711 |
-
|
| 712 |
-
|
| 713 |
-
|
| 714 |
-
|
| 715 |
-
|
| 716 |
-
|
| 717 |
-
|
| 718 |
-
|
| 719 |
-
|
| 720 |
-
|
| 721 |
-
|
| 722 |
-
|
| 723 |
-
|
| 724 |
-
|
| 725 |
-
|
| 726 |
-
|
| 727 |
-
|
| 728 |
-
|
| 729 |
-
|
| 730 |
-
|
| 731 |
-
|
| 732 |
-
|
| 733 |
-
|
| 734 |
-
|
| 735 |
-
|
| 736 |
-
|
| 737 |
-
|
| 738 |
-
|
| 739 |
-
|
| 740 |
-
|
| 741 |
-
|
| 742 |
-
|
| 743 |
-
|
| 744 |
-
|
| 745 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 746 |
}
|
| 747 |
|
| 748 |
@media (max-width: 760px) {
|
| 749 |
-
|
| 750 |
-
|
| 751 |
-
|
| 752 |
-
|
| 753 |
-
|
| 754 |
-
|
| 755 |
-
|
| 756 |
-
|
| 757 |
-
|
| 758 |
-
|
| 759 |
-
|
| 760 |
-
|
| 761 |
-
|
| 762 |
-
|
| 763 |
-
|
| 764 |
-
|
| 765 |
-
|
| 766 |
-
|
| 767 |
-
|
| 768 |
-
|
| 769 |
-
|
| 770 |
-
|
| 771 |
-
|
| 772 |
-
|
| 773 |
-
|
| 774 |
-
|
| 775 |
-
|
| 776 |
-
|
| 777 |
-
|
| 778 |
-
|
| 779 |
-
|
| 780 |
-
|
| 781 |
-
|
| 782 |
-
|
| 783 |
-
|
| 784 |
-
|
| 785 |
-
|
| 786 |
-
|
| 787 |
-
|
| 788 |
-
|
| 789 |
-
|
| 790 |
-
|
| 791 |
-
|
| 792 |
-
|
| 793 |
-
|
| 794 |
-
|
| 795 |
-
|
| 796 |
-
|
| 797 |
-
|
| 798 |
-
|
| 799 |
-
|
| 800 |
-
|
| 801 |
-
|
| 802 |
-
|
| 803 |
-
|
| 804 |
-
|
| 805 |
-
|
| 806 |
-
|
| 807 |
-
|
| 808 |
-
|
| 809 |
-
|
| 810 |
-
|
| 811 |
-
|
| 812 |
-
|
| 813 |
-
|
| 814 |
-
|
| 815 |
-
|
| 816 |
-
|
| 817 |
-
|
| 818 |
-
|
| 819 |
-
|
| 820 |
-
|
| 821 |
-
|
| 822 |
-
|
| 823 |
-
|
| 824 |
-
|
| 825 |
-
|
| 826 |
-
|
| 827 |
-
|
| 828 |
-
|
| 829 |
-
|
| 830 |
-
|
| 831 |
-
|
| 832 |
-
|
| 833 |
-
|
| 834 |
-
|
| 835 |
-
|
| 836 |
-
|
| 837 |
-
|
| 838 |
-
|
| 839 |
-
|
| 840 |
-
|
| 841 |
-
|
| 842 |
-
|
| 843 |
-
|
| 844 |
-
|
| 845 |
-
|
| 846 |
-
|
| 847 |
-
|
| 848 |
-
|
| 849 |
-
|
| 850 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 851 |
}
|
| 852 |
|
| 853 |
@media (prefers-reduced-motion: reduce) {
|
| 854 |
-
|
| 855 |
-
|
| 856 |
-
|
| 857 |
-
|
| 858 |
-
|
| 859 |
-
|
| 860 |
-
|
| 861 |
-
|
| 862 |
-
|
| 863 |
-
|
| 864 |
-
|
| 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,
|
|
|
|
| 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 |
-
|
| 30 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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(
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
| 5 |
from urllib.parse import urlparse
|
| 6 |
|
| 7 |
import httpx
|
|
|
|
| 8 |
|
| 9 |
-
from compliment_forest.prompts import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
|
| 24 |
|
| 25 |
class DemoTextBackend:
|
| 26 |
-
"""Deterministic development backend with the
|
| 27 |
|
| 28 |
-
|
|
|
|
|
|
|
|
|
|
| 29 |
(
|
| 30 |
-
"
|
| 31 |
-
"
|
| 32 |
-
"
|
| 33 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
),
|
| 35 |
(
|
| 36 |
-
"
|
| 37 |
-
"
|
| 38 |
-
"
|
| 39 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
),
|
| 41 |
(
|
| 42 |
-
"
|
| 43 |
-
"
|
| 44 |
-
"
|
| 45 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
),
|
| 47 |
(
|
| 48 |
-
"
|
| 49 |
-
"
|
| 50 |
-
"
|
| 51 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
),
|
| 53 |
(
|
| 54 |
-
"
|
| 55 |
-
"
|
| 56 |
-
|
| 57 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
),
|
| 59 |
)
|
| 60 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
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 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
|
| 94 |
-
def critic(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 122 |
response = self.client.post(
|
| 123 |
f"{self.base_url}/v1/chat/completions",
|
| 124 |
json={
|
| 125 |
"model": self.model,
|
| 126 |
"messages": messages,
|
| 127 |
-
"temperature":
|
| 128 |
"top_p": 0.9,
|
| 129 |
"max_tokens": max_tokens,
|
|
|
|
| 130 |
"response_format": {"type": "json_object"},
|
| 131 |
-
"chat_template_kwargs": {"enable_thinking":
|
| 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 |
-
|
| 148 |
-
max_tokens=
|
|
|
|
|
|
|
| 149 |
)
|
| 150 |
|
| 151 |
-
|
| 152 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 6 |
-
from
|
|
|
|
| 7 |
|
| 8 |
from pydantic import ValidationError
|
| 9 |
|
|
|
|
|
|
|
| 10 |
from .backends.text import TextBackend
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
from .safety import guard_input
|
| 12 |
-
from .schema import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
from .trace import TraceRecorder
|
| 14 |
|
| 15 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
def parse_json_object(raw: str) -> dict[str, object]:
|
| 17 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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:
|
|
|
|
| 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
|
| 60 |
try:
|
| 61 |
raw = self.text_backend.author(
|
| 62 |
name,
|
| 63 |
situation,
|
|
|
|
| 64 |
feedback=feedback,
|
| 65 |
original=original,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
)
|
| 67 |
-
return ForestDraft.model_validate(
|
| 68 |
except (json.JSONDecodeError, ValidationError, ValueError) as error:
|
| 69 |
last_error = error
|
| 70 |
-
raise ValueError(
|
|
|
|
|
|
|
| 71 |
|
| 72 |
def _critic(
|
| 73 |
self,
|
| 74 |
name: str,
|
| 75 |
situation: str,
|
| 76 |
forest: ForestDraft,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
) -> CriticDecision:
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
valid = [index for index in decision.keep_indices if index < len(forest.clearings)]
|
| 88 |
if not valid:
|
| 89 |
-
|
|
|
|
|
|
|
| 90 |
return decision.model_copy(update={"keep_indices": valid})
|
| 91 |
|
| 92 |
@staticmethod
|
| 93 |
-
def _survivor_indices(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
survivors: list[int] = []
|
| 95 |
strengths: set[str] = set()
|
| 96 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
strength = forest.clearings[index].strength.casefold()
|
| 98 |
-
if strength
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
strengths.add(strength)
|
| 100 |
survivors.append(index)
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 109 |
return survivors[:6]
|
| 110 |
|
| 111 |
-
def generate(
|
| 112 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
self._trace(
|
| 114 |
"guard",
|
| 115 |
name=name,
|
| 116 |
-
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 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 132 |
self._trace(
|
| 133 |
"author",
|
| 134 |
name=name,
|
| 135 |
-
situation=
|
| 136 |
-
model=
|
| 137 |
output=forest.model_dump(),
|
| 138 |
)
|
| 139 |
|
| 140 |
-
yield StreamEvent(
|
| 141 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 142 |
self._trace(
|
| 143 |
"critic",
|
| 144 |
name=name,
|
| 145 |
-
situation=
|
| 146 |
-
model=
|
| 147 |
input=forest.model_dump(),
|
| 148 |
output=decision.model_dump(),
|
| 149 |
)
|
| 150 |
|
| 151 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 152 |
if revision_indices:
|
| 153 |
-
feedback = {
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 164 |
)
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 194 |
-
|
| 195 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 196 |
try:
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 202 |
self._trace(
|
| 203 |
-
"
|
| 204 |
-
model="
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
succeeded=not image_error,
|
| 209 |
-
error=image_error,
|
| 210 |
)
|
| 211 |
-
|
| 212 |
-
type="
|
| 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 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 6 |
-
Return exactly one JSON object matching the supplied schema.
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 15 |
-
|
| 16 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
"
|
| 35 |
-
"
|
| 36 |
-
"
|
| 37 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
"spell": "short first-person present-tense mantra",
|
| 39 |
-
"image_prompt": "
|
| 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": ["
|
| 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
|