diff --git a/.gitea/workflows/publish.yml b/.gitea/workflows/publish.yml index 39fd447..0dca9cc 100644 --- a/.gitea/workflows/publish.yml +++ b/.gitea/workflows/publish.yml @@ -56,75 +56,12 @@ jobs: - name: Build run: SITE_NAME=${{ vars.PORTAL_SITE_NAME }} cargo leptos build --release - # The lint (uhhm/iris) is versioned as portal: this step re-pins - # iris to the tag being released, builds and tests it against it, - # and pushes the matching iris tag, which iris's own CI publishes. - # A portal change to something the lint reads fails the release - # right here. Needs IRIS_PUSH_USER (a variable) and IRIS_PUSH_TOKEN - # (a secret) that may push to uhhm/iris; the workflow's own token - # is scoped to this repo. A re-run of a tag whose iris tag already - # exists just builds that. - - name: Release iris to match - env: - # The shared cargo home is read-only for the runner, and a git - # dependency has to be cloned into one (same as uhhm/iris's CI). - CARGO_HOME: /var/local/cargo-target/iris-cargo-home - RUSTC_WRAPPER: /usr/bin/sccache - IRIS_PUSH_USER: ${{ vars.IRIS_PUSH_USER }} - IRIS_PUSH_TOKEN: ${{ secrets.IRIS_PUSH_TOKEN }} - run: | - set -euo pipefail - tag="${{ github.ref_name }}" - ver="${tag#v}" - host="${{ github.server_url }}" - remote="https://${IRIS_PUSH_USER}:${IRIS_PUSH_TOKEN}@${host#https://}/uhhm/iris.git" - # Re-pin, version, changelog: idempotent, so a retry on a fresh - # clone redoes exactly the same edits. - match() { - sed -i -E "s|^(portal = \{ git = \"[^\"]+\", tag = \")[^\"]+(\")|\1$tag\2|" Cargo.toml - sed -i -E "0,/^version = \"[^\"]+\"/s//version = \"$ver\"/" Cargo.toml - cargo update -q -p portal - awk -v h="## $ver ($(date -u +%F)) - portal $tag" -v l="- Matches portal $tag (released by portal's publish job)." \ - 'done==0 && /^## /{print h; print ""; print l; print ""; done=1} {print}' CHANGELOG.md > CHANGELOG.new - mv CHANGELOG.new CHANGELOG.md - git config user.name "portal release" - git config user.email "portal@uhhm.no" - git commit -qam "Match portal $tag" - git tag "$tag" - } - # Someone may push to iris main while this job builds; a push - # then fails as non-fast-forward, and the answer is to start - # over on a fresh clone, once. - for attempt in 1 2; do - cd "$GITHUB_WORKSPACE" - rm -rf iris-src - git clone -q "$host/uhhm/iris.git" iris-src - cd iris-src - if git rev-parse -q --verify "refs/tags/$tag" >/dev/null; then - echo "iris $tag already exists, building it" - git checkout -q "$tag" - cargo build --release - cargo test - break - fi - match - cargo build --release - cargo test - if git push -q "$remote" main "$tag"; then - echo "pushed iris $tag" - break - fi - echo "push rejected on attempt $attempt (iris main moved), retrying on a fresh clone" - [ "$attempt" = 2 ] && exit 1 - done - - name: Package run: | set -euo pipefail tag="${{ github.ref_name }}" stage=$(mktemp -d) cp "$CARGO_TARGET_DIR/release/portal" "$stage/portal" - cp "$CARGO_TARGET_DIR/release/iris" "$stage/iris" # hash-files = true: leptos resolves hash.txt next to the running # binary, so it ships beside portal in the release dir. cp "$CARGO_TARGET_DIR/release/hash.txt" "$stage/hash.txt" @@ -132,7 +69,10 @@ jobs: # not affected by CARGO_TARGET_DIR - only the plain `cargo build` # outputs (release/, front/) move with that override. cp -r target/site "$stage/site" - tar -C "$stage" -czf "portal-$tag.tar.gz" portal iris hash.txt site + tar -C "$stage" -czf "portal-$tag.tar.gz" portal hash.txt site + # The bare binary as well: a content repo's CI runs `portal lint` + # and needs nothing else from the release. + cp "$stage/portal" portal-bin rm -rf "$stage" # The run's own ephemeral token has write access to this repo - @@ -159,4 +99,7 @@ jobs: curl -sf -X POST -H "$auth" \ -F "attachment=@portal-$tag.tar.gz" \ "$api/releases/$id/assets?name=portal-$tag.tar.gz" > /dev/null + curl -sf -X POST -H "$auth" \ + -F "attachment=@portal-bin" \ + "$api/releases/$id/assets?name=portal" > /dev/null echo "published $tag" diff --git a/CHANGELOG.md b/CHANGELOG.md index d086420..80cb539 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ One line per change, grouped by the release that shipped it. Newest first. A release is a Cargo.toml bump plus a `v` tag; the content repos pin the tag they run (see the end of this file). +## Unreleased + +- One binary. The lint (uhhm/iris) and the trainer (uhhm/portal-trainer) are commands of `portal`: `portal serve` (the default), `portal lint`, and `portal plan`, `build`, `answer`, `rate`, `prefer`, `score`, `simulate`, `compare`, `report`, `generate`. Same source, same version, so the site, the lint and the trainer never disagree about what content means; both histories are merged in. The release attaches the bare `portal` binary beside the tarball, so a content repo's CI downloads it and runs `portal lint`. No more iris releases; the pins that exist keep working. The trainer's `plans/`, `intakes/`, `engines/`, `sectors/` and its reference prompt live here now; its `runs/` stay in uhhm/portal-trainer. Docs gained "Training a site" and "The lint". + ## 0.5.11 (2026-09-30) - Storylines (`src/content/variants.rs`, `docs/storylines.md`): any text a person reads may be a list, one entry per storyline, every list the same length; a visitor is given one storyline for the whole site (the `portal_storyline` cookie, a year) and reads it on every page and in every mail about their case. What the machine reads - a field's `name`, `options`, states, groups, paths - never varies. The lint refuses a list of another length. Records and answer events carry `storyline`, and `GET /report` gives counts per bucket, per storyline, per state, and nothing else. diff --git a/Cargo.toml b/Cargo.toml index 73c8add..ec4e77b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,7 +11,9 @@ leptos = { version = "0.8" } leptos_meta = { version = "0.8" } leptos_router = { version = "0.8" } serde = { version = "1", features = ["derive"] } -serde_json = "1" +# preserve_order: a rendered page is read by the people who own it, so +# the keys come out as written (name, then description), not sorted. +serde_json = { version = "1", features = ["preserve_order"] } # Inline markdown for content descriptions (links, emphasis, code) - # runs on both server and client renders, so not feature-gated. pulldown-cmark = { version = "0.13", default-features = false, features = ["html"] } diff --git a/docs/README.md b/docs/README.md index e477bbd..33cbb48 100644 --- a/docs/README.md +++ b/docs/README.md @@ -14,12 +14,15 @@ engine does with it. | [Storylines](storylines.md) | The same site told several ways: lists of texts, one storyline per visitor, counted per storyline. | | [Memberships](memberships.md) | Groups kept by portal itself: a membership as a case, a person as a phone number first, and the provider only saying who. | | [Operations](operations.md) | Releasing, deploying, adding a site, environment variables. | +| [Training a site](training.md) | `portal plan` and `portal build`: a business in plain words becomes a content repo, held to the lint. | +| [The lint](lint.md) | `portal lint`: what a content repo's CI runs before anything reaches a site. | | [Filesystem routes](design/filesystem-routes.md) | Why the `questions/` tree is the router. | -Two repos in the same family carry their own docs: +One repo in the same family carries its own docs: -- [uhhm/iris](https://project.uhhm.no/uhhm/iris) — the lint. Versioned - as portal and released by portal's publish job; it is the thing a - content repo's CI runs before anything reaches a site. - [uhhm/gdo](https://project.uhhm.no/uhhm/gdo) — the mailer. Delivers what content asked to be sent, and carries replies back. + +The lint (once uhhm/iris) and the trainer (once uhhm/portal-trainer) +are commands of the `portal` binary since 0.6; their chapters are +above, and the trainer's runs stay in the old repo. diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index 2231d5f..294f957 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -12,6 +12,11 @@ - [Memberships](memberships.md) - [Operations](operations.md) +# The trainer and the lint + +- [Training a site](training.md) +- [The lint](lint.md) + # How it is made - [Architecture](architecture.md) @@ -20,3 +25,4 @@ --- [Changelog](changelog.md) +[The lint's changelog, to 0.5.11](lint-changelog.md) diff --git a/docs/content-reference.md b/docs/content-reference.md index 8025919..05d7333 100644 --- a/docs/content-reference.md +++ b/docs/content-reference.md @@ -66,7 +66,7 @@ place: # this page is about somewhere lat: 59.8938 lon: 11.0332 zoom: 15 - name: Tomter + name: The town hall alternatives: [ ... ] ``` @@ -207,14 +207,14 @@ The other half is a listing that asks for the children: And the way there from a list: `link` puts a link on every listed record to a page about it, with `{id}` for the record's id and `{field}` for any of its fields. A notice on a public board links to -`/oppslag/{id}`, the dynamic page `/oppslag/[key]` whose form carries +`/notices/{id}`, the dynamic page `/notices/[key]` whose form carries the notice's id back in with `value: "{key}"`: ```yaml resource: - source: { kind: kv, bucket: oppslag } + source: { kind: kv, bucket: notices } public: true - link: { to: "/oppslag/{id}", label: Jeg kan låne ut eller låne denne } + link: { to: "/notices/{id}", label: I can lend or borrow this } ``` ## A resource @@ -231,7 +231,7 @@ reads is never a client-supplied parameter. where: { field: case, equals: "{key}" } jq: ".[] | {name, url: .html_url}" empty: Nothing decided yet. - link: { to: "/oppslag/{id}", label: Answer this } # a page per record + link: { to: "/notices/{id}", label: Answer this } # a page per record transitions: # the desk's buttons - { from: complete, to: on_hearing, label: Send it out } ``` diff --git a/docs/getting-started.md b/docs/getting-started.md index 0ae0aae..a0a3974 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -32,11 +32,11 @@ Three things in the content decide who needs an account: |---|---|---| | `responsible: { name, contact }` on a page | the person answering for that question | an account, and a mail saying they are listed as responsible for asking it | | `responsible: { name, contact, group }` | a group answering for it | nothing: the inbox is not a person; the group's members have the desk, and each of them may suggest changes to the page | -| `qualifies: styret` on a page or `_section.yaml` | members of that group | the pages and desks under it | -| `invite: { group: kasserer }` on a desk page | the desk's own group | the right to bring people into `kasserer` | +| `qualifies: board` on a page or `_section.yaml` | members of that group | the pages and desks under it | +| `invite: { group: treasurer }` on a desk page | the desk's own group | the right to bring people into `treasurer` | The desk whose group may invite into the most groups is the site's most -privileged one; on a vel that is the board. Portal works that out from +privileged one; for an association that is the board. Portal works that out from the content (see step 4). ## 2. Set up Kanidm for portal @@ -58,7 +58,7 @@ $K group add-members idm_people_pii_read portal-onboarding # One group per `qualifies:` in the content, managed by that account: # adding a member to a group takes the right to manage it. -for g in styret kasserer trafikk; do +for g in board treasurer traffic; do $K group create "$g" portal-onboarding done @@ -67,7 +67,7 @@ done $K system oauth2 create portal "My site" https://site.example.no $K system oauth2 add-redirect-url portal https://site.example.no/auth/callback $K system oauth2 update-scope-map portal idm_all_persons openid profile email -for g in styret kasserer trafikk; do +for g in board treasurer traffic; do $K system oauth2 update-claim-map portal groups "$g" "$g" done $K system oauth2 update-claim-map-join portal groups array @@ -94,7 +94,7 @@ and on an empty Kanidm there is no desk anyone can open yet. | `SEED_ADMIN_EMAIL` | the first person's address | | `SEED_ADMIN_NAME` | their name, as it should appear (optional) | | `SEED_GROUP` | a group to put them in, if not the most privileged one (optional) | -| `RESPONSIBLE_GROUP` | a group every responsible person joins, e.g. `redaktor` (optional) | +| `RESPONSIBLE_GROUP` | a group every responsible person joins, e.g. `editors` (optional) | ## 4. Start portal @@ -151,7 +151,7 @@ A desk that lists the bucket offers the way back with a transition from ```yaml resource: source: { kind: kv, bucket: portal_invites } - requires_group: styret + requires_group: board transitions: - { from: open, to: done, label: Is in } - { from: expired, to: open, label: Send again } @@ -172,7 +172,7 @@ list, and whoever sits there takes it up and settles it: ```yaml resource: source: { kind: kv, bucket: portal_edit_suggestions } - requires_group: styret + requires_group: board transitions: - { from: open, to: discussed, label: Looking at it } - { from: open, to: done, label: Changed } @@ -187,7 +187,7 @@ where each one stands. A content repo that declares the bucket itself in `aggregates.yaml`, with a `mail:` on a state, sends that instead. Their group can also be the way into other tools. The vel's -`redaktor` group signs in to the project Gitea with the same account: +`editors` group signs in to the project Gitea with the same account: a second OAuth2 client scoped to that group alone. ## When it does not work diff --git a/docs/lint-changelog.md b/docs/lint-changelog.md new file mode 100644 index 0000000..580bb30 --- /dev/null +++ b/docs/lint-changelog.md @@ -0,0 +1,104 @@ +# Changelog + +One line per change, grouped by the release that shipped it. Newest +first. Each release names the portal tag it is built against. + +## Unreleased + +- `needs.yaml` may name the site's `tones`, one storyline each; the checks read the first storyline, which is what `build_questions` gives. + +## 0.5.10 (2026-09-30) - portal v0.5.10 + +- Matches portal v0.5.10 (released by portal's publish job). + +## 0.5.9 (2026-09-30) - portal v0.5.9 + +- Matches portal v0.5.9 (released by portal's publish job). + +## 0.5.8 (2026-09-30) - portal v0.5.8 + +- Matches portal v0.5.8 (released by portal's publish job). + +## 0.5.7 (2026-09-30) - portal v0.5.7 + +- Matches portal v0.5.7 (released by portal's publish job). + +## Unreleased + +- The effort budget counts what a person fills in: a `type: record` field (the case an answer is about, carried on the link) and a `hidden` preset are not asked and no longer count. An objection page whose form carried the application's id used to be one field over. + +## 0.5.6 (2026-09-30) - portal v0.5.6 + +- Matches portal v0.5.6 (released by portal's publish job). + +## 0.5.5 (2026-09-30) - portal v0.5.5 + +- Matches portal v0.5.5 (released by portal's publish job). + +## 0.5.4 (2026-09-29) - portal v0.5.4 + +- Matches portal v0.5.4 (released by portal's publish job). + +## 0.5.3 (2026-09-29) - portal v0.5.3 + +- Matches portal v0.5.3 (released by portal's publish job). + +## 0.5.2 (2026-09-28) - portal v0.5.2 + +- Matches portal v0.5.2 (released by portal's publish job). + +## 0.5.1 (2026-09-28) - portal v0.5.1 + +- Matches portal v0.5.1 (released by portal's publish job). + +## 0.5.0 (2026-09-28) - portal v0.5.0 + +- Matches portal v0.5.0 (released by portal's publish job). + +## 0.4.0 (2026-09-24) - portal v0.4.0 + +- Matches portal v0.4.0 (released by portal's publish job). + +## 0.3.47 (2026-09-24) - portal v0.3.47 + +- Matches portal v0.3.47 (released by portal's publish job). + +## 0.3.46 (2026-09-23) - portal v0.3.46 + +- Matches portal v0.3.46 (released by portal's publish job). + +## 0.3.45 (2026-09-23) - portal v0.3.45 + +- Matches portal v0.3.45 (released by portal's publish job). + +## 0.3.44 (2026-09-23) - portal v0.3.44 + +- Matches portal v0.3.44 (released by portal's publish job). + +## 0.3.43 (2026-09-23) - portal v0.3.43 + +- Matches portal v0.3.43 (released by portal's publish job). + +## 0.3.42 (2026-09-23) - portal v0.3.42 + +- Matches portal v0.3.42 (released by portal's publish job). + +## 0.3.41 (2026-09-23) - portal v0.3.41 + +- Matches portal v0.3.41 (released by portal's publish job). + +## 0.3.39 (2026-09-23) - portal v0.3.39 + +- Matches portal v0.3.39 (released by portal's publish job). + +## 0.1.1 (2026-09-22) - portal v0.3.36 + +- `check --repo` fetches needs.yaml itself; the v0.1.0 tag never published (a cache permission on the runner, since fixed). + +## 0.1.0 (2026-09-22) - portal v0.3.36 + +- First release: the lint moved out of portal (`question_lint` becomes + `iris check`, `needs_replay` becomes `iris replay`), with the + business-needs pass, the wording task export and scoring, and the + simulation model export. Portal is a library dependency pinned to + the release iris matches. diff --git a/docs/lint.md b/docs/lint.md new file mode 100644 index 0000000..563a342 --- /dev/null +++ b/docs/lint.md @@ -0,0 +1,54 @@ +# The lint + +The iris over a site. A content push is an incoming wormhole; nothing +comes through until it checks out. + +``` +portal lint --path questions # a local checkout +portal lint --repo https://project.uhhm.no/uhhm/questions +portal lint replay --path questions --nats nats://127.0.0.1:14222 +``` + +It was its own binary, iris, until portal 0.6: the same code, released +as portal's twin. Now it is the `portal` binary's `lint` command, so a +content repo's CI downloads the release and runs it: + +```yaml + - run: | + curl -sfL "https://project.uhhm.no/uhhm/portal/releases/download/$PORTAL_RELEASE/portal" -o portal + chmod +x portal && ./portal lint --path questions +``` + +`check` loads a content repo exactly the way the running site does, +validates it (every action lands on a page, every transition is one +the state machine allows, every recorded bucket is read back +somewhere), and then holds it to the business needs the repo declares +in `needs.yaml`, when it has one: + +- a path of submissions gets each kind of visitor's answer into the + bucket it names, within a submission and required-field budget; +- the handling group holds a desk over that bucket, and every + finished state is reachable through the buttons it (and any + `also_moved_by` group) offers; no record strands on the way; +- every stage the business named is a state of the machine. + +A need marked `planned` warns instead of failing. Exit status is the +verdict, so a content repo's CI is one line. + +Beyond the verdict, `check` exports what a trainer needs to grade the +wording: `--needs-tasks` writes the personas as typed choice tasks +(`--skim` for headings only), `--needs-score` grades an engine's +answers, and `--needs-sim` writes a resolved model of the whole site. +`portal lint replay` runs simulated cases through the site's real aggregate +engine on a throwaway JetStream and reports every bucket by state; +`--report-only` takes the same scorecard from a live site's buckets. +See [Training a site](training.md) for what sits around those. + +## Matching the site + +The lint is the site: `portal lint` and `portal serve` are one binary +built from one source, so a content shape the site reads is a shape +the lint knows, at the same version. A content repo pins +`PORTAL_RELEASE` to what its site runs and lints with that release's +`portal`. The `IRIS_RELEASE` pins from before portal 0.6 keep working +against the iris releases that exist; nothing new is published there. diff --git a/docs/memberships.md b/docs/memberships.md index 2c6080a..7972b12 100644 --- a/docs/memberships.md +++ b/docs/memberships.md @@ -33,7 +33,7 @@ Because it is a case, a desk lists it like any other bucket: ```yaml resource: source: { kind: kv, bucket: portal_memberships } - requires_group: styret + requires_group: board transitions: - { from: invited, to: removed, label: Withdraw } - { from: active, to: removed, label: Take out } diff --git a/docs/storylines.md b/docs/storylines.md index 9e272b4..8caf152 100644 --- a/docs/storylines.md +++ b/docs/storylines.md @@ -6,13 +6,13 @@ Anywhere content says something to a person, it may say it in several ways by giving a list instead of one string: ```yaml -name: Vil du hjelpe til i nabolaget? +name: Want to help out in the neighbourhood? description: - - Dugnad, 17. mai og komiteene trenger flere hender. Si hva du kan. - - Naboene gjør det meste selv. Her melder du deg til det du har lyst på. + - The work day, the spring fair and the committees need more hands. Say what you can do. + - The neighbours do most things themselves. Sign up here for whatever you fancy. alternatives: - - name: [Bli med, Meld deg] - consequence: [[Send], [Meld meg]] + - name: [Join in, Sign me up] + consequence: [[Send], [Count me in]] ``` Each index is a *storyline*. A visitor is given one at their first @@ -49,12 +49,12 @@ warm front door is not thanked in the plain voice. ```json { "storylines": 2, - "buckets": { "hjelpebehov": { "0": { "open": 3, "ordnet": 9 }, - "1": { "open": 5, "ordnet": 14 } } } } + "buckets": { "requests": { "0": { "open": 3, "solved": 9 }, + "1": { "open": 5, "solved": 14 } } } } ``` Storylines are assigned evenly, so those numbers are the comparison: which words brought more people to send, and how their cases went. No record, field, address or time leaves the site. The trainer reads this -(`portal-trainer report`) next to what its personas scored offline, and +(`portal report`) next to what its personas scored offline, and the plan's `tones` are where the storylines are written. diff --git a/docs/training.md b/docs/training.md new file mode 100644 index 0000000..29ddd26 --- /dev/null +++ b/docs/training.md @@ -0,0 +1,490 @@ +# Training a site + +A business describes itself in plain words: what it does, who comes to +it, what it keeps track of and through which stages, and who handles +what. The trainer, `portal plan` and `portal build`, turns that into a +working content repo: a question series for every role, the state +machines behind them, and a desk per handling role. No software is +written for the business; the only thing produced is content, and +every line of it is held to the lint (`portal lint`), which is the +same code as the site, at the same version. + +The trainer was its own repo, uhhm/portal-trainer, until portal 0.6; +its runs (`runs/`, the lab notebook of every site trained so far) +stay there. + +## Build: structure from the plan, words from the model + +``` +portal build --plan plans/contractor.yaml --out out/nordvik \ + --lint "portal lint" --llm "MODEL=sonnet engines/claude.sh" +``` + +A `plan.yaml` is a needs spec that also says what each form collects +and, per need, `max_fields`: how many required fields that person +will tolerate, taken from what the business said about them, never +from the form. `build` refuses a form over its budget, and hands the +budget to the verifier, so the check still means something after +handover. Everything structural is derived from the plan with no +model involved: the state machines (read off `stages`: walk the line, +be turned down at any point, succeed only from the end, pass over +`skippable` stages), the front door, a followup page per need, a gated +directory per group with its desk and the pages only that group may +open, and every button the machine allows. The verifier accepts the +result by construction; what it still checks is the budget. + +The model writes words only, one page at a time, into a JSON object +whose shape is fixed. Its strings are merged over built-in fallbacks +key by key, so a bad reply costs wording, never a working site. `--llm-for ` points the model at some pages only +(`aggregates.yaml` for the mails alone); a reply in the wrong language +for the plan is refused, and a mail that lost the submitter's link +gets it back. + +### Onboarding from the plan + +`invites_by: [board]` on a plan, or `invited_by: [...]` on a group, +puts an invite form on the inviting group's desk for each group it may +fill, and a list of the invites sent. Portal does the rest (an account +in Kanidm, the group, a one-time credential link) through `invite:` on +the alternative. Every group with a desk invites into itself by +default. + +### Rebuilding a live site + +`--words-from ` lifts the words a repo already has back into the +words map by what each card is (its bucket, the page it leads to, the +group it invites into), across the whole repo, so a rebuild keeps +hand-edited wording even when a card moves page (a door taken away, a +need brought onto the front door) and +adds only the new slots. Cards are matched by what they are (the +bucket they record into, the page they lead to, a field's name, the +group they invite into), not by position, so a rebuild that reorders +or reshapes a page still hands each card its own words; on uhhm.no +the live front door's three headings follow their buckets onto the +new page. `--words +` overrides slots by page and may repeat, each file layered over +the last (the words a build used, then a round of hand changes); +`words.json` in the output is the words as used. See +`runs/tomtervel-invites-2026-09-23` and `runs/uhhm-2026-09-23`. + +| Same contractor, same local model (nemotron 30B-A3B on ergo) | Free-form `generate` | `build` | +|---|---|---| +| A site the verifier accepts | never, in 6 rounds and 16 min | first try, 130 s | +| Held-out personas, with descriptions | - | 8 of 8 | +| Held-out personas, headings only | - | 7 of 8 | + +The wording numbers are from the re-score of 2026-09-22, one task per +call (`runs/contractor-build-2026-09-21/rescore-2026-09-22/`). The +first score, all tasks in one prompt, said 8 of 8 on headings only; +the apprentice picking the building survey over "Join our carpentry +team" only showed once no other visitor was in the prompt. + +For comparison, a Sonnet-class model writing everything free-form +scored 8 of 10 on headings only and silently dropped a stage the owner +had described. `build` cannot drop a stage: it never decides one. The +denominators differ: the `build` site gives the two crew personas a +one-option page, so they generate no task, and 8 of 8 is eight public +personas where the `generate` number is ten. Held out means held out +from the model: the plan's `who` and `wants` lines and the personas +were written by the same person about the same needs, and the fallback +heading is the need's `wants`, so the two are cousins. + +### The front door opens modestly + +A front door with more buttons than text leaves a visitor paralysed. +So the built front door opens with what the business does, in one or +two features and no button at all (`does:` on the plan, at most two, +each `for:` a need so it speaks to one role and is headed by what +that role gets); then a look-around card, things a visitor may +inspect before asking for anything (`shows:`, each a portal resource +block passed through verbatim, plus every public view the plan +opens); and only then the offers, as more alternatives on the same +page. Nothing is layered: doors still exist for a plan that wants +them, but a second tier is what this shape is there to avoid, and on +uhhm.no a flat front door with an opener routed better than the +doored one. + +The house and its players: a role the opener invites in has to see +its win, and the machine has to give it something to do after it +sits down. `build` warns when a featured need gives the submitter +neither a move (`moves_by: {: [submitter, ...]}`) nor a public +view; a player with no move is a spectator. +### Doors: a first question before the forms + +Five or six needs on one page is more than a visitor sorts through +(the full contractor plan had six). A plan can put public needs +behind `doors`: each door is one alternative on the front door +leading to a page of its own, where the needs that name it sit. + +```yaml +doors: + - id: patients + who: someone with pain or an injury, or a patient who already has a time here +needs: + - id: new-patient + door: patients + ... +``` + +The door's page is a nav page, public, holding the forms. The spec +gives such a need `max_steps: 2` and pins `via` to the door's heading, +so the verifier walks both pages. A door nobody is behind, a door on +an internal need, or a door named like a group is refused, and a +front door over five alternatives is warned about. The visitor is +scored on both pages: a persona who picks the wrong door is a +misroute on `/`, one who picks the wrong form is a misroute on the +door's page. + +Cases with doors: `plans/physio.yaml` (a physiotherapy clinic, +patients and referrers), `plans/fotballklubb.yaml` (a football club, +in Norwegian, parents and helpers), `plans/hire.yaml` (a machine hire +yard, hiring and firms), and `plans/contractor-full.yaml` (three +doors over six public needs). Each has an intake under `intakes/` in +the business's own words and held-out personas. + +### What a plan can say about a form and its readers + +- `multiple: true` on a `select`: pick any number (portal renders + toggles). What a volunteer can do, which events, which committees. +- `groups:` at the top, a list of `{ id, label }`: the groups with a + name the public knows, such as a vel's committees. A select with + `options_from: groups` offers exactly those labels, so the + application form and the desks cannot drift apart. +- `watched_by: [public]`: an ungated read-only page of the bucket at + `/status/`, linked from the followup page. Everyone sees + what was sent in and where it stands. `email` and `tel` fields, and + any field marked `private: true`, are projected away before it is + shown. +- `events: { open: applied, active: onboarded }` on a need: the event + name a stage is entered by, where the builder's default (the stage's + own name, `received` for the first) is not what a live site already + has. Portal replays every record by event name, so a rebuild of a + site that holds records keeps the names its aggregates.yaml has, or + every record vanishes from its desk. `plans/uhhm.yaml` rebuilds + uhhm.no this way. +- `mails: { : submitter |
}` on a need: who hears + when a case enters that stage, sent by portal the moment it moves + (portal 0.3.43, delivered by uhhm/gdo on the host). `submitter` goes + to the email the form collected, so the form must collect a required + `email`; an address is the treasurer, the board. The plan then needs + `mail: { from, reply_to }` at the top, the sender every mail carries + into `site.yaml`. The words model writes each mail's subject and + body (`aggregates.yaml` in the words map); the fallback says what + happened and, where the submitter has a move from that stage, the + link to make it. +- `stages_text: { : }` on a need: each stage in the + plan's language, where the ASCII id will not do (`ikke_lost` reads + as English). Used by the fallback mails, the "what happens next" + line, the desk buttons' fallback labels, and written into needs.yaml + as `stage_words` so the desk score describes an outcome in the + site's own words. With them, the vel's desks score 26 of 26. +- `turned_down_from: [, ...]` on a need: the unfinished stages + a case can still be turned down from; omitted, every one. An + applicant is declined before they start and departs after, which + is what uhhm.no's live machine says and what two near-synonymous + desk buttons had been standing in for. +- `reached_from: { : [, ...] }` on a need: where a stage + is entered from, when the line a case walks does not say it. Two + shapes need it and nothing else does - a case SENT BACK to a stage it + was already past (a building application returned for the drawing it + was missing, after the hearing turned something up), and a GOOD ENDING + PARTWAY ALONG (an authority that changes its own decision while it is + looking at the appeal, rather than passing it up to the board). A + stage named there is entered from exactly those stages and nowhere + else, so saying where a refusal may come from also says where it may + not: an applicant may withdraw from anywhere, and be refused only once + the papers are in, which `turned_down_from` could not express because + it was one list for every bad ending at once. Found by replaying a + building authority's real cases through portal's engine, which + refused three legal paths and accepted two illegal ones until the + plan could say this; see runs/permits-2026-09-25/README.md. +- `attended_by:` on a need: who moves cases that no desk button moves, + an automation listening on the bucket's events, written into + aggregates.yaml as portal's own annotation for the reader. + +## Plan: the intake, read by a model, checked by a person + +``` +portal plan --intake intakes/contractor.yaml --out out/nordvik \ + --personas sectors/construction/needs.yaml \ + --llm "MODEL=nemotron-3.5-lightning:30b-a3b engines/ollama.sh" +``` + +This is the one step where a model decides structure: which needs the +intake implies, the stages, who handles what, the form, the budget. +Its YAML is parsed and validated exactly as `build` will, the errors go +back to it, and what passes is written verbatim for a person to read +before building from it. Personas are never asked of this model; they +are attached from a file, and where their `need:` names an id the plan +does not have, `plan` says so and leaves the mapping to the reader. + +`generate` (below) is the older path, a model writing pages free-form +against the lint. It is kept for the record of what it measured. + +## How it works + +``` + intake (plain words) + │ + ▼ phase 1 frozen after this phase: the generator + needs.yaml ─────────────────────── may not weaken the spec to pass + │ + ▼ phase 2 ◄── iris findings, until it exits 0 + aggregates.yaml + questions/** + desks + │ + ▼ phase 3 ◄── misroutes from a blind visitor model + wording +``` + +1. **Needs.** The intake becomes `needs.yaml`: one need per kind of + person and thing they want, including the roles inside the + business. Each names the bucket the answer lands in, the group that + handles it, the states that count as finished, and the effort the + visitor will tolerate. +2. **Structure.** Pages, state machines and desks are generated, then + revised against the lint, `iris check`, until every need is met: + a path within budget, a desk held by the right group, every + finished state reachable, no record stranding. The page-writing + model sees the needs and never the personas. +3. **Wording.** A separate model plays each persona, blind, first with + headings only and then with descriptions. A misroute is a wording + defect and goes back to the generator. A rewrite that breaks + structure is reverted. + +Model output is only ever written to `aggregates.yaml`, `site.yaml` +and `questions/**.yaml` inside the output directory. + +``` +portal generate --intake intakes/contractor.yaml --out out/nordvik \ + --lint "portal lint" --llm engines/ollama.sh +portal answer --tasks tasks.jsonl --llm engines/ollama.sh > answers.jsonl +``` + +`answer` puts one task per call to the visitor model, with the options +shuffled per task and the call seeded from the task id: no visitor +sees another visitor's situation, a heading's place on the page cannot +carry the answer, and a rerun on the same site is the same experiment. +The earlier runs under `runs/` answered all tasks in one prompt. + +The verifier is uhhm/iris, not code in this repo: it +reads portal's own content types and ships in every portal release, so +a generated repo's CI keeps enforcing its needs after handover. + +## LLM backends + +A backend is any command that takes a prompt on stdin and prints text. + +| Backend | Use | +|---|---| +| `engines/ollama.sh` | Local Ollama. `MODEL=qwen3:8b` answers a task in about two seconds on ergo's GPU and is the default visitor. Generation wants a larger model (`MODEL=qwen3.6:27b`), which runs mostly on CPU there. | +| `engines/claude.sh` | Claude through Claude Code's own login (a Max or Pro subscription), no API key. `MODEL=sonnet` or `opus`, `EFFORT=low` by default. It drops any `ANTHROPIC_API_KEY` from the environment, because a key outranks the login and a stale one fails every call. Log in once with `claude auth login`. | +| `engines/anthropic.sh` | The Anthropic API, given a working `ANTHROPIC_API_KEY` from the Console. That is separate, prepaid billing; a subscription does not cover it. | +| `engines/mailbox.sh` | Writes each prompt to a directory and waits for a reply file: drive the generator from an agent session, or by hand. | + +The visitor and the generator should be different models. A model +grading its own wording agrees with itself. + +## The onboarding site + +`portal-portal/` is the front end of this: a portal content repo whose +front door is the intake interview. A submission lands in +`onboardings` (`open → drafted ⇄ revising → live | declined`) and is +worked from an owners' desk. Today the desk step is a person running +`generate` and sending the link; the intake record already carries +every field the generator reads, so the step that remains is a +listener on `portal.answers.submitted` that runs it and pushes the +draft to a new repo. It passes its own `needs.yaml`. + +## The whole card, not the heading + +A visitor never sees a bare heading and description. Portal renders a +card: a button, an encouragement, one or more sections each with an +icon, a title and a line of guidance, and the fields with their +labels and placeholders. Since 2026-09-23 the builder fills all of it +and the scorers read all of it. + +- **Slots.** Beside the heading and description, each form alternative + gets a button, an encouragement, an icon, a form title and guidance + line, and a placeholder per field. A door gets an icon and a "for + whom" line. Icons are picked from a fixed list of Iconify lucide + names; anything else is dropped. +- **Context from the plan.** Every form alternative also carries two + sections built from what the plan already knows, before the form: + what happens next (the stages, in order, as one sentence) and who + reads it (the handling group and the contact). Their fallbacks are + true before a model has written a word; the model rewords them. +- **Scoring.** `answer --repo ` shows the visitor each + option as its whole card (headings-only tasks stay headings only). + `rate --repo` does the same and asks a fourth question, "where": do + you know where you are, who this is for, and what the button does. + All four are controlled against the gutted twin. + +## Scoring a built site + +``` +portal score --site out/nordvik --out out/scored \ + --iris /var/local/cargo-target/release/iris \ + --llm "MODEL=qwen3:8b engines/ollama.sh" +``` + +One command for the whole grading, which every run under `runs/` +before 2026-09-23 carried as its own `score.sh`: iris writes the +visitor tasks and the simulation model, the blind visitor answers +them with descriptions and with headings only, iris scores the +answers (the misroutes are printed), and `rate` reads the cards. The +two routing numbers land in `summary.json`, the rating in +`rated/rate.json`. The pieces still run alone: `answer --tasks` for +one visitor model against tasks iris wrote, and: + +``` +iris check --path out/nordvik/questions --needs-sim model.json +portal rate --model model.json --out out/rated --repo out/nordvik \ + --llm "MODEL=qwen3:8b engines/ollama.sh" +``` + +Four yes-or-no questions per persona (which option is mine, where am +I and what does the button do, what will I be asked, what happens +after I send it), each also put to a gutted +twin of the page with headings, descriptions, the form and the +followup removed. A question the twin still gets a yes on is not +measuring the page, and `rate` says which. The followup page is read +from the repo (`--repo`); without it "what happens next" is reported +as not measured rather than as zero. LIX is over the prose only +(question, descriptions, followup): headings and labels are not +sentences and would pull every page down to "very easy". The +`rate.json` in `runs/contractor-build-2026-09-21/` predates this and +its `asked` and `next` columns are the old, pinned ones. + +`rate --repo` also scores the **desks**, the inside of the business, +which reads button labels and nothing else: for every desk and bucket +with two or more buttons, a worker is told what has just happened to +a case (in the state's own words, never the label's) and picks the +button among the ones a row in that state shows, in a rotated order. +Scored per desk as right of total, with what each miss was mistaken +for. Measured on 2026-09-24 on the live vel: 24 of 26, the misses +one button each on the neighbour-help desk and the notice board. Two +limits: the outcome is put from the state id, and a Norwegian id with +its accents stripped (`ikke_lost`) reads as English even to a strong +model; and a first version that offered every button of the bucket, +not the row's, scored the same desks 17 of 26 and sent a rewrite +chasing an instrument fault. A desk that scores full marks has labels +a stranger to the business could press right. +`prefer` has a positive control on record +(`runs/tomtervel-opener-2026-09-23`, "The friendliness judge"): the +live vel front door against a bureaucratic twin, friendlier 9 of 10 +to the live page and 0 to the twin. A pair that comes back "no +preference" throughout is alike in tone, not an instrument that +cannot see; and the rejected door rewrite that was judged clearer on +5 of 10 needs routed worse across three seeds, so clarity as judged +and routing as measured are different things, and routing decides. +## Measured so far + +**Generated: a building contractor** (`runs/contractor-2026-09-21/`), +from `intakes/contractor.yaml`, a Sonnet-class model generating. + +- Phase 1 derived six needs from the owner's prose, including one + nobody asked for: a project manager logging a phone enquiry, taken + from "enquiries arrive by phone, email and text and get lost". +- Phase 2 was green on the first round: 14 files, four state machines + (`enquiries: open → quoted → won | lost`, `subcontractors` with an + `awaiting_papers` loop, `applicants`, `site_issues`), two desks. +- Phase 3, its own personas: 10 of 10 in both modes. That number is + flattering, because in that run the pages were written with the + personas in view. Against ten **held-out** personas, with qwen3:8b + as the visitor: + +| Visitor could read | Routed right | +|---|---| +| Headings and descriptions | 10 of 10 | +| Headings only | 8 of 10 | + + A scaffolding firm asking "how do I get to do jobs for you?" chose + "Ask about working with us" (the hiring door). An attic conversion + chose "Get someone to look at a building". The generator has since + been changed to hide personas from the page-writing phase. + +**Built: the same contractor from a plan** (`runs/contractor-build-2026-09-21/`), +nemotron writing words only, and re-scored one task per call the day +after (`rescore-2026-09-22/` in it): 8 of 8 with descriptions, 7 of 8 +on headings alone, the miss being an apprentice bricklayer reading +"Join our carpentry team" as not for them. `rate` on the same site, +with the followup shown and every question controlled, passes its +controls and puts the pages at LIX 18 to 35. + +**Built behind doors, 2026-09-22.** Five plans with a first question +before the forms, each built and then scored by qwen3:8b one task per +call, against held-out personas. Behind a door a persona is asked +twice, on the front door and on the door's page. + +| Case | Words by | With descriptions | Headings only | Notes | +|---|---|---|---|---| +| Machine hire (`hire`) | nemotron | 10 of 10 | 10 of 10 | doors that ask a question the visitor already knows the answer to | +| Physio clinic (`physio`) | nemotron | 8 of 10 | 9 of 10 | "Book your first appointment" fails a daughter booking for her mother | +| Football club, Norwegian (`fotballklubb`) | nemotron | 14 of 16 | 11 of 16 | three parents walk past "Er det på tide å melde inn?" | +| Football club, Norwegian | Claude Sonnet | 13 of 16 | 12 of 16 | better sentences, same driver/sponsor blur | +| Whole contractor (`contractor-full`) | nemotron | 18 of 22 | 15 of 22 | "Apply to work with us" and "Find a job with us" cross over for all four | +| Tomter Vel, Norwegian (`tomtervel`), first plan | Claude Sonnet | 25 of 30 | 22 of 30 | "utstyr" for a swing, hearing input behind the wrong door | +| Tomter Vel, plan revised 2026-09-23, whole card | Claude Sonnet | 31 of 38 | 30 of 38 | committees as groups, public lists, hearing input on the front door | + +Each run's README names the misroutes and what in the plan or the +heading caused them. A stronger word model did not route better on +the one plan built with both: routing is decided by which distinctions +sit on one page, and the visitor grades that the same whoever wrote +the words. + +**Planned: the contractor intake read by a local model** +(`runs/contractor-plan-2026-09-22/`), nemotron, one round, 41 s. The +plan passed every check `build` makes and a reader finds three +structural misses in it the checks cannot: the crew's report made +public, the "papers missing" stage dropped, and no need for the +project manager logging a call. The person reading the plan is not +optional. + +**Existing site: uhhm.no** (`runs/uhhm-2026-09-21/`), eight tasks. + +| Visitor model | With descriptions | Headings only | +|---|---|---| +| small LLM, blind | 7 of 8 | 5 of 8 | +| qwen3:8b (Ollama) | 7 of 8 | 7 of 8 | +| granite4.1:8b (Ollama) | 7 of 8 | 6 of 8 | + +All three send a paying client through **"Work happens through +dialogue, not tickets"**, the collaborator door. Different models, +same defect: that is what a wording problem looks like. + +**The noise floor, 2026-09-23** (`runs/spread.sh`, `answer --seed`). +The same tasks answered by qwen3:8b with four seeds (0 is the run +every score above used): + +| Run | With descriptions | Headings only | +|---|---|---| +| uhhm.no round 6, 15 tasks | 11, 10, 10, 10 | 12, 12, 7, 7 | +| Tomter Vel with the opener, 38 tasks | 30, 29, 29, 31 | 26, 24, 26, 23 | + +With descriptions the visitor is steady: one task in fifteen, two in +thirty-eight. On headings alone it is not: five of fifteen on uhhm.no +between seeds, three of thirty-eight on the vel. A headings-only +difference smaller than that is the visitor's mood. Compare +descriptions-mode scores; read headings-only as a sanity check, and +run several seeds before believing it. +Small numbers, one run each. Leads, not measurements. + +## What else is here + +- `skill/SKILL.md` - the same procedure for an agent or a person + working by hand. +- `sectors/` - needs specs to start from. `construction/` doubles as + the held-out persona set above. +- `generator/reference.md` - everything the generating model is told + about portal content. When the lint rejects something the reference + did not warn about, the fix belongs there. + +## Toward a trained model + +Nothing here trains weights yet. What would make it possible is +accumulating: every run leaves the intake, the needs, the pages at +each revision and the scores. With a fast local visitor as the scorer, +generating many variants per need and keeping the winners is cheap, +and those winners are the fine-tuning set for a small generator. The +verifier has to be trusted before any of that: a generator trained +against a judge that is often wrong learns the judge. diff --git a/engines/anthropic.sh b/engines/anthropic.sh new file mode 100755 index 0000000..76b86ea --- /dev/null +++ b/engines/anthropic.sh @@ -0,0 +1,11 @@ +#!/bin/sh +# LLM backend for portal-trainer: prompt on stdin, text on stdout. +# Needs ANTHROPIC_API_KEY, curl and jq. MODEL and MAX_TOKENS override. +set -eu +: "${ANTHROPIC_API_KEY:?set ANTHROPIC_API_KEY}" +jq -Rs --arg model "${MODEL:-claude-sonnet-5}" --argjson max "${MAX_TOKENS:-16000}" \ + '{model: $model, max_tokens: $max, messages: [{role: "user", content: .}]}' | +curl -sS --fail-with-body --max-time "${TIMEOUT:-600}" https://api.anthropic.com/v1/messages \ + -H "x-api-key: $ANTHROPIC_API_KEY" -H "anthropic-version: 2023-06-01" \ + -H "content-type: application/json" --data-binary @- | +jq -er '[.content[] | select(.type == "text") | .text] | join("")' diff --git a/engines/claude.sh b/engines/claude.sh new file mode 100755 index 0000000..d053a70 --- /dev/null +++ b/engines/claude.sh @@ -0,0 +1,22 @@ +#!/bin/sh +# LLM backend for portal-trainer on Claude Code's own login (a Max or +# Pro subscription): prompt on stdin, text on stdout. No API key +# needed: an ANTHROPIC_API_KEY in the environment would outrank the +# login, so it is dropped here. Log the CLI in once with +# `claude auth login`; `claude auth status` shows what it uses. +# MODEL is an alias or id (sonnet, opus, claude-sonnet-5); EFFORT is +# low, medium or high. Not --bare: that mode skips the stored login. +# +# The generator's SEED and TEMPERATURE hints cannot be honoured; +# FORMAT=json is turned into an instruction. +set -eu +unset ANTHROPIC_API_KEY ANTHROPIC_AUTH_TOKEN CLAUDECODE CLAUDE_CODE_ENTRYPOINT +prompt=$(cat) +if [ "${FORMAT:-}" = json ]; then + prompt="$prompt + +Reply with the JSON object only, no prose, no code fences." +fi +printf '%s' "$prompt" | claude -p --model "${MODEL:-sonnet}" --effort "${EFFORT:-low}" \ + --output-format text --permission-mode dontAsk --no-session-persistence \ + --tools "" diff --git a/engines/mailbox.sh b/engines/mailbox.sh new file mode 100755 index 0000000..d8277e4 --- /dev/null +++ b/engines/mailbox.sh @@ -0,0 +1,12 @@ +#!/bin/sh +# LLM backend that hands each prompt to whoever watches $MAILBOX: +# prompt-N.md goes out, the reply is read from reply-N.md. For driving +# the generator from an agent session, or by hand. +set -eu +: "${MAILBOX:?set MAILBOX to a directory}" +mkdir -p "$MAILBOX" +n=$(( $(ls "$MAILBOX" | grep -c '^prompt-' || true) + 1 )) +cat > "$MAILBOX/prompt-$n.md" +until [ -s "$MAILBOX/reply-$n.md" ]; do sleep 2; done +sleep 1 +cat "$MAILBOX/reply-$n.md" diff --git a/engines/ollama.sh b/engines/ollama.sh new file mode 100755 index 0000000..75bc0de --- /dev/null +++ b/engines/ollama.sh @@ -0,0 +1,18 @@ +#!/bin/sh +# LLM backend for portal-trainer on a local Ollama: prompt on stdin, +# text on stdout. MODEL, OLLAMA_HOST, NUM_CTX and TIMEOUT override. +# Thinking is off: the generator wants files, the answerer wants JSONL. +# The simulator sets SEED (so the same case replays the same way +# against two versions of a site), FORMAT=json and TEMPERATURE. +set -eu +jq -Rs --arg model "${MODEL:-qwen3:8b}" --argjson ctx "${NUM_CTX:-32768}" \ + --argjson temp "${TEMPERATURE:-0.2}" --arg seed "${SEED:-}" --arg format "${FORMAT:-}" \ + '{model: $model, stream: false, think: false, + options: ({num_ctx: $ctx, temperature: $temp} + + (if $seed == "" then {} else {seed: ($seed | tonumber)} end)), + messages: [{role: "user", content: .}]} + + (if $format == "json" then {format: "json"} else {} end)' | +curl -sS --fail-with-body --max-time "${TIMEOUT:-1800}" \ + "${OLLAMA_HOST:-http://127.0.0.1:11434}/api/chat" \ + -H "content-type: application/json" --data-binary @- | +jq -er '.message.content' diff --git a/intakes/building-permits-personas.yaml b/intakes/building-permits-personas.yaml new file mode 100644 index 0000000..c2425d5 --- /dev/null +++ b/intakes/building-permits-personas.yaml @@ -0,0 +1,28 @@ +# Held out from every model that writes words. Each is one real person +# arriving with one real thing, in their own register: a homeowner who +# has never read a regulation, a firm that files for a living, an +# inspector on a roof in the wind, a politician reading a bundle. +personas: + - { id: garage-owner, need: pre-application-meeting, text: "We want to put a double garage where the old shed is. Before I pay an architect I'd like to know whether you'll even allow it, because the neighbour says it's too close to the road." } + - { id: shop-to-flats, need: pre-application-meeting, text: "I own the old grocery on Torget and want to make four flats out of it. There are a lot of questions - parking, fire, the facade is listed. Can we sit down with someone?" } + - { id: firm-files-extension, need: permit-application, text: "We are the responsible applicant for an extension at Bjorkeveien 14. Drawings, site plan and the neighbour notices are all ready. We file maybe forty of these a year." } + - { id: owner-small-window, need: permit-application, text: "I want to put a dormer window in my roof. The man at the counter said I have to apply even though it's small." } + - { id: missing-drawings, need: permit-application, text: "You wrote that our section drawing is missing and the case is on hold. I've got it here now, where do I send it so the clock starts again?" } + - { id: too-close-to-boundary, need: exemption-request, text: "The garage ends up one and a half metres from the boundary and the plan says four. We are asking you to allow it anyway - the ground falls away steeply everywhere else on the plot." } + - { id: neighbour-view, need: objection, text: "I got a notice about the extension at number 14. It takes the afternoon sun off my entire terrace and I want that on the record before you decide anything." } + - { id: neighbour-parking, need: objection, text: "The flats they want to build in the old grocery have four parking places for four flats and everyone here already parks on the street. I object." } + - { id: road-authority, need: hearing-answer, text: "Regarding your case 2026/418: the access onto the county road is within the sight line and we cannot accept it as drawn. Our formal reply is attached." } + - { id: fire-service, need: hearing-answer, text: "We have looked at the conversion on Torget. We have no objection provided the stairwell is upgraded. Reference your case 2026/501." } + - { id: refused-applicant, need: appeal, text: "You refused our exemption on the ninth. We don't accept the reasoning - you've allowed the same thing twice in the same street. We're appealing." } + - { id: neighbour-appeals, need: appeal, text: "You approved the extension next door even though I objected. I'm appealing the decision. What do I need to send?" } + - { id: board-member, need: appeal, text: "I sit on the appeals board. I need the case, our own decision, and what the neighbours wrote, before Tuesday." } + - { id: inspector-on-roof, need: inspection-finding, text: "I'm up on the scaffold at Bjorkeveien. The fire wall isn't built to the drawing at all. I've got gloves on and it's blowing. I want this logged before I climb down." } + - { id: inspector-all-good, need: inspection-finding, text: "Been out at Torget. Everything is as approved. Nothing to report but I have to record that I was there." } + - { id: firm-wants-certificate, need: completion-request, text: "The work at Bjorkeveien 14 is finished and inspected. We're asking for the completion certificate so the owners can get their mortgage released." } + - { id: wants-to-move-in, need: completion-request, text: "The house is done apart from the outside steps, which we can't pour until spring. The family has sold their flat. Can we get a temporary permission to move in?" } + - { id: something-behind-fence, need: illegal-building-report, text: "There's a large structure going up behind the fence at Solbakken 7. Nobody has had a notice about it and it's been going on for two weeks." } + - { id: owner-explains, need: illegal-building-report, text: "I got your letter about the terrace. I genuinely did not know it needed an application, it's been there since we moved in. What happens now?" } + - { id: new-firm, need: firm-approval, text: "We are a three-person architecture firm that has just started. We want to be approved so we can file applications for our clients in your municipality." } + - { id: firm-insurance-lapsed, need: firm-approval, text: "You wrote that our liability insurance certificate has expired. We have renewed it, here it is." } + - { id: counter-phone-call, need: log-counter-enquiry, text: "A woman rang about the case behind her house. Wants to know where it stands. I'm at the counter and there's a queue - I need to write it down and send it to whoever has that case." } + - { id: where-is-my-case, need: case-status-enquiry, text: "I sent in my application five weeks ago and I've heard nothing. Is anyone looking at it? Is it in the six weeks with the neighbours or is it waiting on me?" } diff --git a/intakes/building-permits.yaml b/intakes/building-permits.yaml new file mode 100644 index 0000000..9de3825 --- /dev/null +++ b/intakes/building-permits.yaml @@ -0,0 +1,91 @@ +# A building authority's intake: the hardest sector the trainer has +# been given. Cases that pause and resume, cases that belong to other +# cases, a decision two roles sign, a deadline nobody in the building +# controls, and an appeal that reopens something already finished. +# Written the way the department head would write it, in plain words. +business: Vestmark kommune - Building and Planning +contact: { name: Solveig Hauge, email: byggesak@vestmark.example } +language: en + +what_we_do: > + We are the building authority for a municipality of twenty-one + thousand people. Anyone who wants to build, demolish, divide a + property or change what a building is used for has to come to us + first. We check the drawings against the zoning map and the building + regulations, we let the neighbours and the other authorities have + their say, and then we approve or refuse. We also decide exemptions + from the zoning rules, we inspect what is actually built, we sign off + finished work, and we deal with building that was never applied for. + +who_comes_to_us: > + Homeowners who want a garage, an extension or a new window, and who + have never read a regulation in their life. Architects and + engineering firms who file on behalf of owners; they have to be + approved by us before they may file anything. Neighbours who got a + notice in the post and do not agree with what is planned next door. + The other authorities we are obliged to ask: the road authority, the + fire service, the heritage office, the county. People who ring to say + something is going up behind their fence without permission. And + anyone who wants to sit down with us before they spend eighty + thousand on drawings. + +what_we_keep_track_of: > + Pre-application meetings: someone asks for one, we find a time, we + meet, and we write down what we told them so they can hold us to it. + Applications: they arrive, someone checks whether the papers are all + there, and if something is missing we write and ask for it, and the + twelve-week clock stops until they answer. Often we have to ask + twice. When it is complete we send it out to the neighbours and to + the agencies and wait six weeks. Then a case officer writes a + recommendation, and the head of section signs the decision: approved, + approved with conditions, or refused. People can withdraw at any + point, and quite a few do once they hear from the neighbours. + Exemptions: if what someone wants breaks the zoning rules they can + ask for an exemption. It rides along with the application but it is a + decision of its own, and we are not allowed to grant one before the + county has been asked. + Objections: a neighbour who got a notice writes to us within the + hearing period. Each objection belongs to an application. We register + it, the applicant gets to answer it, and the case officer has to + address every single one in the decision, by name. + Hearing answers: we ask an agency, they answer, or they do not, and + when the deadline has passed we go on without them. + Appeals: for three weeks after a decision anyone with an interest can + appeal it. We go through it ourselves first, and if we change our own + mind that is the end of it. If we do not, it goes to the appeals + board, who meet once a month and either uphold us or overturn us. + Inspections: we pick cases and go and look, on site. Either it is as + approved, or we write up what is wrong and set a date for it to be + put right, and then we go back. + Finishing: when the work is done the firm asks for a completion + certificate. If they want to move in before everything is done they + can ask for a temporary permission instead. We check what was + inspected and what is still outstanding before we sign anything. + Illegal building: someone reports it, we go and look, we write to the + owner and they have to explain themselves, and then they either apply + for it properly, take it down, or we fine them weekly until they do. + Approved firms: a firm sends us their registration, their insurance + and what they are qualified for, we check it, and then they are on + the list and may file applications. + +who_is_involved: > + The front desk logs what comes in over the counter and on the phone, + and keeps the archive. Four case officers carry the cases. The head + of section signs every decision and every exemption; a case officer + may not sign their own. Two inspectors are out on site most of the + week and write up what they find on a phone in a cold wind. The + appeals board is seven elected politicians who meet on the third + Tuesday of the month and read what we send them. The agencies are + outside the building altogether and only ever answer hearings. The + approved firms file applications and answer objections on behalf of + their clients. + +what_slows_us_down: > + The twelve-week clock, and the fact that stopping it and starting it + again is done by hand in a spreadsheet. Applications that arrive + missing half the drawings. Objections that come in on paper and get + stapled to the wrong case. Neighbours ringing to ask what is + happening with the case behind their house, which we are obliged to + tell them and which takes ten minutes every time. Agencies that never + answer at all. And nobody outside this building can see a case + without asking us for it. diff --git a/intakes/contractor.yaml b/intakes/contractor.yaml new file mode 100644 index 0000000..405900b --- /dev/null +++ b/intakes/contractor.yaml @@ -0,0 +1,44 @@ +# An intake as the owner of a small building contractor would fill it +# in: plain words, no portal vocabulary. These are the fields the +# onboarding site asks for. +business: Nordvik Bygg +contact: { name: Ingrid Nordvik, email: ingrid@nordvikbygg.example } +language: en + +what_we_do: > + We are twelve people. We survey old buildings, draw up rehabilitation + plans, handle the permit application, and do the building work with + our own carpenters plus subcontractors for electrical, plumbing and + scaffolding. + +who_comes_to_us: > + Housing boards and private owners who have a problem with a building + and want someone to look at it. Owners who already know what they + want built and need drawings, a permit and a price. Trade firms who + want to be on our list of subcontractors. Carpenters and apprentices + asking if we are hiring. + +what_we_keep_track_of: > + Enquiries: they come in, we go and look at the building, we send a + quote, and then we either win it or lose it. Sometimes it is simply + not for us and we say so straight away. + Subcontractors: they apply, we check their company registration, + trade approval and insurance. Often papers are missing and we have to + ask again. Then they are approved or turned down. + Job applications: we read them, invite some to an interview, and hire + or decline. + Things that go wrong on site: a hazard, a near miss, wrong materials + delivered. Someone reports it, the safety officer decides what is + done about it, and closes it when it is fixed. + +who_is_involved: > + Two project managers handle enquiries, subcontractors and hiring. + One safety officer handles everything reported from site. + The crew, ours and the subcontractors' people, are the ones on site + who report things. They are on their phones, with gloves on, and + will not fill in a long form. + +what_slows_us_down: > + Enquiries arrive by phone, email and text and get lost. We never know + which subcontractors have valid papers. Site problems get mentioned + at lunch and forgotten. diff --git a/intakes/fotballklubb.yaml b/intakes/fotballklubb.yaml new file mode 100644 index 0000000..0130a04 --- /dev/null +++ b/intakes/fotballklubb.yaml @@ -0,0 +1,38 @@ +# Slik lederen i en fotballgruppe ville fylt det ut. +business: Lia IL Fotball +contact: { name: Kari Lien, email: kari@liail.example } +language: no + +what_we_do: > + Fotballgruppa i et idrettslag på bygda. Rundt hundre og tjue unger + fra seks til sekstenår, fordelt på lag etter årskull. Alt drives av + foreldre på fritida: trenere, lagledere, en kasserer og et styre. + +who_comes_to_us: > + Foreldre som vil ha ungen med på et lag. Foreldre som sliter med + treningsavgiften og vil dele den opp eller slippe. Folk som vil + hjelpe til, som trener, lagleder, sjåfør eller på dugnad. Bedrifter + i bygda som vil sponse. + +what_we_keep_track_of: > + Innmeldinger: en forelder melder inn, treneren på årskullet + godkjenner at det er plass, kassereren ser at avgiften er betalt. + Noen trekker seg underveis. + Avgiftssaker: noen ber om å dele opp eller slippe avgiften; styret + vurderer og sier ja eller nei. + Frivillige: noen tilbyr seg, styret tar kontakt og plasserer dem der + det trengs, eller takker nei. + Sponsorer: en bedrift melder interesse, vi tar en prat, og det blir + en avtale eller ikke. + Ting på banen: lys som ikke virker, mål som er ødelagt, drakter som + mangler. Trenerne melder fra, styret tar tak i det og ordner det. + +who_is_involved: > + Trenerne godkjenner innmeldinger på sitt årskull og melder fra om + banen. Kassereren følger opp betaling. Styret tar frivillige, + sponsorer, avgiftssaker og alt som gjelder anlegget. + +what_slows_us_down: > + Alt går i en gruppechat og et regneark. Trenerne vet ikke hvem som + har betalt, kassereren vet ikke hvem som er godkjent, og en som + tilbød seg å kjøre i høst fikk aldri svar. diff --git a/intakes/hire.yaml b/intakes/hire.yaml new file mode 100644 index 0000000..ad3382d --- /dev/null +++ b/intakes/hire.yaml @@ -0,0 +1,38 @@ +# An intake as the owner of a machine hire yard would fill it in. +business: Fjellstad Hire +contact: { name: Ola Fjellstad, email: ola@fjellstadhire.example } +language: en + +what_we_do: > + We hire out machines: mini diggers, lifts, compactors, generators, + that kind of thing. A counter with two people, a yard with three who + deliver, collect and fix, and me in the office doing accounts and + the business customers. + +who_comes_to_us: > + Private people who need a machine for a weekend and have often never + driven one. Building and landscaping firms who need something on a + site from a date for a period. Anyone whose hired machine has + stopped working while they have it. Firms who hire often and want + an account so they can send anyone to collect and get one invoice a + month. + +what_we_keep_track_of: > + Hires: someone asks, the counter reserves it, the yard hands it out + or delivers it, and the yard takes it back. Some are cancelled + before they go out. + Breakdowns: a machine stops during a hire; the yard takes it on and + either fixes it on site or swaps it. + Account applications: a firm applies, I check the company and its + credit, and it is approved or turned down. + Damage on return: the yard finds damage when a machine comes back; I + either invoice the customer or write it off. + +who_is_involved: > + The counter handles hires. The yard handles breakdowns and the + handing out and taking back. The office does accounts and damage. + +what_slows_us_down: > + The yard finds out about breakdowns from whoever picks up the phone. + Damage found on return is written on a whiteboard and half of it is + never invoiced. diff --git a/intakes/physio.yaml b/intakes/physio.yaml new file mode 100644 index 0000000..9cb02e4 --- /dev/null +++ b/intakes/physio.yaml @@ -0,0 +1,42 @@ +# An intake as the manager of a physiotherapy clinic would fill it in. +business: Bakken Fysioterapi +contact: { name: Marit Bakken, email: marit@bakkenfysio.example } +language: en + +what_we_do: > + Six physiotherapists and a receptionist. People come to us with + pain, injuries and after operations; we assess them and treat them + over a course of sessions. Some come on their own, some are sent by + a GP or a hospital, and some have insurance that pays if the insurer + approves the course first. + +who_comes_to_us: > + People with something that hurts who want to see the right + therapist. Patients who already have a time and need to move or + drop it. GPs and hospital wards sending us someone, who want to + hear how it went. Insurers, through us, when a course has to be + approved before it starts. + +what_we_keep_track_of: > + Requests for a first appointment: they come in, we offer a time with + a therapist who suits, and it is either booked or they go elsewhere. + Cancellations: someone can't come; we either find them a new time or + give the slot to someone waiting. + Referrals: a doctor sends someone, we accept it, book them in, treat + them, and send the doctor a report at the end. Sometimes we send it + back because it isn't something we treat. + Insurance approvals: for some patients we have to ask the insurer + before we start; we send the request and it comes back approved or + refused. + No-shows: someone doesn't turn up and doesn't call. The receptionist + rings them and either rebooks or, after a couple of times, closes it. + +who_is_involved: > + The receptionist handles every request, cancellation, referral and + approval. The therapists treat and write the report back to the + doctor, and they are the ones who notice a no-show. + +what_slows_us_down: > + Referrals, cancellations and insurance approvals all go through one + inbox that only the receptionist understands. Therapists tell her + about no-shows in the corridor and they get forgotten. diff --git a/intakes/rehab-entreprenor-no.yaml b/intakes/rehab-entreprenor-no.yaml new file mode 100644 index 0000000..489edb1 --- /dev/null +++ b/intakes/rehab-entreprenor-no.yaml @@ -0,0 +1,48 @@ +# En fiktiv norsk rehabiliteringsentreprenør, inspirert av hva +# Klingenberg Bygg driver med: tilstandsrapporter, prosjektering, +# byggesøknader og utførelse på eldre murgårder. Skrevet slik eieren +# ville sagt det, uten portal-ord. +business: Åsrud Bygg og Rehabilitering AS +contact: { name: Marte Åsrud, email: marte@asrudbygg.example } +language: "no" + +what_we_do: > + Vi er fjorten ansatte og jobber mest med eldre murgårder og + trehus. Vi lager tilstandsrapporter, prosjekterer rehabilitering, + sender byggesøknad med ansvarsrett, og utfører arbeidet med egne + tømrere og murere. Elektriker, rørlegger, stillas og blikkenslager + leier vi inn som underentreprenører. + +who_comes_to_us: > + Styreledere i borettslag og sameier, og private huseiere, som har + et problem med bygget og vil at noen skal se på det. Eiere som + allerede vet hva de vil bygge eller endre, og trenger tegninger, + søknad til kommunen og en pris. Håndverksfirmaer som vil stå på + lista vår over underentreprenører. Tømrere, murere og lærlinger som + lurer på om vi trenger folk. + +what_we_keep_track_of: > + Henvendelser: de kommer inn, vi avtaler og drar på befaring, vi + sender tilbud, og så vinner vi jobben eller taper den. Noen ganger + er det rett og slett ikke noe for oss, og da sier vi det med en gang. + Underentreprenører: de melder seg, vi sjekker organisasjonsnummer, + sentral godkjenning, forsikring og at folka har HMS-kort. Ofte + mangler det papirer og vi må etterlyse dem. Så blir de godkjent + eller avslått. + Jobbsøknader: vi leser dem, kaller inn noen til intervju, og + ansetter eller takker nei. + Avvik og uønskede hendelser på byggeplass: løse rekkverk, + nestenulykker, feil materialer levert. Noen melder fra, HMS-ansvarlig + bestemmer tiltak, og lukker saken når det er utbedret. + +who_is_involved: > + To prosjektledere tar seg av henvendelser, underentreprenører og + ansettelser. En HMS-ansvarlig tar alt som meldes fra byggeplass. + Mannskapet, både våre egne og underentreprenørenes folk, er de som + står på plassen og melder fra. De sitter med telefonen, har hansker + på, og fyller ikke ut lange skjemaer. + +what_slows_us_down: > + Henvendelser kommer på telefon, e-post og SMS og blir borte. Vi vet + aldri sikkert hvilke underentreprenører som har papirene i orden. + Avvik blir nevnt i lunsjen og glemt. diff --git a/intakes/tomtervel.yaml b/intakes/tomtervel.yaml new file mode 100644 index 0000000..4c2b9d8 --- /dev/null +++ b/intakes/tomtervel.yaml @@ -0,0 +1,62 @@ +# Tomter Vel, slik en i styret ville fylt det ut. Det som står om +# velet er hentet fra tomtervel.no (komiteer, kontingent, medlemstall, +# arrangementer); det som står om deling av utstyr er et forslag til +# en ny tjeneste. +business: Tomter Vel +contact: { name: Styret i Tomter Vel, email: post@tomtervel.no } +language: no + +what_we_do: > + Velforeningen for Tomter, stiftet i 1947. Vi er et styre på sju og + sju komiteer som alle drives av folk på fritida: arrangementer + (17. mai-frokost, juletregranstenning, skøytedag), elvestien langs + Hobølelva, to lekeplasser vi holder i stand og får kontrollert hvert + år, høringssvar til kommunen, miljøgata i sentrum, pendlerforhold + mot Ruter, og trafikkforhold. 157 husstander betaler kontingent på + 250 kroner i året. + +who_comes_to_us: > + Husstander som vil bli medlem, eller som har flyttet eller byttet + e-post og vil at vi skal ha det riktig. Folk som har sett noe i + nabolaget som ikke er som det skal: en farlig sving, en huske som + henger i én kjetting, en råtten planke på stien. Beboere som vil + si sin mening om en sak kommunen har på høring. Folk som vil hjelpe + til på et arrangement eller en dugnad, eller sitte i en komité. + Og naboer som har en tilhenger, en stige eller en hekksaks som står + ubrukt, og naboer som trenger å låne en. Eldre og nyopererte som + trenger en hånd med snømåking, handling eller en skyss, og naboer + som gjerne stiller opp hvis noen spør. + +what_we_keep_track_of: > + Medlemskap: en husstand melder seg inn, kassereren ser at + kontingenten er betalt, og noen melder seg ut når de flytter. + Meldinger om trafikk: noen melder fra, trafikkomiteen tar det opp + med kommunen eller veieier, og det blir ordnet eller avvist. + Feil på lekeplass og sti: noen melder fra, komiteen tar tak i det + og utbedrer det. + Høringsinnspill: en beboer sender inn, styret tar det med i svaret + eller lar være. + Frivillige: noen tilbyr seg, arrangementskomiteen tar kontakt og + setter dem opp, eller takker nei. + Komitésøknader: noen vil være med, styret plasserer dem eller sier + nei. + Utlegg: en i en komité har lagt ut for noe, kassereren godkjenner og + betaler ut, eller avviser. + Nabohjelp: noen ber om en hånd, nabohjelpsgruppa finner en som kan, + og den som fikk hjelp sier fra når det er ordnet. Naboer som vil + hjelpe melder seg, og kan trekke seg igjen. + Deling av utstyr: en nabo melder at de har noe å låne ut, eller + trenger å låne noe; styret legger det ut på oppslagstavla og tar + det ned når det er gjort. + +who_is_involved: > + Kassereren har medlemskap og utlegg. Trafikkomiteen har + trafikkmeldinger, lekeplasskomiteen har feil på anlegg, + arrangementskomiteen har frivillige. Styret har høringsinnspill, + komitésøknader og oppslagstavla. Nabohjelpsgruppa kobler dem som + trenger hjelp med dem som kan. Komiteene leverer utlegg. + +what_slows_us_down: > + Medlemslista er en bankutskrift og et regneark. Ting som meldes på + Facebook eller i butikken blir husket av den som hørte det. Folk + som tilbyr seg å hjelpe får svar sent eller aldri. diff --git a/plans/building-permits.yaml b/plans/building-permits.yaml new file mode 100644 index 0000000..5ecfb5e --- /dev/null +++ b/plans/building-permits.yaml @@ -0,0 +1,487 @@ +# A building authority, written by hand to find where the plan language +# stops. 14 needs, 7 roles, 12 buckets, two needs sharing a bucket in +# each direction (public and internal), a decision no case officer may +# sign, a group outside the building that moves records and nothing +# else, a stage a deadline moves rather than a person, and four things +# the plan language cannot say at all - each named in the run's README +# rather than smuggled past. +business: Vestmark kommune - Building and Planning +language: en +contact: { name: Solveig Hauge, email: byggesak@vestmark.example } +mail: { from: "Vestmark Building and Planning ", reply_to: byggesak@vestmark.example } +about: > + The building authority for twenty-one thousand people. Anything built, + demolished, divided or changed in use is decided here: the drawings are + checked against the zoning map, the neighbours and the other + authorities have their say, and then the head of section signs. The + same office inspects what is actually built, signs off finished work, + and deals with building nobody applied for. + +# Two features, two roles, no button. The authority exists for the +# person who wants to build; the next one it serves is the neighbour +# who finds out what is planned next door. Both have a move of their +# own after sending: the applicant withdraws, the objector withdraws, +# and both buckets are open to read. +does: + - id: we-decide-what-may-be-built + for: small-works-application + text: > + Everything built in Vestmark is decided here. You send the drawings, + the neighbours and the road, fire and heritage authorities are heard, + and you get a written decision you can hold us to. + - id: your-neighbours-case-is-readable + for: objection + text: > + If a notice landed in your postbox, the case behind it is open to + read here, and what you write to us has to be answered by name in + the decision. + +doors: + - id: want-to-build + who: an owner or a firm with something to build, divide or change + - id: building-near-me + who: someone who lives next to what is being built + +groups: + - { id: front_desk, label: Front desk } + - { id: case_officers, label: Case officers } + - { id: section_head, label: Head of section } + - { id: inspectors, label: Inspectors } + - { id: appeals_board, label: Appeals board, invited_by: [section_head] } + - { id: agencies, label: Hearing bodies, invited_by: [case_officers, section_head] } + - { id: approved_firms, label: Approved firms, invited_by: [section_head] } +invites_by: [section_head, case_officers] + +needs: + # --- the public: something they want to build ---------------------- + - id: pre-application-meeting + door: want-to-build + who: someone with an idea for a building and no drawings yet + wants: to hear what we will allow before paying an architect + lands_in: meetings + handled_by: case_officers + stages: [open, scheduled, met, advised, cancelled] + done_when: [advised, cancelled] + success: [advised] + # They can call it off themselves; the front desk does it when they ring. + moves_by: { cancelled: [submitter, front_desk] } + watched_by: [front_desk] + mails: { open: submitter, scheduled: submitter, advised: submitter } + stages_text: { met: we have met, advised: written advice sent } + max_fields: 5 + collects: + - { name: name } + - { name: email, type: email } + - { name: property_address } + - { name: what_you_want, type: textarea } + - { name: phone, type: tel, optional: true } + - { name: site, type: location, optional: true } + + - id: small-works-application + door: want-to-build + who: an owner applying for something small enough to file themselves + wants: permission to build it, and to know where the case stands + lands_in: applications + handled_by: case_officers + stages: [open, incomplete, complete, on_hearing, recommended, approved, approved_with_conditions, refused, withdrawn] + done_when: [approved, approved_with_conditions, refused, withdrawn] + success: [approved, approved_with_conditions] + skippable: [incomplete] + # A case is sent back for the drawing it was missing, and the + # hearing turns things up that send it back again. It may be + # withdrawn from anywhere, and refused only once we have seen + # everything: not on the day it arrives. + reached_from: + incomplete: [open, complete, on_hearing] + refused: [complete, on_hearing, recommended] + # No case officer signs their own case: only the head of section + # moves one into a decision. The applicant answers a request for + # missing papers, and may withdraw right up to the signature. + moves_by: + complete: [submitter, case_officers] + approved: [section_head] + approved_with_conditions: [section_head] + refused: [section_head] + withdrawn: [submitter] + watched_by: [public, front_desk, inspectors, section_head] + mails: + open: submitter + incomplete: submitter + on_hearing: submitter + approved: submitter + approved_with_conditions: submitter + refused: submitter + stages_text: + incomplete: papers missing + complete: papers complete + on_hearing: out to the neighbours and the agencies + recommended: recommendation written + approved_with_conditions: approved on conditions + max_fields: 6 + collects: + - { name: email, type: email } + - { name: property_address } + - { name: property_number } + - { name: what_is_planned, type: textarea } + - { name: neighbours_notified, type: select, options: ["Yes", Not yet] } + - { name: site, type: location, optional: true } + - { name: drawings_url, optional: true } + + - id: exemption-request + door: want-to-build + who: someone whose plans break a rule in the zoning map + wants: to be allowed it anyway, with reasons on the record + requires: An application already with us, and the case number from its receipt. + lands_in: exemptions + handled_by: case_officers + stages: [open, county_asked, county_answered, recommended, granted, denied, withdrawn] + done_when: [granted, denied, withdrawn] + success: [granted] + # The county answers from outside the building; nothing may be + # granted before it has. The head of section signs, as for a permit. + moves_by: + county_answered: [agencies] + granted: [section_head] + denied: [section_head] + withdrawn: [submitter] + watched_by: [public, section_head] + mails: { open: submitter, county_asked: submitter, granted: submitter, denied: submitter } + stages_text: { county_asked: with the county, county_answered: the county has answered } + max_fields: 4 + collects: + - { name: email, type: email } + - { name: case_number } + - { name: which_rule } + - { name: why_it_should_be_allowed, type: textarea } + - { name: site, type: location, optional: true } + + - id: completion-request + door: want-to-build + who: an owner or firm whose building work is finished + wants: the certificate that lets the building be used + requires: A case number, and the work finished or close enough to move in. + lands_in: completions + handled_by: case_officers + stages: [open, checked, temporary_permission, certificate_issued, refused] + done_when: [certificate_issued, refused] + success: [certificate_issued] + # Most go straight through; the ones who want to move in before the + # last of it is done detour past a temporary permission. + skippable: [temporary_permission] + moves_by: + temporary_permission: [section_head] + certificate_issued: [section_head] + refused: [section_head] + watched_by: [public, inspectors] + turned_down_from: [open, checked] + mails: { open: submitter, temporary_permission: submitter, certificate_issued: submitter, refused: submitter } + stages_text: { checked: checked against the inspections, temporary_permission: temporary permission to use, certificate_issued: certificate issued } + max_fields: 5 + collects: + - { name: email, type: email } + - { name: case_number } + - { name: property_address } + - { name: work_finished, type: date } + - { name: asking_for, type: select, options: [Completion certificate, Temporary permission to move in] } + - { name: what_is_outstanding, type: textarea, optional: true } + + # --- the public: someone else is building --------------------------- + - id: objection + door: building-near-me + who: a neighbour who got a notice about what is planned next door + wants: what they think on the record, and answered in the decision + requires: The notice that came in the post, which has the case number on it. + lands_in: objections + handled_by: case_officers + stages: [open, registered, answered_by_applicant, addressed, withdrawn] + done_when: [addressed, withdrawn] + success: [addressed] + # The firm that filed the case answers the objection; the neighbour + # can take it back if they are satisfied. + moves_by: { answered_by_applicant: [approved_firms, case_officers], withdrawn: [submitter] } + watched_by: [public, section_head] + mails: { open: submitter, answered_by_applicant: submitter, addressed: submitter } + stages_text: { registered: on the case file, answered_by_applicant: the applicant has answered, addressed: answered in the decision } + max_fields: 4 + collects: + - { name: email, type: email } + - { name: case_number } + - { name: your_property } + - { name: your_objection, type: textarea } + + - id: case-status-enquiry + door: building-near-me + who: someone waiting on a case and getting nothing back + wants: to be told where it actually stands + lands_in: enquiries + handled_by: front_desk + stages: [open, in_hand, answered] + done_when: [answered] + success: [answered] + moves_by: { in_hand: [front_desk, case_officers] } + watched_by: [case_officers] + mails: { open: submitter, answered: submitter } + max_fields: 3 + collects: + - { name: email, type: email } + - { name: case_number_or_address } + - { name: what_you_want_to_know, type: textarea } + + - id: illegal-building-report + door: building-near-me + who: someone who can see building going up that nobody was told about + wants: us to go and look, and to hear that we did + lands_in: illegal_building + handled_by: inspectors + stages: [open, inspected, owner_asked, explained, resolved, fined, dropped] + done_when: [resolved, fined, dropped] + success: [resolved, dropped] + # An inspector goes and looks; the letter to the owner is a case + # officer's, and so is the fine. + moves_by: { owner_asked: [case_officers], explained: [case_officers], fined: [section_head] } + watched_by: [case_officers, section_head] + turned_down_from: [open, inspected] + mails: { open: submitter, inspected: submitter, resolved: submitter, dropped: submitter } + stages_text: { owner_asked: the owner has been written to, explained: the owner has answered, dropped: nothing to pursue } + max_fields: 3 + collects: + - { name: email, type: email } + - { name: address } + - { name: what_you_saw, type: textarea } + - { name: where, type: location, optional: true } + + # --- the public: after a decision ------------------------------------ + - id: appeal + who: anyone with an interest who will not accept a decision of ours + wants: the decision looked at again, by us and then by the board + requires: A decision from us less than three weeks old, and its case number. + lands_in: appeals + handled_by: case_officers + stages: [open, reconsidered, changed_by_us, before_the_board, upheld, overturned, withdrawn] + done_when: [changed_by_us, upheld, overturned, withdrawn] + success: [changed_by_us, upheld] + # If we change our own mind that is the end of it, and it happens + # while we are looking - not after the board has the case. The + # board can only decide what has reached it. + reached_from: + changed_by_us: [reconsidered] + upheld: [before_the_board] + overturned: [before_the_board] + # We look at ourselves first and the head of section decides whether + # we were wrong. What we will not change goes to seven elected + # people who meet once a month and move nothing else in this office. + moves_by: + changed_by_us: [section_head] + before_the_board: [section_head] + upheld: [appeals_board] + overturned: [appeals_board] + withdrawn: [submitter] + watched_by: [public, front_desk, section_head] + mails: + open: submitter + reconsidered: submitter + changed_by_us: submitter + before_the_board: submitter + upheld: submitter + overturned: submitter + stages_text: + reconsidered: we are looking at it again + changed_by_us: we changed our own decision + before_the_board: with the appeals board + upheld: the board kept our decision + overturned: the board overturned us + max_fields: 5 + collects: + - { name: email, type: email } + - { name: case_number } + - { name: property } + - { name: what_you_disagree_with, type: textarea } + - { name: your_interest, type: select, options: [I applied, I am a neighbour, Other] } + + # --- the public: a firm that wants to file here ----------------------- + - id: firm-approval + who: an architect or engineering firm that files for its clients + wants: to be on the list, and then to file without asking again + lands_in: firms + handled_by: case_officers + stages: [open, papers_missing, checked, approved, declined] + done_when: [approved, declined] + success: [approved] + skippable: [papers_missing] + moves_by: { checked: [submitter, case_officers], approved: [section_head], declined: [section_head] } + watched_by: [section_head, front_desk] + turned_down_from: [open, papers_missing, checked] + mails: { open: submitter, papers_missing: submitter, approved: submitter, declined: submitter } + stages_text: { papers_missing: waiting on papers, checked: papers checked } + max_fields: 6 + collects: + - { name: firm_name } + - { name: org_number } + - { name: email, type: email } + - { name: qualified_for, type: select, multiple: true, options: [Design, Execution, Independent control, Site management] } + - { name: insurance_expires, type: date } + - { name: insurance_document, optional: true } + + # --- approved firms ---------------------------------------------------- + - id: permit-application + who: an approved firm filing on behalf of an owner + wants: a decision inside twelve weeks, and to know when the clock stops + enters_as: approved_firms + lands_in: applications + handled_by: case_officers + stages: [open, incomplete, complete, on_hearing, recommended, approved, approved_with_conditions, refused, withdrawn] + done_when: [approved, approved_with_conditions, refused, withdrawn] + success: [approved, approved_with_conditions] + skippable: [incomplete] + # A case is sent back for the drawing it was missing, and the + # hearing turns things up that send it back again. It may be + # withdrawn from anywhere, and refused only once we have seen + # everything: not on the day it arrives. + reached_from: + incomplete: [open, complete, on_hearing] + refused: [complete, on_hearing, recommended] + moves_by: + complete: [submitter, case_officers] + approved: [section_head] + approved_with_conditions: [section_head] + refused: [section_head] + withdrawn: [submitter] + watched_by: [public, front_desk, inspectors, section_head] + mails: + open: submitter + incomplete: submitter + on_hearing: submitter + approved: submitter + approved_with_conditions: submitter + refused: submitter + stages_text: + incomplete: papers missing + complete: papers complete + on_hearing: out to the neighbours and the agencies + recommended: recommendation written + approved_with_conditions: approved on conditions + max_fields: 6 + collects: + - { name: email, type: email } + - { name: property_address } + - { name: property_number } + - { name: what_is_planned, type: textarea } + - { name: neighbours_notified, type: select, options: ["Yes", Not yet] } + - { name: site, type: location, optional: true } + - { name: drawings_url, optional: true } + + # --- the front desk ----------------------------------------------------- + - id: log-counter-enquiry + who: whoever is on the counter with a queue and a caller on the line + wants: the question written down and sent to whoever has the case + enters_as: front_desk + lands_in: enquiries + handled_by: front_desk + stages: [open, in_hand, answered] + done_when: [answered] + success: [answered] + moves_by: { in_hand: [front_desk, case_officers] } + watched_by: [case_officers] + mails: { open: submitter, answered: submitter } + max_fields: 3 + collects: + - { name: email, type: email } + - { name: case_number_or_address } + - { name: what_you_want_to_know, type: textarea } + + # --- case officers ------------------------------------------------------ + - id: request-hearing + who: a case officer sending a case out to the authority that must see it + wants: an answer by the deadline, or a record that none came + enters_as: case_officers + lands_in: hearings + handled_by: case_officers + stages: [open, answered, no_answer, cancelled] + done_when: [answered, no_answer, cancelled] + success: [answered] + # This is how an agency sees what it has been asked. Nobody presses + # a button for the deadline passing. + moves_by: { answered: [agencies] } + watched_by: [agencies, section_head, front_desk] + attended_by: > + the six-week deadline: when it passes with nothing from the agency + the case goes to no_answer and the application goes on without one + stages_text: { no_answer: the deadline passed with no answer } + max_fields: 4 + collects: + - { name: case_number } + - { name: which_authority, type: select, options: [Road authority, Fire service, Heritage office, The county] } + - { name: what_we_ask, type: textarea } + - { name: answer_by, type: date } + - { name: site, type: location, optional: true } + + # --- the authorities outside the building -------------------------------- + - id: hearing-answer + who: a road, fire, heritage or county officer who has been asked about a case + wants: their answer on the file, in their own words + requires: The hearing letter we sent you, which has the case number on it. + enters_as: agencies + lands_in: hearing_answers + handled_by: case_officers + stages: [open, read, followed, set_aside] + done_when: [followed, set_aside] + success: [followed] + watched_by: [section_head, front_desk] + stages_text: { read: read by the case officer, followed: followed in the decision, set_aside: set aside with reasons } + max_fields: 4 + collects: + - { name: case_number } + - { name: which_authority, type: select, options: [Road authority, Fire service, Heritage office, The county] } + - { name: our_answer, type: select, options: [No objection, Objection, Conditions required] } + - { name: what_we_say, type: textarea } + + # --- inspectors ----------------------------------------------------------- + - id: inspection-finding + who: an inspector on a scaffold with gloves on and the wind blowing + wants: what they found logged before they climb down + enters_as: inspectors + lands_in: inspections + handled_by: inspectors + stages: [open, as_approved, deviation, put_right, escalated] + done_when: [as_approved, put_right, escalated] + success: [as_approved, put_right] + # Anything that has to become a fine or a refusal leaves the + # inspectors and becomes a case officer's problem. + moves_by: { escalated: [case_officers, section_head] } + watched_by: [case_officers, section_head] + stages_text: { as_approved: built as approved, deviation: built differently, put_right: put right, escalated: handed to a case officer } + max_fields: 3 + collects: + - { name: case_number } + - { name: site_address } + - { name: what_i_found, type: textarea } + - { name: where, type: location, optional: true } + +# Held out from every model that writes words. +# arriving with one real thing, in their own register: a homeowner who +# has never read a regulation, a firm that files for a living, an +# inspector on a roof in the wind, a politician reading a bundle. +personas: + - { id: garage-owner, need: pre-application-meeting, text: "We want to put a double garage where the old shed is. Before I pay an architect I'd like to know whether you'll even allow it, because the neighbour says it's too close to the road." } + - { id: shop-to-flats, need: pre-application-meeting, text: "I own the old grocery on Torget and want to make four flats out of it. There are a lot of questions - parking, fire, the facade is listed. Can we sit down with someone?" } + - { id: firm-files-extension, need: permit-application, text: "We are the responsible applicant for an extension at Bjorkeveien 14. Drawings, site plan and the neighbour notices are all ready. We file maybe forty of these a year." } + - { id: owner-small-window, need: permit-application, text: "I want to put a dormer window in my roof. The man at the counter said I have to apply even though it's small." } + - { id: missing-drawings, need: permit-application, text: "You wrote that our section drawing is missing and the case is on hold. I've got it here now, where do I send it so the clock starts again?" } + - { id: too-close-to-boundary, need: exemption-request, text: "The garage ends up one and a half metres from the boundary and the plan says four. We are asking you to allow it anyway - the ground falls away steeply everywhere else on the plot." } + - { id: neighbour-view, need: objection, text: "I got a notice about the extension at number 14. It takes the afternoon sun off my entire terrace and I want that on the record before you decide anything." } + - { id: neighbour-parking, need: objection, text: "The flats they want to build in the old grocery have four parking places for four flats and everyone here already parks on the street. I object." } + - { id: road-authority, need: hearing-answer, text: "Regarding your case 2026/418: the access onto the county road is within the sight line and we cannot accept it as drawn. Our formal reply is attached." } + - { id: fire-service, need: hearing-answer, text: "We have looked at the conversion on Torget. We have no objection provided the stairwell is upgraded. Reference your case 2026/501." } + - { id: refused-applicant, need: appeal, text: "You refused our exemption on the ninth. We don't accept the reasoning - you've allowed the same thing twice in the same street. We're appealing." } + - { id: neighbour-appeals, need: appeal, text: "You approved the extension next door even though I objected. I'm appealing the decision. What do I need to send?" } + - { id: board-member, need: appeal, text: "I sit on the appeals board. I need the case, our own decision, and what the neighbours wrote, before Tuesday." } + - { id: inspector-on-roof, need: inspection-finding, text: "I'm up on the scaffold at Bjorkeveien. The fire wall isn't built to the drawing at all. I've got gloves on and it's blowing. I want this logged before I climb down." } + - { id: inspector-all-good, need: inspection-finding, text: "Been out at Torget. Everything is as approved. Nothing to report but I have to record that I was there." } + - { id: firm-wants-certificate, need: completion-request, text: "The work at Bjorkeveien 14 is finished and inspected. We're asking for the completion certificate so the owners can get their mortgage released." } + - { id: wants-to-move-in, need: completion-request, text: "The house is done apart from the outside steps, which we can't pour until spring. The family has sold their flat. Can we get a temporary permission to move in?" } + - { id: something-behind-fence, need: illegal-building-report, text: "There's a large structure going up behind the fence at Solbakken 7. Nobody has had a notice about it and it's been going on for two weeks." } + - { id: owner-explains, need: illegal-building-report, text: "I got your letter about the terrace. I genuinely did not know it needed an application, it's been there since we moved in. What happens now?" } + - { id: new-firm, need: firm-approval, text: "We are a three-person architecture firm that has just started. We want to be approved so we can file applications for our clients in your municipality." } + - { id: firm-insurance-lapsed, need: firm-approval, text: "You wrote that our liability insurance certificate has expired. We have renewed it, here it is." } + - { id: counter-phone-call, need: log-counter-enquiry, text: "A woman rang about the case behind her house. Wants to know where it stands. I'm at the counter and there's a queue - I need to write it down and send it to whoever has that case." } + - { id: where-is-my-case, need: case-status-enquiry, text: "I sent in my application five weeks ago and I've heard nothing. Is anyone looking at it? Is it in the six weeks with the neighbours or is it waiting on me?" } diff --git a/plans/contractor-full.yaml b/plans/contractor-full.yaml new file mode 100644 index 0000000..ec5f279 --- /dev/null +++ b/plans/contractor-full.yaml @@ -0,0 +1,294 @@ +# A contractor's whole operation, to stress the builder and the +# harness: 13 needs, 6 roles, 9 buckets, needs sharing a bucket, +# skippable stages, and a front door that had to be tiered. +business: Nordvik Bygg +language: en +contact: { name: Ingrid Nordvik, email: ingrid@nordvikbygg.example } +about: > + Twelve people who survey old buildings, draw up rehabilitation plans, + handle the permit application, and do the building work with their + own carpenters plus subcontractors for electrical, plumbing and + scaffolding. Project managers win and run the jobs, site managers run + each building site day to day, and one safety officer owns everything + reported from site. + +# Six public needs is one over what a front door carries, so the +# public comes through three doors: a building of theirs, work with +# us, and a site of ours next to them (that one is a need on its own). +doors: + - id: your-building + who: an owner, a housing board or a past customer with a building + - id: work-with-us + who: a trade firm, a carpenter or an apprentice who wants work from us + +needs: + # --- the public --------------------------------------------------- + - id: survey-enquiry + door: your-building + who: a housing board or private owner with a problem in a building + wants: someone qualified to come and look at it and say what it takes + lands_in: enquiries + handled_by: project_managers + stages: [open, surveyed, quoted, won, lost] + done_when: [won, lost] + success: [won] + # Whoever went to the building marks it surveyed; site managers + # see what is coming their way. + moves_by: { surveyed: [project_managers, site_managers] } + watched_by: [site_managers] + max_fields: 4 + collects: + - { name: name } + - { name: phone, type: tel } + - { name: address } + - { name: problem, type: textarea } + - { name: access, optional: true } + + - id: build-enquiry + door: your-building + who: an owner who already knows what they want built or changed + wants: drawings, a permit application and a price + lands_in: enquiries + handled_by: project_managers + stages: [open, surveyed, quoted, won, lost] + done_when: [won, lost] + success: [won] + # Whoever went to the building marks it surveyed; site managers + # see what is coming their way. + moves_by: { surveyed: [project_managers, site_managers] } + watched_by: [site_managers] + max_fields: 5 + collects: + - { name: name } + - { name: phone, type: tel } + - { name: address } + - { name: project, type: textarea } + - { name: timeframe, type: select, options: [This year, Next year, No fixed date] } + + - id: warranty-claim + door: your-building + who: a past customer who has found a fault in work we handed over + wants: us to come back, look at it, and put it right + lands_in: claims + handled_by: project_managers + stages: [open, inspected, accepted, fixed, rejected] + done_when: [fixed, rejected] + success: [fixed] + # The site manager inspects and puts it right; whether it is ours + # to fix is the project manager's call. + moves_by: { inspected: [site_managers], fixed: [site_managers] } + max_fields: 4 + collects: + - { name: name } + - { name: phone, type: tel } + - { name: address } + - { name: fault, type: textarea } + - { name: handed_over, type: date, optional: true } + + - id: neighbour-complaint + who: someone living or working next to one of our building sites + wants: the noise, dust or blocked access dealt with, and an answer + lands_in: complaints + handled_by: site_managers + stages: [open, answered] + done_when: [answered] + success: [answered] + watched_by: [project_managers] + max_fields: 3 + collects: + - { name: site_address } + - { name: what_is_wrong, type: textarea } + - { name: reach_me_on } + + - id: subcontractor-application + door: work-with-us + who: a trade firm doing electrical, plumbing or scaffolding work + wants: to be approved once, and then be called when a job needs them + lands_in: subcontractors + handled_by: project_managers + stages: [open, awaiting_papers, approved, declined] + done_when: [approved, declined] + success: [approved] + watched_by: [site_managers] + skippable: [awaiting_papers] + max_fields: 5 + collects: + - { name: company } + - { name: registration_number } + - { name: trade, type: select, options: [Electrical, Plumbing, Scaffolding, Sheet metal, Other] } + - { name: contact_email, type: email } + - { name: insurance_confirmed, type: select, options: ["Yes", Not yet] } + - { name: trade_approval, optional: true } + + - id: job-application + door: work-with-us + who: a carpenter, bricklayer or apprentice looking for work + wants: to be considered, without writing a letter + lands_in: applicants + handled_by: project_managers + stages: [open, interviewing, offered, hired, declined] + done_when: [hired, declined] + success: [hired] + max_fields: 3 + collects: + - { name: name } + - { name: phone, type: tel } + - { name: trade, type: select, options: [Carpenter, Bricklayer, Apprentice, Other] } + - { name: experience, type: textarea, optional: true } + + # --- clients with a job under way --------------------------------- + - id: change-request + who: a client whose job is under way and who wants something changed + wants: a price for the change, and the change done if they accept it + enters_as: clients + lands_in: change_orders + handled_by: project_managers + stages: [open, priced, approved, completed, declined] + done_when: [completed, declined] + success: [completed] + # The project manager prices it, the client says yes or no through + # their own link, the site manager does the work. + moves_by: { approved: [submitter], declined: [submitter, project_managers], completed: [site_managers] } + watched_by: [site_managers] + max_fields: 3 + collects: + - { name: email, type: email } + - { name: job_address } + - { name: change, type: textarea } + - { name: needed_by, type: date, optional: true } + + # --- project managers --------------------------------------------- + - id: log-phone-enquiry + who: a project manager who just took a call or a text about a building + wants: the enquiry written down before it gets lost + enters_as: project_managers + lands_in: enquiries + handled_by: project_managers + stages: [open, surveyed, quoted, won, lost] + done_when: [won, lost] + success: [won] + # Whoever went to the building marks it surveyed; site managers + # see what is coming their way. + moves_by: { surveyed: [project_managers, site_managers] } + watched_by: [site_managers] + max_fields: 4 + collects: + - { name: caller } + - { name: phone, type: tel } + - { name: address } + - { name: problem, type: textarea } + + - id: post-job-request + who: a project manager with work to hand to a subcontractor + wants: approved subcontractors to see the job and answer with a price + enters_as: project_managers + lands_in: job_requests + handled_by: project_managers + stages: [open, awarded, withdrawn] + done_when: [awarded, withdrawn] + success: [awarded] + # This is how subcontractors see what there is to bid on. + watched_by: [subcontractors, site_managers] + max_fields: 4 + collects: + - { name: site_address } + - { name: trade, type: select, options: [Electrical, Plumbing, Scaffolding, Sheet metal, Other] } + - { name: work, type: textarea } + - { name: start, type: date } + + # --- approved subcontractors -------------------------------------- + - id: submit-bid + who: an approved subcontractor who has seen a job they can take + wants: to say yes with a price and a start date + enters_as: subcontractors + lands_in: bids + handled_by: project_managers + stages: [open, shortlisted, accepted, declined] + done_when: [accepted, declined] + success: [accepted] + watched_by: [site_managers] + skippable: [shortlisted] + max_fields: 3 + collects: + - { name: job } + - { name: price } + - { name: can_start, type: date } + + # --- on site -------------------------------------------------------- + - id: report-site-issue + who: anyone on site, ours or a subcontractor's, on a phone with gloves on + wants: to report a hazard, a near miss or wrong materials before they forget + enters_as: crew + lands_in: site_issues + handled_by: safety_officer + stages: [open, in_hand, closed] + done_when: [closed] + success: [closed] + # A site manager can take an issue in hand on the spot; only the + # safety officer closes it. + moves_by: { in_hand: [safety_officer, site_managers] } + watched_by: [project_managers] + max_fields: 3 + collects: + - { name: kind, type: select, options: [Hazard, Near miss, Wrong materials, Other] } + - { name: where } + - { name: what_happened, type: textarea } + + - id: order-materials + who: a carpenter or bricklayer who is about to run out of something + wants: it ordered and on site before work stops + enters_as: crew + lands_in: material_orders + handled_by: site_managers + stages: [open, ordered, delivered, refused] + done_when: [delivered, refused] + success: [delivered] + # The crew confirms it arrived. + moves_by: { delivered: [crew, site_managers] } + max_fields: 3 + collects: + - { name: site_address } + - { name: what, type: textarea } + - { name: needed_by, type: date } + + - id: safety-round-finding + who: the safety officer walking a site on the weekly round + wants: each thing found written down where the reported issues already are + enters_as: safety_officer + lands_in: site_issues + handled_by: safety_officer + stages: [open, in_hand, closed] + done_when: [closed] + success: [closed] + # A site manager can take an issue in hand on the spot; only the + # safety officer closes it. + moves_by: { in_hand: [safety_officer, site_managers] } + watched_by: [project_managers] + max_fields: 3 + collects: + - { name: kind, type: select, options: [Hazard, Near miss, Wrong materials, Other] } + - { name: where } + - { name: what_happened, type: textarea } + +# No model that writes words ever sees these. +personas: + - { id: housing-board-damp, need: survey-enquiry, text: "I chair the board of a housing cooperative from the seventies. Two ground-floor flats have damp in the walls and we don't know if it's drainage or the facade. We need someone to tell us." } + - { id: buyer-before-purchase, need: survey-enquiry, text: "We are about to buy an old timber house. Before we sign we want a professional to go through it and tell us what is wrong with it." } + - { id: attic-conversion, need: build-enquiry, text: "We want to turn our attic into two bedrooms and a bathroom. We need drawings and someone who handles the application to the municipality." } + - { id: shop-to-flat, need: build-enquiry, text: "I own a former shop on the ground floor and want to make it a flat. I have no idea what is allowed. Can you sort that out and give me a price?" } + - { id: leaking-bathroom, need: warranty-claim, text: "You redid our bathroom two years ago. Now water is coming through the ceiling of the flat below. I want you to come and fix it." } + - { id: cracked-render, need: warranty-claim, text: "The render you put on our gable wall last summer has cracked right across. That can't be right after one winter." } + - { id: noisy-mornings, need: neighbour-complaint, text: "I live next door to the block you are working on. You start drilling at half past six every morning and I have a baby. Who do I talk to?" } + - { id: blocked-driveway, need: neighbour-complaint, text: "Your skip and a van have been parked across the entrance to our shop's loading bay for three days. We can't get deliveries." } + - { id: electrician-firm, need: subcontractor-application, text: "We are a four-person electrical firm, fully certified. We would like to get on your list so you call us when you need electricians." } + - { id: scaffolder, need: subcontractor-application, text: "I run a scaffolding company. How do I get to do jobs for you?" } + - { id: carpenter-jobseeker, need: job-application, text: "I'm a certified carpenter with eight years on rehabilitation jobs. Are you hiring?" } + - { id: apprentice, need: job-application, text: "I finish vocational school this spring and need an apprenticeship as a bricklayer." } + - { id: extra-window, need: change-request, text: "You are in the middle of our attic conversion. We have decided we want a roof window over the stairs as well. What would that cost?" } + - { id: pm-took-a-call, need: log-phone-enquiry, text: "A woman from a housing board in Storgata just rang about water in their basement. I'm in the van. I need to note it before I forget her number." } + - { id: pm-needs-plumber, need: post-job-request, text: "The bathroom job in Kirkeveien needs a plumber for two weeks from the first of next month. I want prices from the ones we have approved." } + - { id: plumber-bidding, need: submit-bid, text: "We are an approved plumbing firm. I saw you need someone in Kirkeveien next month. We can do it, I have a price." } + - { id: loose-railing, need: report-site-issue, text: "The railing on the third level of the scaffold on the north side is loose. Nobody got hurt but someone will." } + - { id: wrong-membrane, need: report-site-issue, text: "The bathroom membrane delivered today is not the one in the specification. We stopped work on it." } + - { id: out-of-battens, need: order-materials, text: "We have enough roof battens for today and maybe half of tomorrow. Need another two hundred metres on site by Thursday." } + - { id: weekly-round, need: safety-round-finding, text: "On my Friday round I found the first-aid box on level two empty and an extension lead run through a puddle." } diff --git a/plans/contractor.yaml b/plans/contractor.yaml new file mode 100644 index 0000000..ca2ccc3 --- /dev/null +++ b/plans/contractor.yaml @@ -0,0 +1,122 @@ +# The contractor from intakes/contractor.yaml as a plan: the needs spec +# plus what each form collects. Everything structural in the generated +# site comes from this file; a model only writes the words. +business: Nordvik Bygg +language: en +contact: { name: Ingrid Nordvik, email: ingrid@nordvikbygg.example } +about: > + Twelve people who survey old buildings, draw up rehabilitation plans, + handle the permit application, and do the building work with their + own carpenters plus subcontractors for electrical, plumbing and + scaffolding. + +needs: + - id: survey-enquiry + who: a housing board or private owner with a problem in a building + wants: someone qualified to come and look at it and say what it takes + lands_in: enquiries + handled_by: project_managers + stages: [open, surveyed, quoted, won, lost] + done_when: [won, lost] + success: [won] + max_fields: 4 + collects: + - { name: name } + - { name: phone, type: tel } + - { name: address } + - { name: problem, type: textarea } + - { name: access, optional: true } + + - id: build-enquiry + who: an owner who already knows what they want built or changed + wants: drawings, a permit application and a price + lands_in: enquiries + handled_by: project_managers + stages: [open, surveyed, quoted, won, lost] + done_when: [won, lost] + success: [won] + max_fields: 5 + collects: + - { name: name } + - { name: phone, type: tel } + - { name: address } + - { name: project, type: textarea } + - { name: timeframe, type: select, options: [This year, Next year, No fixed date] } + + - id: log-phone-enquiry + who: a project manager who just took a call or a text about a building + wants: the enquiry written down before it gets lost + enters_as: project_managers + lands_in: enquiries + handled_by: project_managers + stages: [open, surveyed, quoted, won, lost] + done_when: [won, lost] + success: [won] + max_fields: 4 + collects: + - { name: caller } + - { name: phone, type: tel } + - { name: address } + - { name: problem, type: textarea } + + - id: subcontractor-application + who: a trade firm doing electrical, plumbing or scaffolding work + wants: to be approved once, and then be called when a job needs them + lands_in: subcontractors + handled_by: project_managers + stages: [open, awaiting_papers, approved, declined] + done_when: [approved, declined] + success: [approved] + skippable: [awaiting_papers] + max_fields: 5 + collects: + - { name: company } + - { name: registration_number } + - { name: trade, type: select, options: [Electrical, Plumbing, Scaffolding, Sheet metal, Other] } + - { name: contact_email, type: email } + - { name: insurance_confirmed, type: select, options: ["Yes", Not yet] } + - { name: trade_approval, optional: true } + + - id: job-application + who: a carpenter, bricklayer or apprentice looking for work + wants: to be considered, without writing a letter + lands_in: applicants + handled_by: project_managers + stages: [open, interviewing, hired, declined] + done_when: [hired, declined] + success: [hired] + max_fields: 3 + collects: + - { name: name } + - { name: phone, type: tel } + - { name: trade, type: select, options: [Carpenter, Bricklayer, Apprentice, Other] } + - { name: experience, type: textarea, optional: true } + + - id: report-site-issue + who: anyone on site, ours or a subcontractor's, on a phone with gloves on + wants: to report a hazard, a near miss or wrong materials before they forget + enters_as: crew + lands_in: site_issues + handled_by: safety_officer + stages: [open, in_hand, closed] + done_when: [closed] + success: [closed] + max_fields: 3 + collects: + - { name: kind, type: select, options: [Hazard, Near miss, Wrong materials, Other] } + - { name: where } + - { name: what_happened, type: textarea } + +# Held out from every model that writes words: these are the personas +# from sectors/construction, which no generator has seen. +personas: + - { id: housing-board-damp, need: survey-enquiry, text: "I chair the board of a housing cooperative from the seventies. Two ground-floor flats have damp in the walls and we don't know if it's drainage or the facade. We need someone to tell us." } + - { id: buyer-before-purchase, need: survey-enquiry, text: "We are about to buy an old timber house. Before we sign we want a professional to go through it and tell us what is wrong with it." } + - { id: attic-conversion, need: build-enquiry, text: "We want to turn our attic into two bedrooms and a bathroom. We need drawings and someone who handles the application to the municipality." } + - { id: shop-to-flat, need: build-enquiry, text: "I own a former shop on the ground floor and want to make it a flat. I have no idea what is allowed. Can you sort that out and give me a price?" } + - { id: electrician-firm, need: subcontractor-application, text: "We are a four-person electrical firm, fully certified. We would like to get on your list so you call us when you need electricians." } + - { id: scaffolder, need: subcontractor-application, text: "I run a scaffolding company. How do I get to do jobs for you?" } + - { id: carpenter-jobseeker, need: job-application, text: "I'm a certified carpenter with eight years on rehabilitation jobs. Are you hiring?" } + - { id: apprentice, need: job-application, text: "I finish vocational school this spring and need an apprenticeship as a bricklayer." } + - { id: loose-railing, need: report-site-issue, text: "The railing on the third level of the scaffold on the north side is loose. Nobody got hurt but someone will." } + - { id: wrong-membrane, need: report-site-issue, text: "The bathroom membrane delivered today is not the one in the specification. We stopped work on it." } diff --git a/plans/fotballklubb.yaml b/plans/fotballklubb.yaml new file mode 100644 index 0000000..6f55263 --- /dev/null +++ b/plans/fotballklubb.yaml @@ -0,0 +1,119 @@ +# Fotballgruppa i et idrettslag, på norsk: to dører på forsiden +# (foresatte, og de som vil bidra), roller som overlapper på én +# innmelding (treneren godkjenner, kassereren ser betalingen), og en +# intern melding fra trenerne til styret. +business: Lia IL Fotball +language: no +contact: { name: Kari Lien, email: kari@liail.example } +mail: { from: "Liail IL ", reply_to: kari@liail.example } +about: > + Fotballgruppa i et idrettslag på bygda. Rundt hundre og tjue unger + fra seks til seksten år, fordelt på lag etter årskull, drevet av + foreldre på fritida. + +doors: + - id: foresatte + who: en forelder eller foresatt til et barn som vil spille, eller allerede spiller + - id: bidra + who: noen som vil hjelpe til i klubben, eller en bedrift som vil støtte den + +needs: + - id: innmelding + who: en forelder som vil ha ungen med på et lag + wants: at barnet får plass på laget for sitt årskull + door: foresatte + lands_in: innmeldinger + handled_by: trenere + stages: [open, godkjent, betalt, trukket] + stages_text: { open: "mottatt", godkjent: "godkjent av treneren", betalt: "betalt", trukket: "trukket" } + mails: { open: submitter, godkjent: submitter, betalt: submitter } + done_when: [betalt, trukket] + success: [betalt] + # Treneren godkjenner plassen, kassereren ser at det er betalt. + moves_by: { betalt: [kasserer] } + watched_by: [kasserer] + max_fields: 5 + collects: + - { name: barnets_navn } + - { name: fodselsar, type: select, options: ["2010", "2011", "2012", "2013", "2014", "2015", "2016", "2017", "2018", "2019", "2020"] } + - { name: foresatt } + - { name: telefon, type: tel } + - { name: email, type: email } + - { name: spilt_for, optional: true } + + - id: avgiftssak + who: en forelder som sliter med treningsavgiften + wants: å dele den opp, eller slippe den + door: foresatte + lands_in: avgiftssaker + handled_by: styret + stages: [open, vurdert, innvilget, avslatt] + stages_text: { open: "mottatt", vurdert: "vurdert", innvilget: "innvilget", avslatt: "avslått" } + done_when: [innvilget, avslatt] + success: [innvilget] + max_fields: 3 + collects: + - { name: barnets_navn } + - { name: foresatt } + - { name: hva_gjelder_det, type: textarea } + - { name: telefon, type: tel, optional: true } + + - id: frivillig + who: en som vil hjelpe til, som trener, lagleder, sjåfør eller på dugnad + wants: å bli brukt der det trengs + door: bidra + lands_in: frivillige + handled_by: styret + stages: [open, kontaktet, plassert, avslatt] + stages_text: { open: "mottatt", kontaktet: "kontaktet", plassert: "satt opp", avslatt: "takket nei" } + done_when: [plassert, avslatt] + success: [plassert] + max_fields: 3 + collects: + - { name: navn } + - { name: telefon, type: tel } + - { name: hva_kan_du, type: select, options: [Trene et lag, Være lagleder, Kjøre til kamper, Dugnad, Annet] } + + - id: sponsor + who: en bedrift i bygda som vil støtte klubben + wants: en prat om hva de kan bidra med, og en avtale + door: bidra + lands_in: sponsorer + handled_by: styret + stages: [open, i_dialog, avtalt, avslatt] + stages_text: { open: "mottatt", i_dialog: "i dialog", avtalt: "avtalt", avslatt: "avslått" } + done_when: [avtalt, avslatt] + success: [avtalt] + max_fields: 3 + collects: + - { name: bedrift } + - { name: kontaktperson } + - { name: telefon, type: tel } + - { name: hva_tenker_dere, type: textarea, optional: true } + + - id: baneproblem + who: en trener som ser at noe på anlegget ikke er i orden + wants: at styret får vite det og ordner det + enters_as: trenere + lands_in: baneproblemer + handled_by: styret + stages: [open, tatt_tak_i, ordnet] + stages_text: { open: "mottatt", tatt_tak_i: "tatt tak i", ordnet: "ordnet" } + done_when: [ordnet] + success: [ordnet] + max_fields: 2 + collects: + - { name: hvor } + - { name: hva, type: textarea } + +# Holdt unna alle modeller som skriver ord. +personas: + - { id: ny-i-bygda, need: innmelding, text: "Vi flyttet hit i sommer og sønnen vår på ni har spilt fotball før. Hvordan får vi ham med på et lag her?" } + - { id: tvillinger, need: innmelding, text: "Jeg har tvillingjenter på sju som maser om å begynne. Er det plass, og hva koster det?" } + - { id: alenefar, need: avgiftssak, text: "Jeg er alene med tre unger og treningsavgiften for alle tre blir for mye på en gang. Går det an å dele den opp?" } + - { id: permittert, need: avgiftssak, text: "Jeg ble permittert i vinter. Kan dattera mi fortsette å spille selv om vi ikke klarer avgiften akkurat nå?" } + - { id: pensjonist, need: frivillig, text: "Jeg er pensjonert og spilte selv i mange år. Har dere bruk for en som kan ta de minste et par kvelder i uka?" } + - { id: stor-bil, need: frivillig, text: "Jeg kan ikke trene noen, men jeg har stor bil og kan kjøre til bortekamper." } + - { id: rorlegger, need: sponsor, text: "Jeg driver rørleggerfirma her i bygda og vil gjerne ha logoen vår på draktene til et lag. Hvem snakker jeg med?" } + - { id: kafeen, need: sponsor, text: "Vi har kafé i sentrum og kan bidra med noe til cupen i mai, kanskje vafler eller litt penger." } + - { id: lyset-slukker, need: baneproblem, text: "Lyset på kunstgresset slår seg av halv åtte og vi trener til åtte. Ungene løper i mørket." } diff --git a/plans/hire.yaml b/plans/hire.yaml new file mode 100644 index 0000000..9373d7e --- /dev/null +++ b/plans/hire.yaml @@ -0,0 +1,94 @@ +# A machine hire yard: two doors (hiring, and an account for firms), +# one aggregate walked by two roles (the counter reserves, the yard +# hands out and takes back), and the yard's own damage report. +business: Fjellstad Hire +language: en +contact: { name: Ola Fjellstad, email: ola@fjellstadhire.example } +about: > + Machine hire: mini diggers, lifts, compactors, generators. A counter, + a yard that delivers, collects and fixes, and an office. + +doors: + - id: hiring + who: someone who needs a machine, or has one of ours that has stopped + - id: firms + who: a firm that hires often and wants an account with us + +needs: + - id: hire-request + who: a private person or a firm who needs a machine for a while + wants: the right machine, from a date, for as long as they need it + door: hiring + lands_in: hires + handled_by: counter + stages: [open, reserved, out, returned, cancelled] + done_when: [returned, cancelled] + success: [returned] + # The counter reserves; the yard hands it out and takes it back. + moves_by: { out: [yard], returned: [yard] } + max_fields: 5 + collects: + - { name: name } + - { name: phone, type: tel } + - { name: what, type: textarea } + - { name: from, type: date } + - { name: until, type: date } + - { name: deliver_to, optional: true } + + - id: breakdown + who: someone whose hired machine has stopped working + wants: it running again, or swapped, today + door: hiring + lands_in: breakdowns + handled_by: yard + stages: [open, in_hand, resolved] + done_when: [resolved] + success: [resolved] + max_fields: 3 + collects: + - { name: phone, type: tel } + - { name: machine } + - { name: what_happened, type: textarea } + - { name: where, optional: true } + + - id: account-request + who: a firm that hires often + wants: to send anyone to collect, and get one invoice a month + door: firms + lands_in: accounts + handled_by: office + stages: [open, checked, approved, declined] + done_when: [approved, declined] + success: [approved] + max_fields: 4 + collects: + - { name: company } + - { name: registration_number } + - { name: contact } + - { name: phone, type: tel } + - { name: invoice_email, type: email, optional: true } + + - id: damage-on-return + who: someone in the yard who finds damage when a machine comes back + wants: it written down before the customer has left + enters_as: yard + lands_in: damage_reports + handled_by: office + stages: [open, invoiced, written_off] + done_when: [invoiced, written_off] + success: [invoiced] + max_fields: 3 + collects: + - { name: machine } + - { name: hire_or_customer } + - { name: damage, type: textarea } + +# Held out from every model that writes words. +personas: + - { id: garden-wall, need: hire-request, text: "I'm building a stone wall in the garden and need a small digger for a weekend. I've never driven one." } + - { id: roofer-lift, need: hire-request, text: "We're a roofing crew and need a scissor lift on site in Dalen from Monday for about two weeks." } + - { id: stuck-compactor, need: breakdown, text: "The plate compactor I picked up this morning won't start. I've got a crew standing around." } + - { id: lift-error, need: breakdown, text: "The lift you delivered keeps showing an error and stops halfway up. It's on our site at the school." } + - { id: landscaper-account, need: account-request, text: "We're a landscaping firm hiring from you almost every month and I'm tired of paying by card each time. Can we get invoiced monthly?" } + - { id: builder-account, need: account-request, text: "Small building firm, six people. We'd like to be able to send whoever is nearest to pick things up and have it on our account." } + - { id: yard-window, need: damage-on-return, text: "The mini digger from the Berg job came back with a smashed rear window and hydraulic oil everywhere." } diff --git a/plans/physio.yaml b/plans/physio.yaml new file mode 100644 index 0000000..79e36a8 --- /dev/null +++ b/plans/physio.yaml @@ -0,0 +1,113 @@ +# A physiotherapy clinic: two doors on the front, patients and the +# people who send them; the reception handles everything, therapists +# write the report back and notice who did not turn up. +business: Bakken Fysioterapi +language: en +contact: { name: Marit Bakken, email: marit@bakkenfysio.example } +about: > + Six physiotherapists and a receptionist. People come with pain, + injuries and after operations, on their own or sent by a doctor, + and we treat them over a course of sessions. + +doors: + - id: patients + who: someone with pain or an injury, or a patient who already has a time here + - id: referrers + who: a doctor, a hospital ward or an insurer sending someone to us + +needs: + - id: new-patient + who: someone with something that hurts + wants: a first appointment with a therapist who suits them + door: patients + lands_in: requests + handled_by: reception + stages: [open, offered, booked, declined] + done_when: [booked, declined] + success: [booked] + max_fields: 4 + collects: + - { name: name } + - { name: phone, type: tel } + - { name: what_hurts, type: textarea } + - { name: when, type: select, options: [Mornings, Afternoons, Any time] } + - { name: sent_by_doctor, type: select, options: ["Yes", "No"], optional: true } + + - id: move-or-cancel + who: a patient who already has a time and cannot come + wants: the time moved, or the slot freed + door: patients + lands_in: cancellations + handled_by: reception + stages: [open, rebooked, released] + done_when: [rebooked, released] + success: [rebooked] + max_fields: 3 + collects: + - { name: name } + - { name: phone, type: tel } + - { name: appointment_day, type: date } + - { name: new_time_wish, optional: true } + + - id: referral + who: a GP or a hospital ward sending a patient + wants: the patient seen, and a report back when the course ends + door: referrers + lands_in: referrals + handled_by: reception + stages: [open, accepted, booked, reported, returned] + done_when: [reported, returned] + success: [reported] + # The therapist who treated them writes the report; they see the + # referral from the start. + moves_by: { reported: [therapists] } + watched_by: [therapists] + max_fields: 5 + collects: + - { name: patient_name } + - { name: patient_phone, type: tel } + - { name: referrer } + - { name: what_for, type: textarea } + - { name: urgency, type: select, options: [This week, Within a month, Routine] } + + - id: insurance-approval + who: the receptionist, before a course that an insurer has to approve + wants: the request sent and its answer kept where the patient's case is + enters_as: reception + lands_in: approvals + handled_by: reception + stages: [open, sent, approved, refused] + done_when: [approved, refused] + success: [approved] + max_fields: 3 + collects: + - { name: patient_name } + - { name: insurer } + - { name: sessions } + - { name: case_number, optional: true } + + - id: no-show + who: a therapist whose patient did not turn up and did not call + wants: the reception to ring them and get them back in + enters_as: therapists + lands_in: no_shows + handled_by: reception + stages: [open, contacted, rebooked, closed] + done_when: [rebooked, closed] + success: [rebooked] + max_fields: 2 + collects: + - { name: patient_name } + - { name: appointment_day, type: date } + - { name: note, optional: true } + +# Held out from every model that writes words. +personas: + - { id: runner-knee, need: new-patient, text: "I've been running for years and the outside of my right knee has started hurting after about five kilometres. I'd like someone to have a look, preferably in the mornings." } + - { id: post-surgery-mother, need: new-patient, text: "My mother had a hip replacement three weeks ago and the hospital said she needs training. She can't do much on her own and I'll drive her." } + - { id: sick-child, need: move-or-cancel, text: "I have a session on Thursday at ten but my daughter has a fever and I can't come. Can I move it?" } + - { id: sent-abroad, need: move-or-cancel, text: "Work is sending me abroad next week so my appointment on the 3rd is off. Just free the slot, I'll be in touch when I'm back." } + - { id: gp-shoulder, need: referral, text: "I'm a GP. A patient of mine has a frozen shoulder that isn't improving with what I can offer. I'd like her to start with you and hear how it goes." } + - { id: hospital-discharge, need: referral, text: "Orthopaedic ward here. We're discharging a man after an ankle fracture on Friday and need follow-up rehab arranged near where he lives. He'll need a report for his sick leave." } + - { id: reception-approval, need: insurance-approval, text: "The patient in room two has a policy that covers twelve sessions if the insurer signs off first. I need to send that off and keep track of the answer." } + - { id: therapist-no-show, need: no-show, text: "My two o'clock didn't turn up and didn't call. Second time. Someone needs to ring him." } diff --git a/plans/tomtervel.yaml b/plans/tomtervel.yaml new file mode 100644 index 0000000..bea3f51 --- /dev/null +++ b/plans/tomtervel.yaml @@ -0,0 +1,331 @@ +# Tomter Vel, en velforening: fire dører på forsiden (medlemskap, +# melde fra, bidra, naboer imellom) og høringsinnspill rett på +# forsiden, siden den som har en mening om en sak ikke ser seg selv +# som frivillig. Komiteene er gruppene: en søknad om plass i en +# komité velger blant de samme sju som har hver sin pult. Trafikk, +# anlegg og oppslagstavla kan alle lese; kontaktopplysninger vises +# ikke der. Nabohjelp begge veier. Oppslagstavla for deling av utstyr +# står ute med en gang: en nabo som kan låne ut, eller vil låne, sier +# fra rett fra oppslaget, og den som la det ut hører fra dem. Køen er +# innboksen til den som la ut; siden holder ikke rede på hvem som ble +# valgt. Redaktøren, de som står som ansvarlige, tar ned det som står +# igjen. +business: Tomter Vel +language: no +# Styret svarer for sidene som gruppe: ingen konto for innboksen, og +# hvert styremedlem kan foreslå endringer på dem. +contact: { name: Styret i Tomter Vel, email: post@tomtervel.no, group: styret } +# Portal holder gruppene selv: en invitasjon er en plass som venter til +# personen logger inn, og en person er telefonnummeret sitt først. Med +# Vipps-innlogging kan alle logge inn med en gang; til da lager Kanidm +# kontoen ved siden av. +people: memberships +# Avsenderen for all e-post fra siden (gdo på kasse leverer); det +# kasses postfix kan sende for i dag. +mail: { from: "Tomter Vel ", reply_to: post@tomtervel.no } +about: > + Velforeningen for Tomter, stiftet i 1947. Sju komiteer drevet på + fritida: arrangementer, elvestien, lekeplassene, høringssvar, + miljøgata, pendlerforhold og trafikk. 157 husstander er medlemmer. + +# Komiteene, som gruppene heter, med navnet folk kjenner dem under. +groups: + - { id: arrangementer, label: Arrangementer } + - { id: elvesti, label: Elvesti } + - { id: horingsinstans, label: Høringsinstans } + - { id: lekeplasser, label: Leke- og møteplasser } + - { id: miljogate, label: Miljøgate } + - { id: pendlerforhold, label: Pendlerforhold } + - { id: trafikk, label: Trafikkforhold } + +# Styret kan invitere inn i alle grupper som har en pult; hver gruppe kan +# invitere inn i seg selv. Portal oppretter kontoen og en engangslenke. +invites_by: [styret] + +# Forsiden åpner beskjedent: to trekk, én rolle hver, ingen knapp, +# før tilbudene lenger ned på samme side. De to velet er til for: en +# husstand som blir medlem, og en nabo som trenger en hånd. Begge har +# noe å gjøre etterpå: medlemmet melder seg ut selv, den som fikk +# hjelp sier selv fra at det er ordnet. +does: + - id: medlem + for: bli-medlem + text: > + Velet taler for Tomter overfor kommunen, holder lekeplassene og + stien i stand og samler naboene på 17. mai og dugnad. 157 + husstander er med. Du melder deg inn her, og ut igjen selv når + du vil. + - id: nabo + for: trenger-hjelp + text: > + Trenger du en hånd med snømåking, handling eller skyss, kobler + nabohjelpsgruppa deg med en nabo som kan. Du sier selv fra når + det er ordnet. + +doors: + - id: medlem + who: en husstand i Tomter som vil bli medlem, betale kontingent, eller melde flytting, ny e-post eller utmelding + - id: melde-fra + who: noen som har sett noe farlig i trafikken, eller noe ødelagt på lekeplassen eller stien + - id: bidra + who: noen som vil stille opp på 17. mai, dugnad eller et arrangement, eller sitte i en komité + - id: naboer + who: en nabo som trenger en hånd med snømåking, handling eller skyss, kan gi en, eller har noe å låne ut + + +needs: + - id: bli-medlem + who: en husstand i Tomter + wants: å bli medlem og få betalt kontingenten + lands_in: husstander + door: medlem + handled_by: kasserer + stages: [open, betalt, utmeldt] + stages_text: { open: "mottatt", betalt: "betalt", utmeldt: "meldt ut" } + done_when: [betalt, utmeldt] + success: [betalt] + # Medlemmet melder seg ut selv, gjennom sin egen lenke. + moves_by: { utmeldt: [submitter, kasserer] } + mails: { open: submitter, betalt: submitter } + # Betalt kontingent er medlemskapet: husstanden får konto og plass + # blant medlemmene i samme trekk, og det som er medlemmenes eget + # (komitéplass) ligger bak innloggingen. + grants: { betalt: medlemmer } + max_fields: 3 + collects: + - { name: name } + - { name: adresse } + - { name: email, type: email } + - { name: telefon, type: tel, optional: true } + + - id: endre-medlemskap + who: et medlem som har flyttet, byttet e-post eller vil melde seg ut + wants: at velet har riktige opplysninger om dem + lands_in: medlemsendringer + door: medlem + handled_by: kasserer + stages: [open, oppdatert] + stages_text: { open: "mottatt", oppdatert: "oppdatert" } + done_when: [oppdatert] + success: [oppdatert] + max_fields: 3 + collects: + - { name: navn } + - { name: adresse } + - { name: hva_er_nytt, type: textarea } + + - id: trafikkfare + who: en som har sett et farlig sted i trafikken + wants: at velet tar det opp med kommunen eller den som eier veien + lands_in: trafikkmeldinger + door: melde-fra + handled_by: trafikk + stages: [open, tatt_opp, ordnet, avvist] + stages_text: { open: "mottatt", tatt_opp: "tatt opp med kommunen", ordnet: "ordnet", avvist: "avvist" } + done_when: [ordnet, avvist] + success: [ordnet] + # Alle kan se hva som er meldt og hvor det står; e-posten vises ikke. + watched_by: [styret, public] + max_fields: 2 + collects: + - { name: hvor } + - { name: hva, type: textarea } + - { name: epost, type: email, optional: true } + + - id: anleggsfeil + who: en som ser at noe på lekeplassen eller stien er ødelagt + wants: at det blir fikset før noen skader seg + lands_in: anleggsfeil + door: melde-fra + handled_by: lekeplasser + stages: [open, tatt_tak_i, utbedret] + stages_text: { open: "mottatt", tatt_tak_i: "tatt tak i", utbedret: "utbedret" } + done_when: [utbedret] + success: [utbedret] + watched_by: [public] + max_fields: 2 + collects: + - { name: hvor, type: select, options: [Lekeplassen, Elvestien, Skøytebanen, Et annet sted] } + - { name: hva, type: textarea } + + # Rett på forsiden: den som har en mening om en sak er ikke frivillig. + - id: horingsinnspill + who: en beboer som har en mening om en sak kommunen har på høring + wants: at velet tar det med i sitt svar + lands_in: innspill + handled_by: horingsinstans + watched_by: [styret] + stages: [open, tatt_med, ikke_tatt_med] + stages_text: { open: "mottatt", tatt_med: "tatt med i høringssvaret", ikke_tatt_med: "ikke tatt med" } + done_when: [tatt_med, ikke_tatt_med] + success: [tatt_med] + max_fields: 3 + collects: + - { name: navn } + - { name: sak } + - { name: innspill, type: textarea } + + - id: hjelpe-til + who: en som vil hjelpe til på et arrangement eller en dugnad + wants: å bli satt opp på noe som passer dem + lands_in: frivillige + door: bidra + handled_by: arrangementer + stages: [open, kontaktet, satt_opp, takket_nei] + stages_text: { open: "mottatt", kontaktet: "kontaktet", satt_opp: "satt opp", takket_nei: "takket nei" } + done_when: [satt_opp, takket_nei] + success: [satt_opp] + max_fields: 3 + collects: + - { name: navn } + - { name: telefon, type: tel } + - { name: hva, type: select, multiple: true, options: [17. mai, Juletregranstenning, Skøytedag, Dugnad på elvestien, Det som trengs] } + + # Komitéplass er for medlemmer: døra viser veien, skjemaet ligger på + # medlemmenes side bak innloggingen. + - id: komite + who: et medlem som vil sitte i en komité + wants: en plass i den komiteen som driver med det de bryr seg om + lands_in: komitesoknader + door: bidra + enters_as: medlemmer + handled_by: styret + stages: [open, plassert, avslatt] + stages_text: { open: "mottatt", plassert: "plassert i en komité", avslatt: "avslått" } + done_when: [plassert, avslatt] + success: [plassert] + max_fields: 3 + collects: + - { name: navn } + - { name: epost, type: email } + - { name: komite, type: select, multiple: true, options_from: groups } + + # Nabohjelp: den som trenger en hånd, og den som kan gi en. En + # nabohjelpsgruppe kobler dem; den som fikk hjelp sier fra selv at + # det er ordnet. + - id: trenger-hjelp + who: en beboer som ikke får til noe selv for tiden, som snømåking, handling eller en skyss + wants: at en nabo stiller opp + lands_in: hjelpebehov + # Et behov ingen har tatt tak i på en uke går opp til styret: samme + # sak, samme knapper, på styrets pult i tillegg. + escalate: { open: { days: 7, to: styret } } + door: naboer + handled_by: nabohjelp + stages: [open, formidlet, ordnet, ikke_lost] + stages_text: { open: "mottatt", open_escalated: "hos styret, siden ingen tok den på en uke", formidlet: "formidlet til en nabo", ordnet: "ordnet", ikke_lost: "ikke løst" } + done_when: [ordnet, ikke_lost] + success: [ordnet] + moves_by: { ordnet: [submitter, nabohjelp] } + mails: { open: submitter, formidlet: submitter, ordnet: submitter } + max_fields: 4 + collects: + - { name: navn } + - { name: email, type: email } + - { name: adresse } + - { name: hva, type: textarea } + - { name: telefon, type: tel, optional: true } + + - id: kan-hjelpe + who: en nabo som kan stille opp når noen trenger det + wants: å bli spurt når det passer + lands_in: hjelpere + door: naboer + handled_by: nabohjelp + stages: [open, registrert, trukket] + stages_text: { open: "mottatt", registrert: "registrert som hjelper", trukket: "trukket" } + done_when: [registrert, trukket] + success: [registrert] + moves_by: { trukket: [submitter, nabohjelp] } + max_fields: 4 + collects: + - { name: navn } + - { name: email, type: email } + - { name: telefon, type: tel } + - { name: kan, type: select, multiple: true, options: [Snømåking, Handling, Kjøring, Småfiks i huset, En prat og litt selskap, Det som trengs] } + + # Oppslagstavla: ut med en gang, styret hører om det. Svar går + # rett til den som la ut; køen er innboksen deres. Tas ned av den + # som la ut, av redaktøren, eller av klokka etter en måned. + - id: dele-utstyr + who: en nabo som har noe å låne ut, eller trenger å låne noe + wants: et oppslag naboene ser med en gang + lands_in: oppslag + door: naboer + handled_by: redaktor + stages: [open, tatt_ned] + stages_text: { open: "ute", tatt_ned: "tatt ned" } + done_when: [tatt_ned] + success: [tatt_ned] + moves_by: { tatt_ned: [submitter, redaktor] } + mails: { open: post@tomtervel.no } + deadlines: { open: { days: 30, to: tatt_ned } } + # Tavla er offentlig: det er den naboene leser; navn og e-post + # vises ikke der. + watched_by: [public] + public_states: [open] + max_fields: 4 + collects: + - { name: navn, private: true } + - { name: email, type: email } + - { name: har_eller_trenger, type: select, options: [Jeg har noe å låne ut, Jeg trenger å låne noe] } + - { name: hva, type: textarea } + + # Svaret på et oppslag: fra oppslagets egen rad på tavla, med + # e-post eller innlogget, rett til den som la ut. Redaktøren ser + # svarene og merker dem lest. + - id: melde-seg + who: en nabo som kan låne ut, eller vil låne, det et oppslag gjelder + wants: at den som la ut oppslaget får vite det + about: dele-utstyr + lands_in: svar + handled_by: redaktor + stages: [open, lest] + stages_text: { open: "sendt", lest: "lest" } + done_when: [lest] + success: [lest] + mails: { open: owner } + max_fields: 2 + collects: + - { name: email, type: email } + - { name: hilsen, type: textarea, optional: true } + + - id: utlegg + who: en i en komité som har lagt ut for noe + wants: pengene tilbake + enters_as: komiteer + lands_in: utlegg + handled_by: kasserer + stages: [open, godkjent, utbetalt, avvist] + stages_text: { open: "mottatt", godkjent: "godkjent", utbetalt: "utbetalt", avvist: "avvist" } + done_when: [utbetalt, avvist] + success: [utbetalt] + max_fields: 3 + collects: + - { name: navn } + - { name: belop } + - { name: hva, type: textarea } + +# Holdt unna alle modeller som skriver ord. +personas: + - { id: nyinnflyttet, need: bli-medlem, text: "Vi flyttet til Tomter i mars og har hørt at det finnes et vel. Hvordan blir vi med, og hva koster det?" } + - { id: glemte-a-betale, need: bli-medlem, text: "Jeg har vært medlem før men fikk ikke betalt i fjor. Kan jeg bare vippse, og til hvem?" } + - { id: ny-epost, need: endre-medlemskap, text: "Vi har byttet e-postadresse og får ikke lenger noe fra dere." } + - { id: flytter, need: endre-medlemskap, text: "Vi selger huset og flytter til Ski i august, så vi må ut av medlemslista." } + - { id: skolevei, need: trafikkfare, text: "Bilene kjører altfor fort i svingen ved skolen om morgenen, og det er ikke fortau der ungene går." } + - { id: busslomme, need: trafikkfare, text: "Det er umulig å se om det kommer noen når man skal ut fra busslomma ved butikken, hekken har blitt kjempehøy." } + - { id: huske, need: anleggsfeil, text: "Huska på lekeplassen henger i én kjetting. Sønnen min dro i den i går og den holdt så vidt." } + - { id: bru, need: anleggsfeil, text: "Planken på den lille brua på stien langs elva er råtten, jeg gikk nesten gjennom." } + - { id: blokker, need: horingsinnspill, text: "Kommunen skal bygge blokker på jordet bak stasjonen, og jeg synes det er alt for høyt. Kan velet si ifra?" } + - { id: togtider, need: horingsinnspill, text: "Ruter vil kutte det tidlige toget. Jeg pendler til Oslo hver dag og vil at velet skriver til dem om det." } + - { id: vafler, need: hjelpe-til, text: "Jeg kan steke vafler på 17. mai-frokosten hvis dere trenger folk." } + - { id: motorsag, need: hjelpe-til, text: "Har motorsag og ledig helg, kan ta trær langs elvestien hvis det er en dugnad." } + - { id: pendler, need: komite, text: "Jeg pendler og er lei av at ingen snakker med Ruter. Jeg vil gjerne være med i gjengen som jobber med det." } + - { id: planlegger, need: komite, text: "Jeg er arealplanlegger av yrke og kunne bidratt når kommunen sender saker på høring." } + - { id: tilhenger, need: dele-utstyr, text: "Jeg har en tilhenger som står ubrukt mesteparten av året, naboer kan gjerne låne den for en hundrelapp." } + - { id: stige, need: dele-utstyr, text: "Trenger en lang stige en dag i helga for å rense takrenna, er det noen i nærheten som har?" } + - { id: snomaking, need: trenger-hjelp, text: "Jeg er 84 og klarer ikke å måke innkjørselen lenger. Er det noen som kan hjelpe meg når det snør?" } + - { id: nyoperert, need: trenger-hjelp, text: "Jeg er nyoperert i hofta og kommer meg ikke til butikken på noen uker. Kunne noen handlet for meg?" } + - { id: hjemmekontor, need: kan-hjelpe, text: "Jeg jobber hjemmefra og kan godt måke eller handle for noen som ikke får det til selv." } + - { id: pensjonert-elektriker, need: kan-hjelpe, text: "Pensjonert elektriker, har bil og god tid. Si fra om noen trenger skyss eller en hånd med småting." } + - { id: kvittering, need: utlegg, text: "Jeg kjøpte pølser og kakao til skøytedagen for 1 840 kroner, her er kvitteringa, vil ha det igjen." } diff --git a/plans/uhhm.yaml b/plans/uhhm.yaml new file mode 100644 index 0000000..d02c9c5 --- /dev/null +++ b/plans/uhhm.yaml @@ -0,0 +1,233 @@ +# UHHM, uhhm.no: the studio that builds portal and carries redoal. +# Rebuilt from the live site (uhhm/questions) on 2026-09-23. The five +# buckets the site already holds keep their live state and event +# names (`events:`), so every record on the owners' desk stays where +# it is. Two are new: a business trying the trainer on its own +# workflow (`trials`, approved by a person before anything is built), +# and what reaches UHHM as the custodian of the redoal network +# (`custody_requests`, a desk of its own). +business: UHHM +language: en +contact: { name: Bendik Aagaard Lynghaug, email: bl@uhhm.no } +# Every mail the site sends comes from here (gdo on ergo delivers). +mail: { from: "UHHM ", reply_to: bl@uhhm.no } +about: > + UHHM builds software around a clear argument: infrastructure, tools + and products for organizations that can say what problem they have. + It also builds and carries the redoal network - the gesture math, + the relay, the first node, and the portal this site runs on - and + answers for what that network keeps. + +groups: + - { id: owners, label: Owners } + # The people who answer for the redoal network: they host its first + # node and relay, keep what is left there, and decide what stays. + - { id: custodians, label: redoal custodians, invited_by: [owners] } +# Owners onboard into every desk; a custodian can vouch for another. +invites_by: [owners] + +# The front door opens modestly: one or two features, one role each, +# and no button, before the offers further down the same page. The +# two roles UHHM exists for: an organization with a problem, and the +# people on the redoal network it keeps. +does: + - id: software + for: commission + text: > + Software built around a clear argument. Infrastructure, tools + and products for organizations that can say what problem they + have - and a working draft of your own workflow as a site, + before you pay for anything. + - id: redoal + for: custody + text: > + The redoal network, built and carried here: the gesture math, + the relay, the first node, and the portal this site runs on. + UHHM answers for what that network keeps. + +# Things to look at before asking for anything. +shows: + - id: built + text: A few things already out in the world. + resource: + public: true + source: { kind: gitea_starred, username: bl } + jq: > + .[] | {name: .name, description: .description, + url: (if .website != null and .website != "" then .website + elif .private then null + else .html_url end)} + - id: voices + text: Live from the redoal relay - what has been left at places so far. + resource: + public: true + source: { kind: url, url: https://relay.redoal.com/pulse } + jq: > + { "name": (.voices_echoed | tostring), + "description": "voices echoed so far" } + empty: The first voice has not echoed yet. + +needs: + - id: commission + who: an organization with a problem it wants software to solve + wants: a considered reply to a brief, from people who read the whole thing + lands_in: projects + handled_by: owners + stages: [open, accepted, declined] + done_when: [accepted, declined] + success: [accepted] + events: { open: submitted } + stages_text: { open: "received", accepted: "taken on", declined: "not a fit for us" } + # The client plays too: they can withdraw the brief themselves. + moves_by: { declined: [submitter, owners] } + mails: { open: submitter, accepted: submitter, declined: submitter } + max_fields: 4 + collects: + - { name: name } + - { name: email, type: email } + - { name: project, type: textarea } + - { name: organization } + - { name: org_contact, optional: true } + + # The client feature: the trainer tried on a real business. A person + # approves each intake before anything is built from it. + - id: trial + who: someone who runs a business where requests, approvals and handovers get lost, and wants to see it as a site before committing to anything + wants: a first draft of a site for their own roles, built from their own words and approved by a person first + lands_in: trials + handled_by: owners + stages: [open, approved, previewed, declined] + done_when: [previewed, declined] + success: [previewed] + stages_text: { open: "received", approved: "approved for a draft", previewed: "a preview is ready", declined: "not a fit for us" } + mails: { open: submitter, approved: submitter, previewed: submitter, declined: submitter } + attended_by: > + approved -> previewed: the n8n workflow that builds the draft + site from an approved intake (docker, rauthy, portal content) + and sends the preview link. Until it exists, owners run + portal-trainer by hand and press the button. + max_fields: 6 + collects: + - { name: business } + - { name: email, type: email } + - { name: what_we_do, type: textarea } + - { name: who_comes_to_us, type: textarea } + - { name: what_we_keep_track_of, type: textarea } + - { name: who_is_involved, type: textarea } + - { name: what_slows_us_down, type: textarea, optional: true } + + - id: collaborate + who: someone who wants to work with us on what we build + wants: to be read as a person, and invited in if there is a fit + lands_in: applicants + handled_by: owners + stages: [open, invited, active, departed, declined] + done_when: [departed, declined] + success: [departed] + events: { open: applied, active: onboarded } + # Declined before starting; after that a person departs. + turned_down_from: [open, invited] + stages_text: { open: "received", invited: "invited in", active: "working with us", departed: "moved on", declined: "not a fit for us" } + max_fields: 3 + collects: + - { name: name } + - { name: email, type: email } + - { name: interest, type: textarea } + - { name: note, type: textarea, optional: true } + + - id: subscribe + who: someone who wants to hear from us when there is something to say + wants: a note now and then, and an easy way out + lands_in: subscribers + handled_by: owners + stages: [open, unsubscribed] + done_when: [unsubscribed] + events: { open: subscribed } + stages_text: { open: "on the list", unsubscribed: "off the list" } + moves_by: { unsubscribed: [submitter, owners] } + max_fields: 2 + collects: + - { name: name } + - { name: email, type: email } + + # This site proposes its own changes through itself. + - id: propose + who: a visitor who sees how a page on this site should change + wants: the change read by a person and, if approved, made + lands_in: development_proposals + handled_by: owners + stages: [open, approved, rejected] + done_when: [approved, rejected] + success: [approved] + events: { open: proposed } + stages_text: { open: "received", approved: "approved", rejected: "turned down" } + # The site changes in the open: anyone can read what was approved, + # without who proposed it. + watched_by: [public] + public_states: [approved] + max_fields: 3 + collects: + - { name: submitted_by, private: true } + - { name: email, type: email, optional: true } + - { name: page, optional: true } + - { name: rationale, type: textarea } + - { name: proposed_yaml, type: textarea } + + # The custodian role, on the front door: UHHM answers for the redoal + # network it hosts. Owners see these too, and move nothing. + - id: custody + who: someone who runs, builds on, or is affected by the redoal network and needs the people who keep its first node + wants: an answer from a custodian, and action where the network keeps something it should not + lands_in: custody_requests + handled_by: custodians + watched_by: [owners] + stages: [open, in_dialogue, resolved, declined] + done_when: [resolved, declined] + success: [resolved] + stages_text: { open: "received", in_dialogue: "in dialogue with a custodian", resolved: "resolved", declined: "closed without action" } + # The person affected says when it is resolved, not only the custodian. + moves_by: { resolved: [submitter, custodians] } + mails: { open: submitter, resolved: submitter, declined: submitter } + max_fields: 4 + collects: + - { name: name } + - { name: email, type: email } + - { name: about, type: select, options: [Running a node of my own, Building on the network, Something the network keeps about me, Something else] } + - { name: what, type: textarea } + + # Internal: a relationship worth tracking before it is a project. + - id: prospect + who: an owner who has met an organization worth following up + wants: it written down and walked through its states from the desk + enters_as: owners + lands_in: organizations + handled_by: owners + stages: [prospect, client, past_client] + done_when: [past_client] + skippable: [client] + stages_text: { prospect: "a prospect", client: "a client", past_client: "a past client" } + events: { prospect: identified, client: became_client, past_client: churned } + max_fields: 2 + collects: + - { name: name } + - { name: contact } + - { name: note, type: textarea, optional: true } + +# Held out from every model that writes words. The first eight are the +# 2026-09-21 set (runs/uhhm-2026-09-21); the rest are for what is new. +personas: + - { id: architecture-firm, need: commission, text: "We run a small architecture firm and our project files are scattered across three cloud drives. We want to pay someone to set up proper storage and backups we control ourselves." } + - { id: climbing-gym-founder, need: commission, text: "I have an idea for a booking app for climbing gyms and I'm looking for a team that can build the first version with me." } + - { id: municipality-buyer, need: commission, text: "Our municipality needs an internal tool for tracking building permits. We are looking for a vendor who can scope it and deliver it." } + - { id: exhibition-visitor, need: subscribe, text: "I saw your drawing network at an exhibition and liked it. I don't want to get involved, I'd just like to know when there is news." } + - { id: tech-journalist, need: subscribe, text: "I write about Nordic technology. Keep me posted on what you release so I can cover it." } + - { id: rust-developer, need: collaborate, text: "I'm a Rust developer between jobs and I like that you build in the open. I would love to contribute, and maybe join you." } + - { id: interaction-designer, need: collaborate, text: "I'm an interaction designer. I want to work with you on the drawing interface." } + - { id: student-intern, need: collaborate, text: "I'm a computer science student looking for a summer internship doing infrastructure work." } + - { id: plumber-owner, need: trial, text: "I run a plumbing company with four vans. Jobs come in by phone and text and some get lost. I'd like to see how this would look for us before I spend any money." } + - { id: dance-school, need: trial, text: "We are a dance school. Enrolments, cancellations and invoices all go through one email inbox that only I understand. Can you show me a version made for us?" } + - { id: node-runner, need: custody, text: "I run a small server in Berlin and want to host a redoal node that peers with yours. Who do I talk to?" } + - { id: recorded-name, need: custody, text: "Someone left a recording on redoal that says my full name and where I live. I want it gone, and I want to know who is responsible for that network." } + - { id: app-builder, need: custody, text: "I'm building a music app and would like to use redoal's gesture addresses in it. Is that possible, and who decides?" } + - { id: wording-fix, need: propose, text: "The subscribe page should say how often you actually write. I'd like to suggest that exact change to the page." } + - { id: rewrite, need: propose, text: "One page on this site reads wrong to me and I know exactly how I'd rewrite it. Where do I send the new text?" } diff --git a/portal-portal/aggregates.yaml b/portal-portal/aggregates.yaml new file mode 100644 index 0000000..40f8021 --- /dev/null +++ b/portal-portal/aggregates.yaml @@ -0,0 +1,17 @@ +# One onboarding per business. `open` is the intake as submitted; the +# generator (portal-trainer generate) turns it into a draft content +# repo; the business reviews the draft and either it goes live or it +# comes back for another pass. +aggregates: + - bucket: onboardings + initial: open + states: + open: { event: intake_received } + drafted: { event: draft_generated } + revising: { event: revision_requested } + live: { event: went_live } + declined: { event: declined } + transitions: + open: [drafted, declined] + drafted: [live, revising, declined] + revising: [drafted, declined] diff --git a/portal-portal/needs.yaml b/portal-portal/needs.yaml new file mode 100644 index 0000000..fb30cdc --- /dev/null +++ b/portal-portal/needs.yaml @@ -0,0 +1,41 @@ +# The onboarding site is a portal site, so it is held to a needs spec +# like any other. +needs: + - id: onboard-business + who: someone who runs a business where requests, approvals and handovers get lost + wants: a working site for their own roles, without commissioning software + via: Describe your business and get a first draft + lands_in: onboardings + handled_by: owners + done_when: [live, declined] + max_steps: 1 + # The intake is the interview: two to reach them, four about the work. + max_fields: 6 + +personas: + - id: contractor-owner + need: onboard-business + text: > + I run a building firm. Enquiries come by phone, text and email + and we lose track of who promised what. I want one place where + they land and my project managers pick them up. + + - id: clinic-manager + need: onboard-business + text: > + We are a physiotherapy clinic with six therapists. Referrals, + cancellations and insurance approvals all go through one inbox + that only our receptionist understands. I'd like that sorted. + + - id: sports-club + need: onboard-business + text: > + I volunteer for a youth football club. Parents sign kids up, + coaches need to approve them, and the treasurer chases fees. + Right now it's a spreadsheet and a group chat. + + - id: sceptical-founder + need: onboard-business + text: > + I've been burned by agencies before. If I can explain my + workflow and see something real before paying anyone, I'm in. diff --git a/portal-portal/questions/desk/_section.yaml b/portal-portal/questions/desk/_section.yaml new file mode 100644 index 0000000..670fca7 --- /dev/null +++ b/portal-portal/questions/desk/_section.yaml @@ -0,0 +1 @@ +qualifies: owners diff --git a/portal-portal/questions/desk/index.yaml b/portal-portal/questions/desk/index.yaml new file mode 100644 index 0000000..602ec3d --- /dev/null +++ b/portal-portal/questions/desk/index.yaml @@ -0,0 +1,25 @@ +name: Which businesses are waiting on us? +responsible: + name: Bendik Aagaard Lynghaug + contact: bl@uhhm.no +alternatives: + - name: Onboardings + description: > + Run `portal-trainer generate` on an open intake, push the draft, + send the link, then mark it drafted. Revisions come back here. + action: /desk + consequence: [Confirm] + features: + - name: "" + description: "" + resource: + source: { kind: kv, bucket: onboardings } + requires_group: owners + transitions: + - { to: drafted, label: Draft sent } + - { to: declined, label: Decline } + - { from: drafted, to: live, label: Went live } + - { from: drafted, to: revising, label: Revision asked } + - { from: drafted, to: declined, label: Decline } + - { from: revising, to: drafted, label: Revised draft sent } + - { from: revising, to: declined, label: Decline } diff --git a/portal-portal/questions/how.yaml b/portal-portal/questions/how.yaml new file mode 100644 index 0000000..bcd1197 --- /dev/null +++ b/portal-portal/questions/how.yaml @@ -0,0 +1,21 @@ +name: How does an answer become a finished job? +description: > + Every page asks one question. Every answer lands with the person + whose job it is, and carries its own next steps. +alternatives: + - name: A visitor answers + description: > + They pick what they came for and fill in the few fields that + person needs. They are told who reads it and when. + - name: The right role sees it + description: > + Each role has a desk listing what is theirs, with one button per + step your process allows - quoted, approved, closed. + - name: Nothing waits unseen + description: > + Every case has a state and an owner, so what is open, and with + whom, is always one page away. + - name: Describe your business and get a first draft + description: Five answers in your own words. + action: / + consequence: [Start] diff --git a/portal-portal/questions/index.yaml b/portal-portal/questions/index.yaml new file mode 100644 index 0000000..8ff846f --- /dev/null +++ b/portal-portal/questions/index.yaml @@ -0,0 +1,49 @@ +name: What does your business keep doing by hand? +description: > + Tell us who comes to you, what you keep track of, and who handles + what. You get a site that asks each of those people the right + question and moves every answer to the person who acts on it. +responsible: + name: Bendik Aagaard Lynghaug + contact: bl@uhhm.no +alternatives: + - name: Describe your business and get a first draft + description: > + Five answers in your own words. We turn them into pages, forms + and a desk for each role, and send you a link to try it. + action: /received + consequence: [Send it] + record_as: onboardings + encouragements: + - Write it the way you would explain it to a new colleague. + features: + - name: You + requirements: + - { name: business, label: Business name } + - { name: email, label: Your email, type: email } + - name: The work + description: Plain words are best. Stages, handovers and waiting are what we listen for. + requirements: + - name: what_we_do + label: What you do + type: textarea + - name: who_comes_to_us + label: Who comes to you, and what each of them wants + type: textarea + - name: what_we_keep_track_of + label: What you keep track of, and the stages it goes through + type: textarea + - name: who_is_involved + label: Who on your side handles what + type: textarea + - name: what_slows_us_down + label: Where things get lost or wait today + type: textarea + optional: true + + - name: See how a question becomes a finished job + description: > + One enquiry followed from the visitor's answer to the desk that + closes it. + action: /how + consequence: [Show me] diff --git a/portal-portal/questions/received.yaml b/portal-portal/questions/received.yaml new file mode 100644 index 0000000..6d125a6 --- /dev/null +++ b/portal-portal/questions/received.yaml @@ -0,0 +1,15 @@ +followup: true +name: What happens with what you sent? +description: > + Bendik reads every intake. Within two working days you get a link to + a working draft built from your answers. +alternatives: + - name: You try the draft as each of your roles + description: > + Click through it as a customer, then as the colleague who handles + that customer. Tell us what reads wrong and the draft comes back + revised. + - name: Back to the start + description: "" + action: / + consequence: [Back] diff --git a/portal-portal/site.yaml b/portal-portal/site.yaml new file mode 100644 index 0000000..f05560b --- /dev/null +++ b/portal-portal/site.yaml @@ -0,0 +1,2 @@ +title: Portal +lang: en diff --git a/sectors/construction/needs.yaml b/sectors/construction/needs.yaml new file mode 100644 index 0000000..b0754b2 --- /dev/null +++ b/sectors/construction/needs.yaml @@ -0,0 +1,158 @@ +# A building contractor's needs, as a portal needs spec: a small to +# mid-sized firm doing surveys, design and rehabilitation, run by a few +# project managers with its own crew and a ring of subcontractors. +# +# This file is a target, not a description: there are no pages behind +# it yet, so `question_lint` fails every need. The trainer's job is to +# write the pages, aggregates.yaml and desks that turn it green, then +# word them until the personas route right. +# +# Groups (Kanidm): project_managers, hse, crew, subcontractors. +# Everyone else arrives anonymous. +# +# What the sector demands of the flows, and why the budgets are what +# they are: +# - Winning work is a pipeline, not an inbox: a lead is surveyed, then +# quoted, then won or lost. "answered" is not an outcome. +# - A subcontractor must be vetted before they set foot on site +# (company registration, trade approval, insurance, tax standing, +# site ID cards for their people). Documents go missing; the state +# machine has to say so and let them come back. +# - Deviation and near-miss reporting only works when it costs the +# reporter seconds. Three required fields, one submission, from a +# phone, and the safety officer owns closing it. +# - People move between firms constantly; onboarding a worker is the +# same shape as onboarding a subcontractor, one size smaller. +needs: + - id: win-survey-work + who: a building owner or housing board with a problem in their building + wants: someone qualified to look at it and say what it takes + lands_in: leads + handled_by: project_managers + done_when: [won, lost] + max_steps: 2 + max_fields: 5 + + - id: win-design-work + who: an owner who knows what they want built or changed + wants: drawings, a permit application, and a price + lands_in: leads + handled_by: project_managers + done_when: [won, lost] + max_steps: 2 + max_fields: 6 + + - id: vet-subcontractor + who: a trade firm that wants to work for us + wants: to be approved once, and then be asked + lands_in: subcontractors + handled_by: project_managers + done_when: [approved, rejected] + max_steps: 2 + max_fields: 7 + + - id: request-bids + who: a project manager with a job to staff + wants: approved subcontractors to see it and answer + enters: /jobs/post + enters_as: project_managers + lands_in: job_requests + handled_by: project_managers + done_when: [awarded, withdrawn] + max_steps: 1 + max_fields: 5 + + - id: answer-bid + who: an approved subcontractor + wants: to say yes to a job, with a price and a start date + enters: /jobs + enters_as: subcontractors + lands_in: bids + handled_by: project_managers + done_when: [accepted, declined] + max_steps: 2 + max_fields: 3 + + - id: hire-crew + who: a tradesperson looking for work + wants: to be considered, without writing a letter + lands_in: applicants + handled_by: project_managers + done_when: [hired, declined] + max_steps: 2 + max_fields: 4 + + - id: report-deviation + who: anyone on site - crew or a subcontractor's people + wants: to report a hazard, a near miss or a defect before they forget + enters: /site + enters_as: crew + lands_in: deviations + handled_by: hse + done_when: [closed] + max_steps: 1 + max_fields: 3 + +personas: + - id: housing-board-damp + need: win-survey-work + text: > + I chair the board of a housing cooperative from the seventies. + Two ground-floor flats have damp in the walls and we don't know + if it's drainage or the facade. We need someone to tell us. + + - id: buyer-before-purchase + need: win-survey-work + text: > + We are about to buy an old timber house. Before we sign we want + a professional to go through it and tell us what is wrong with it. + + - id: attic-conversion + need: win-design-work + text: > + We want to turn our attic into two bedrooms and a bathroom. We + need drawings and someone who handles the application to the + municipality. + + - id: shop-to-flat + need: win-design-work + text: > + I own a former shop on the ground floor and want to make it a + flat. I have no idea what is allowed. Can you sort that out and + give me a price? + + - id: electrician-firm + need: vet-subcontractor + text: > + We are a four-person electrical firm, fully certified. We would + like to get on your list so you call us when you need + electricians. + + - id: scaffolder + need: vet-subcontractor + text: > + I run a scaffolding company. How do I get to do jobs for you? + + - id: carpenter-jobseeker + need: hire-crew + text: > + I'm a certified carpenter with eight years on rehabilitation + jobs. Are you hiring? + + - id: apprentice + need: hire-crew + text: > + I finish vocational school this spring and need an + apprenticeship as a bricklayer. + + - id: loose-railing + need: report-deviation + text: > + The railing on the third level of the scaffold on the north + side is loose. Nobody got hurt but someone will. + + - id: wrong-membrane + need: report-deviation + text: > + The bathroom membrane delivered today is not the one in the + specification. We stopped work on it. diff --git a/sectors/software-studio/needs.yaml b/sectors/software-studio/needs.yaml new file mode 100644 index 0000000..a8ab2fe --- /dev/null +++ b/sectors/software-studio/needs.yaml @@ -0,0 +1,127 @@ +# What UHHM, as a business, needs this site to do - stated apart from +# how the pages word it. `question_lint` checks every need against the +# pages and aggregates.yaml (portal `src/needs.rs`): a path of +# submissions gets the visitor's answer into `lands_in` within the +# effort budget, `handled_by` has a desk over that bucket, and every +# `done_when` state is reachable through the moves the desk offers. +# +# The personas are visitors in their own words - never the site's. +# `question_lint --needs-tasks` turns each page on their path into a +# typed choice for an answering engine; `--needs-score` grades its +# picks. A misroute means the page said one thing and they heard +# another. The runtime never reads this file. +needs: + - id: commission + who: someone with a problem software could solve, and a budget + wants: UHHM to take the work on + via: Good software begins with a clear argument + lands_in: projects + handled_by: owners + done_when: [accepted, declined] + max_steps: 1 + # name, email, the build, the organization - the page argues for + # each of the four; a fifth would need the same argument. + max_fields: 4 + + - id: follow + who: someone curious who isn't ready to get involved + wants: to hear when something ships + via: Attention is earned + lands_in: subscribers + handled_by: owners + # Being on the list is the finished state; leaving is theirs to do. + done_when: [open, unsubscribed] + max_steps: 1 + max_fields: 2 + + - id: collaborate + who: a developer, designer or researcher + wants: to work with UHHM + via: Work happens through dialogue, not tickets + lands_in: applicants + handled_by: owners + done_when: [active, declined] + max_steps: 1 + max_fields: 3 + + - id: improve-site + who: anyone using this site + wants: the site itself to change + enters: /develop/proposal + # Announced, not open: the automation that turns a proposal into a + # pull request is still being built, so the alternative is disabled. + planned: true + lands_in: development_proposals + handled_by: owners + done_when: [approved, rejected] + max_steps: 1 + max_fields: 3 + + - id: track-prospect + who: an owner who met an organization worth following up + wants: the relationship written down and walked to an outcome + enters: /review + enters_as: owners + via: Record a prospect + lands_in: organizations + handled_by: owners + done_when: [client, past_client] + max_steps: 1 + max_fields: 2 + +personas: + - id: architecture-firm + need: commission + text: > + We run a small architecture firm and our project files are + scattered across three cloud drives. We want to pay someone to + set up proper storage and backups we control ourselves. + + - id: climbing-gym-founder + need: commission + text: > + I have an idea for a booking app for climbing gyms and I'm + looking for a team that can build the first version with me. + + - id: municipality-buyer + need: commission + text: > + Our municipality needs an internal tool for tracking building + permits. We are looking for a vendor who can scope it and + deliver it. + + - id: exhibition-visitor + need: follow + text: > + I saw your drawing network at an exhibition and liked it. I don't + want to get involved, I'd just like to know when there is news. + + - id: tech-journalist + need: follow + text: > + I write about Nordic technology. Keep me posted on what you + release so I can cover it. + + - id: rust-developer + need: collaborate + text: > + I'm a Rust developer between jobs and I like that you build in + the open. I would love to contribute, and maybe join you. + + - id: interaction-designer + need: collaborate + text: > + I'm an interaction designer. I want to work with you on the + drawing interface. + + - id: student-intern + need: collaborate + text: > + I'm a computer science student looking for a summer internship + doing infrastructure work. + + - id: site-critic + need: improve-site + text: > + Your contact form never says how long a reply takes. You should + add that. diff --git a/skill/SKILL.md b/skill/SKILL.md new file mode 100644 index 0000000..c411029 --- /dev/null +++ b/skill/SKILL.md @@ -0,0 +1,151 @@ +--- +name: portal-trainer +description: Onboard a business onto portal - interview them, write a plan, build the content repo (pages, aggregates.yaml, desks, mails) with the words written by a model, and score it until iris proves every need is met and a blind visitor routes the personas right. Use when someone wants a new portal site, a new flow on an existing one, a rebuild of a live one, or asks why visitors pick the wrong alternative. +--- + +# Portal trainer + +You are onboarding a business onto [portal](https://project.uhhm.no/uhhm/portal): +a site that is one question per page, a few alternatives per question, +each alternative optionally a form whose answer is recorded into a +bucket with a state machine, worked from a group-gated desk, with a +mail to the person when their case moves. + +You have a verifier. Use it instead of your judgment wherever it can +speak: `iris check --path /questions` (uhhm/iris, the release +matching the portal the site runs) exits non-zero until the structure +meets every need in `/needs.yaml`. + +## The order of work + +1. **Interview, then write the plan.** Never pages first. `plan` + reads a filled-in intake into `plan.yaml` (a model decides needs, + stages, forms and budgets; a person reads the result before + anything is built). By hand, the same questions: who comes to the + business and what each of them wants, who inside handles it, what + "handled" means (the states a case ends in), what it costs the + visitor to ask (`max_fields`, from the person, never from counting + the form you would like). Start from a `sectors/` file when there + is one. +2. **Say what the plan can say.** `does:` (one or two features, one + role each, the front door's opener that asks for nothing); + `shows:` (things to look at before asking); `doors:` only when the + offers are more than a page holds - measure it, a flat page with + ten offers scored worse than four doors; `mails:` per need (who + hears when a case enters a stage) with `mail: { from }` on the + plan; `moves_by: submitter` so the person has something to play + after sending; `invites_by` for onboarding; `events:` when + rebuilding a live site, so its records keep replaying. +3. **Write personas in the visitor's words, not the business's.** Two + or three per need, one who would not use the trade's vocabulary. + Never reuse a phrase you intend to put on a page. They are held + out from every model that writes words. +4. **Build.** `build --plan ... --lint iris` derives every file with + no model deciding structure; `--llm` lets a model write the words, + `--llm-for ` only some of them (the mails alone); + `--words-from ` keeps a live site's hand-edited words + through a rebuild, following each card across pages; `--words + ` overrides slots. The builder refuses a reply in the wrong + language and puts back a submitter's link a model dropped. +5. **Score.** `iris check --needs-tasks` then `answer`, then + `--needs-score`, with and without descriptions; `rate --repo` for + readability and the four understanding questions, mails included. + A card with no button is context, never a choice. Trust the + descriptions score; the headings-only score swings by a third + between seeds. Before believing a change, `runs/spread.sh` it over + three seeds and require it to beat the spread. A change that does + not is rejected and kept as a run. + - A **misroute** is a wording defect until the same one survives + every rewording; then it is a seam in the plan. + - Change the page or the plan, never the persona. +6. **Keep the run.** `runs/-/` with the words as used + (`words.json`), tasks, answers, scores per seed, and a README with + what moved and why. Runs are the record of what worked. + +## What a page is + +Full reference: portal's `README.md`; a live example is +`uhhm/questions`. The tree under `questions/` is the router +(`index.yaml` is `/`, `_section.yaml` applies `qualifies` to a +directory, nested non-index files are followups). + +```yaml +# questions/index.yaml +name: What brings you here? # the question - always a question +responsible: { name: ..., contact: ... } +alternatives: + - name: What we do # the opener: features, no button + features: + - { name: , description: } + - name: # graded alone under --skim + description: + action: /received # where the submission lands them + consequence: [Send] # the button + record_as: leads # bucket - must exist in aggregates.yaml + features: + - name:
+ requirements: + - { name: email, label: Email, type: email } + - { name: problem, label: What's wrong, type: textarea } +``` + +```yaml +# aggregates.yaml +aggregates: + - bucket: leads + initial: open + states: + open: { event: received, mail: { to: submitter, subject: "We have it", body: "..." } } + quoted: { event: quoted } + won: { event: won, mail: { to: submitter, subject: "...", body: "..." } } + lost: { event: lost } + transitions: + open: [quoted, lost] + quoted: [won, lost] +``` + +```yaml +# site.yaml +mail: { from: "Nordvik ", reply_to: ola@nordvik.example } +``` + +```yaml +# questions/desk/_section.yaml +qualifies: project_managers +# questions/desk/index.yaml +name: What came in? +alternatives: + - name: Leads + action: /desk + consequence: [Confirm] + features: + - name: "" + resource: + source: { kind: kv, bucket: leads } + requires_group: project_managers + transitions: + - { to: quoted, label: Quote sent } # from defaults to open + - { from: quoted, to: won, label: Won } +``` + +## House rules for the copy + +- The page name is a question the visitor could ask themselves. +- The front door opens modestly: what the business does for one or + two roles, then what there is to look at, then the offers. No + second tier. +- Headings say what the visitor gets, in the visitor's words. Lead + with what a thing enables; never define it by what it isn't. +- Every required field must be one the business will use to answer. +- A followup page says who reads it, when they answer, and what the + visitor can do meanwhile. A mail says what happened and, where the + person has a move, the link to make it. +- Code in English; the site in the business's language. + +## Engines + +Any engine that reads a task's `state` (`body` is the persona, +`page` what the page says before its options) and `questions` and +returns `{"id","choice","confidence"}` per line works. The visitor +and the writer must be different models. See the repo README for what +has been measured about each, and for the noise floor. diff --git a/src/lib.rs b/src/lib.rs index 5635369..eefabfa 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -37,6 +37,13 @@ pub mod seed; pub mod memberships; #[cfg(feature = "ssr")] pub mod report; +/// The lint: `portal lint`, what a content repo's CI runs (once uhhm/iris). +#[cfg(feature = "ssr")] +pub mod lint; +/// The trainer: `portal plan`, `portal build`, `portal report` and the +/// rest (once uhhm/portal-trainer). +#[cfg(feature = "ssr")] +pub mod trainer; pub mod maps; diff --git a/src/lint/check.rs b/src/lint/check.rs new file mode 100644 index 0000000..655a9cb --- /dev/null +++ b/src/lint/check.rs @@ -0,0 +1,248 @@ +//! Headless schema-check binary - loads content and `aggregates.yaml` +//! (from a Gitea repo URL or a local directory) and validates them +//! exactly the way `content::watch_for_reload`/`main.rs`'s boot path +//! do, with no NATS, OIDC, web server, or JetStream connection +//! involved. Built once by portal's own deploy workflow and run +//! directly by `questions`' own CI (same bare-metal runner/host, +//! published to a stable path - no artifact download needed), rather +//! than compiled there - keeps that repo's CI coupling to "run a +//! static binary," not "build a Rust workspace." + +use crate::lint::local::{load_aggregates_from_dir, load_from_dir, load_needs_from_dir, load_site_from_dir}; +use crate::lint::needs; +use crate::{aggregates, content}; + +pub async fn run(argv: Vec) -> anyhow::Result<()> { + let mut args = argv.into_iter(); + let mut repo: Option = None; + let mut branch = "main".to_string(); + let mut subdir = "questions".to_string(); + let mut path: Option = None; + let mut tasks_out: Option = None; + let mut answers_in: Option = None; + let mut min_accuracy: f64 = 0.0; + let mut skim = false; + let mut sim_out: Option = None; + let mut show_access = false; + + while let Some(arg) = args.next() { + match arg.as_str() { + "--repo" => repo = args.next(), + "--branch" => branch = args.next().unwrap_or(branch), + "--subdir" => subdir = args.next().unwrap_or(subdir), + "--path" => path = args.next(), + "--needs-tasks" => tasks_out = args.next(), + "--skim" => skim = true, + "--needs-sim" => sim_out = args.next(), + "--access" => show_access = true, + "--needs-score" => answers_in = args.next(), + "--min-accuracy" => { + min_accuracy = args.next().and_then(|v| v.parse().ok()).unwrap_or(min_accuracy) + } + other => { + eprintln!("unknown argument: {other}"); + std::process::exit(2); + } + } + } + + let (questions, aggregates_map, site, needs_raw) = if let Some(dir) = path { + ( + load_from_dir(&dir)?, + load_aggregates_from_dir(&dir)?, + load_site_from_dir(&dir)?, + load_needs_from_dir(&dir)?, + ) + } else if let Some(repo_url) = repo { + // The first storyline of each, as the checks read it; the + // loaders have held every list of texts to the site's arity. + let questions = content::load_questions_from_gitea(&repo_url, &branch, &subdir).await?.at(0).clone(); + let aggregates_map = content::load_aggregates_from_gitea(&repo_url, &branch).await?.at(0).clone(); + // load_site_from_gitea already validates; a missing file is + // the default config, same as at portal boot. + let site = content::load_site_from_gitea(&repo_url, &branch).await?.at(0).clone(); + // Optional: a fetch failure means the repo declares no needs. + let needs_raw = crate::http::get_text(&format!("{}/needs.yaml?ref={branch}", content::gitea_raw_base(&repo_url)?), crate::http::GITEA_CAP).await.ok(); + (questions, aggregates_map, site, needs_raw) + } else { + eprintln!( + "usage: iris check (--repo [--branch main] [--subdir questions] | --path ) [--needs-tasks [--skim]] [--needs-sim ] [--access] [--needs-score [--min-accuracy 0.8]]" + ); + std::process::exit(2); + }; + + if let Err(e) = site.validate() { + eprintln!("FAIL: {e}"); + std::process::exit(1); + } + let aggregates_map = content::with_builtin_aggregates(aggregates_map); + match content::validate_questions(&questions, &aggregates_map) { + Ok(()) => { + // The runtime's own event desk: a repo announcing pages + // (Question.event) should read portal_events somewhere, or + // "post what happened" tasks pile up unseen. Warn, don't + // fail - the runtime creates the records either way. + if questions.values().any(|q| q.event.is_some()) + && !content::bucket_is_read(&questions, content::EVENTS_BUCKET) + { + eprintln!( + "WARN: pages carry `event:` but no kv resource reads bucket {:?} - add a desk so summaries get posted", + content::EVENTS_BUCKET + ); + } + println!( + "OK: {} question(s), {} aggregate(s) valid", + questions.len(), + aggregates_map.len() + ); + // The access policy the site will compile from this content. + // It must type-check against portal's schema: a rule the + // schema cannot express would grant nothing at runtime. + let policy = crate::access::Policy::from_content(&questions, &aggregates_map); + if let Err(e) = policy.validate() { + eprintln!("FAIL: access policy does not validate: {e}"); + std::process::exit(1); + } + println!("OK: access policy, {} rule(s), validates", policy.rules.len()); + if show_access { + println!("\n{:<16} {:<44} {:<40} {}", "ACTION", "RESOURCE", "WHO", "WHEN"); + let mut rows = policy.rules.clone(); + rows.sort_by(|a, b| (&a.resource, &a.action, &a.who).cmp(&(&b.resource, &b.action, &b.who))); + for r in rows { + println!("{:<16} {:<44} {:<40} {}", r.action, r.resource, r.who, r.when); + } + println!(); + } + check_needs( + needs_raw.as_deref(), + &questions, + &aggregates_map, + tasks_out.as_deref(), + skim, + sim_out.as_deref(), + answers_in.as_deref(), + min_accuracy, + ) + } + Err(e) => { + eprintln!("FAIL: {e}"); + std::process::exit(1); + } + } +} + +/// The business-needs pass (see `crate::lint::needs`): structure always, +/// then the wording tasks written out and/or an engine's answers +/// scored, when asked. A repo without `needs.yaml` skips all of it. +fn check_needs( + raw: Option<&str>, + questions: &std::collections::HashMap, + aggregates_map: &std::collections::HashMap, + tasks_out: Option<&str>, + skim: bool, + sim_out: Option<&str>, + answers_in: Option<&str>, + min_accuracy: f64, +) -> anyhow::Result<()> { + let Some(raw) = raw else { + if tasks_out.is_some() || answers_in.is_some() { + eprintln!("FAIL: --needs-tasks/--needs-score given, but the repo has no needs.yaml"); + std::process::exit(1); + } + return Ok(()); + }; + let file = match needs::parse(raw) { + Ok(file) => file, + Err(e) => { + eprintln!("FAIL: {e}"); + std::process::exit(1); + } + }; + let findings = needs::check(&file, questions, aggregates_map); + let mut failed = 0; + for finding in &findings { + let level = match finding.level { + needs::Level::Fail => { + failed += 1; + "FAIL" + } + needs::Level::Warn => "WARN", + }; + eprintln!("{level}: need {:?}: {}", finding.need, finding.message); + } + for need in &file.needs { + if let Some(path) = needs::path_for(questions, need) { + let steps: Vec = path + .iter() + .map(|s| format!("{} [{}]", s.page, s.alternative)) + .collect(); + println!("need {:?}: {} -> {:?}", need.id, steps.join(" -> "), need.lands_in); + } + } + if failed == 0 { + let planned = file.needs.iter().filter(|n| n.planned).count(); + println!( + "OK: {} need(s) structurally met, {} planned, {} persona(s)", + file.needs.len() - planned, + planned, + file.personas.len() + ); + } + // The wording pass still runs for the needs that do have a path - + // a closed flow elsewhere is no reason to stop grading the rest. + + if let Some(out) = sim_out { + let model = needs::sim_model(&file, questions, aggregates_map); + std::fs::write(out, serde_json::to_string_pretty(&model)?) + .map_err(|e| anyhow::anyhow!("writing {out}: {e}"))?; + println!("wrote the simulation model to {out}"); + } + let tasks = needs::tasks(&file, questions, skim); + if let Some(out) = tasks_out { + let mut lines = String::new(); + for task in &tasks { + lines.push_str(&serde_json::to_string(task)?); + lines.push('\n'); + } + std::fs::write(out, lines).map_err(|e| anyhow::anyhow!("writing {out}: {e}"))?; + println!("wrote {} wording task(s) to {out}", tasks.len()); + } + if let Some(path) = answers_in { + let raw = std::fs::read_to_string(path).map_err(|e| anyhow::anyhow!("reading {path}: {e}"))?; + let mut answers = Vec::new(); + for (n, line) in raw.lines().enumerate().filter(|(_, l)| !l.trim().is_empty()) { + answers.push( + serde_json::from_str::(line) + .map_err(|e| anyhow::anyhow!("{path}:{}: {e}", n + 1))?, + ); + } + let score = needs::score(&tasks, &answers, 0.6); + for (id, picked, expected, confidence) in &score.misroutes { + eprintln!( + "MISROUTE: {id}: picked {picked:?}{}, the need is met by {expected:?}", + confidence.map(|c| format!(" ({c:.2})")).unwrap_or_default() + ); + } + for (id, confidence) in &score.hesitant { + eprintln!("HESITANT: {id}: right, at {confidence:.2}"); + } + for id in &score.unanswered { + eprintln!("UNANSWERED: {id}"); + } + println!( + "wording: {}/{} routed right ({:.0}%)", + score.correct, + score.total, + score.accuracy() * 100.0 + ); + if score.accuracy() < min_accuracy { + eprintln!("FAIL: below --min-accuracy {min_accuracy}"); + std::process::exit(1); + } + } + if failed > 0 { + eprintln!("FAIL: {failed} unmet business need finding(s)"); + std::process::exit(1); + } + Ok(()) +} diff --git a/src/lint/local.rs b/src/lint/local.rs new file mode 100644 index 0000000..f8f0647 --- /dev/null +++ b/src/lint/local.rs @@ -0,0 +1,105 @@ +//! Loading a content repo from a local checkout - the offline +//! counterparts to the Gitea loaders in `content`, for tools that lint +//! or replay a branch that has not been pushed (question_lint, +//! needs_replay). `dir` is always the pages directory (`questions/`); +//! `site.yaml`, `aggregates.yaml` and `needs.yaml` sit one level up. + +use crate::{aggregates, content}; + +/// The offline counterpart to `content::load_site_from_gitea` - +/// `site.yaml` lives at the repo root like `aggregates.yaml`, and is +/// just as optional locally: missing means default branding. +pub fn load_site_from_dir(dir: &str) -> anyhow::Result { + let site_path = std::path::Path::new(dir) + .parent() + .unwrap_or_else(|| std::path::Path::new(".")) + .join("site.yaml"); + if !site_path.exists() { + return Ok(content::SiteConfig::default()); + } + let raw = std::fs::read_to_string(&site_path) + .map_err(|e| anyhow::anyhow!("reading {}: {e}", site_path.display()))?; + // The first storyline, held to the arity (content::variants). + let files = vec![("site.yaml".to_string(), raw)]; + content::arity(&files)?; + serde_yaml::from_str(&content::storyline(&files, 0)[0].1).map_err(|e| anyhow::anyhow!("parsing {}: {e}", site_path.display())) +} + +/// The offline counterpart to `content::load_aggregates_from_gitea` - +/// `aggregates.yaml` lives at the repo root, one level up from the +/// pages directory `--path` names, so `dir`'s parent is where it's +/// looked for. Missing entirely is not an error here (unlike +/// `--repo` mode, where `aggregates.yaml` is required) - a local +/// checkout being linted may not have one, and every declared +/// transition still gets checked against whatever *is* found; an +/// empty map just means nothing is checked. +pub fn load_aggregates_from_dir( + dir: &str, +) -> anyhow::Result> { + let aggregates_path = std::path::Path::new(dir) + .parent() + .unwrap_or_else(|| std::path::Path::new(".")) + .join("aggregates.yaml"); + if !aggregates_path.exists() { + return Ok(std::collections::HashMap::new()); + } + let raw = std::fs::read_to_string(&aggregates_path) + .map_err(|e| anyhow::anyhow!("reading {}: {e}", aggregates_path.display()))?; + // The first storyline, held to the arity (content::variants). + let files = vec![("aggregates.yaml".to_string(), raw)]; + content::arity(&files)?; + aggregates::parse_aggregates_yaml(&content::storyline(&files, 0)[0].1) +} + +/// The offline counterpart to `content::load_questions_from_gitea` - +/// the same recursive tree walk and `content::build_questions` +/// pipeline (derived ids, relative refs, sections, followup +/// inference), just reading a local checkout instead of Gitea's API, +/// for linting a branch that hasn't been pushed yet. +pub fn load_from_dir( + dir: &str, +) -> anyhow::Result> { + let base = std::path::Path::new(dir); + let mut files = Vec::new(); + collect_yaml(base, base, &mut files)?; + content::build_questions(&files) +} + +fn collect_yaml( + base: &std::path::Path, + dir: &std::path::Path, + out: &mut Vec<(String, String)>, +) -> anyhow::Result<()> { + for entry in std::fs::read_dir(dir)? { + let path = entry?.path(); + if path.is_dir() { + collect_yaml(base, &path, out)?; + } else if path.extension().and_then(|e| e.to_str()) == Some("yaml") { + let rel = path + .strip_prefix(base) + .expect("walked paths sit under their base") + .to_string_lossy() + .replace('\\', "/"); + let raw = std::fs::read_to_string(&path) + .map_err(|e| anyhow::anyhow!("reading {}: {e}", path.display()))?; + out.push((rel, raw)); + } + } + Ok(()) +} + +/// The offline counterpart to `check`'s needs.yaml fetch - +/// `needs.yaml` sits at the repo root beside `aggregates.yaml`. +pub fn load_needs_from_dir(dir: &str) -> anyhow::Result> { + let needs_path = std::path::Path::new(dir) + .parent() + .unwrap_or_else(|| std::path::Path::new(".")) + .join("needs.yaml"); + if !needs_path.exists() { + return Ok(None); + } + std::fs::read_to_string(&needs_path) + .map(Some) + .map_err(|e| anyhow::anyhow!("reading {}: {e}", needs_path.display())) +} + diff --git a/src/lint/mod.rs b/src/lint/mod.rs new file mode 100644 index 0000000..03a835f --- /dev/null +++ b/src/lint/mod.rs @@ -0,0 +1,53 @@ +//! iris - the shield over a portal site. A content push is an incoming +//! wormhole; nothing comes through until it checks out. +//! +//! `iris check` loads a content repo (a Gitea URL or a local checkout) +//! exactly the way the running site does, validates it, and holds it to +//! the business needs it declares in `needs.yaml` - a path within +//! budget for every kind of visitor, a desk for every handling group, +//! every finished state reachable, no record stranding. It also exports +//! what a trainer needs to grade the wording: personas as typed choice +//! tasks, and a resolved simulation model. +//! +//! `iris replay` runs simulated cases through the site's real aggregate +//! engine on a JetStream and reports every bucket by state; with +//! `--report-only` it takes the same scorecard from a live site's +//! buckets. +//! +//! Every type iris reads is portal's own, pinned to the portal release +//! it matches, so the lint and the site never disagree about what a +//! page is. + +mod check; +mod local; +mod needs; +mod replay; + +/// The lint, as `portal lint ...`: `argv` is everything after `portal`. +pub async fn run(mut argv: Vec) -> anyhow::Result<()> { + // `iris --path questions` reads as `iris check --path questions`, + // so the content repos' one-line CI keeps working as it was. + match argv.first().map(String::as_str) { + Some("check") => { + argv.remove(0); + check::run(argv).await + } + Some("replay") => { + argv.remove(0); + replay::run(argv).await + } + Some("--version") | Some("-V") => { + println!("portal lint v{}", env!("CARGO_PKG_VERSION")); + Ok(()) + } + Some(flag) if flag.starts_with("--") => check::run(argv).await, + _ => { + eprintln!( + "iris {v} - matches portal v{v}\n\nusage:\n iris check (--repo [--branch main] [--subdir questions] | --path ) [--needs-tasks [--skim]] [--needs-sim ] [--access] [--needs-score [--min-accuracy 0.8]]\n iris replay --path --nats (--cases | --report-only)", + v = env!("CARGO_PKG_VERSION") + ); + std::process::exit(2) + } + } +} + diff --git a/src/lint/needs.rs b/src/lint/needs.rs new file mode 100644 index 0000000..893bf4c --- /dev/null +++ b/src/lint/needs.rs @@ -0,0 +1,1036 @@ +//! A business's needs, stated apart from how its pages word them. +//! +//! A content repo may ship a `needs.yaml` at its root: each *need* says +//! who wants what, where they enter, which bucket the answer must land +//! in, and which group has to be able to carry it to a finished state. +//! Two things are checked against it: +//! +//! - **Structure** (`check`): deterministic. A path of submissions +//! leads from the entry page to the bucket, within an effort budget; +//! the handling group has a desk over that bucket; every finished +//! state is reachable through the moves that desk offers, and no +//! record strands on the way. +//! - **Wording** (`tasks` / `score`): each *persona* is a visitor in +//! their own words. Every page on their path becomes one typed +//! `choice` question for an external answering engine - the page's +//! question, its alternatives as the options - and the engine's picks +//! are scored against the alternatives that actually meet the need. +//! A misroute is a wording defect: the page said one thing and the +//! visitor heard another. +//! +//! The engine is deliberately outside this crate: tasks go out as +//! JSONL, answers come back as JSONL, so any classifier or LLM can sit +//! in between and be compared on the same tasks. + +use std::collections::{BTreeMap, HashMap, HashSet, VecDeque}; + +use serde::{Deserialize, Serialize}; + +use crate::aggregates::AggregateSchema; +use crate::content::{action_target_id, Alternative, Question}; + +#[derive(Debug, Default, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct NeedsFile { + #[serde(default)] + pub needs: Vec, + #[serde(default)] + pub personas: Vec, + /// The tones of voice the site is told in, one storyline each + /// (portal docs, Storylines). Named here so the report can say + /// which tone a storyline index is; the checks read the first. + #[serde(default)] + pub tones: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Need { + pub id: String, + /// The kind of visitor - prose, for the reader of the report. + pub who: String, + pub wants: String, + /// The page they arrive on. + #[serde(default = "root")] + pub enters: String, + /// The group they hold when they arrive; unset means anonymous. + #[serde(default)] + pub enters_as: Option, + /// The alternative on the entry page that serves this need, when + /// several record into the same bucket. + #[serde(default)] + pub via: Option, + /// The bucket their answer must be recorded into. + pub lands_in: String, + /// The group that must be able to see the record and move it. + pub handled_by: String, + /// The states that count as handled. + pub done_when: Vec, + /// Submissions the visitor may be asked for before the record + /// exists. + #[serde(default = "default_max_steps")] + pub max_steps: usize, + /// Required fields across the whole path - the onboarding effort + /// budget. + #[serde(default)] + pub max_fields: Option, + /// Declared, and knowingly not open yet: what is missing reports + /// as a warning, so the gap stays visible without failing CI. + #[serde(default)] + pub planned: bool, + /// Each state of the bucket in plain words, in the site's + /// language, for the scorer: state ids are ASCII, and a Norwegian + /// `ikke_lost` reads as English to a model. + #[serde(default)] + pub stage_words: std::collections::BTreeMap, + /// The finished states that are a good outcome for the business - + /// a subset of `done_when`. "lost" is finished; "won" is success. + /// What a simulation or a live bucket report counts toward. + #[serde(default)] + pub success: Vec, + /// Every stage the business said a case goes through, in its own + /// order, first to last. Written from how the business describes + /// its work, before any state machine exists, so that a machine + /// which quietly skips a stage ("we go and look, then we quote" + /// becoming open -> quoted) fails instead of passing. + #[serde(default)] + pub stages: Vec, + /// Other groups whose desks carry some of this need's moves: a + /// site manager closing what a project manager opened. Their + /// buttons count toward reaching `done_when` exactly as + /// `handled_by`'s do. `handled_by` must still hold a desk of its + /// own - someone has to be answerable for the record. + #[serde(default)] + pub also_moved_by: Vec, +} + +fn root() -> String { + "/".to_string() +} + +fn default_max_steps() -> usize { + 2 +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Persona { + pub id: String, + pub need: String, + /// The visitor's situation in their own words - never the page's. + pub text: String, +} + +pub fn parse(raw: &str) -> anyhow::Result { + serde_yaml::from_str(raw).map_err(|e| anyhow::anyhow!("parsing needs.yaml: {e}")) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Level { + Fail, + Warn, +} + +#[derive(Debug)] +pub struct Finding { + pub need: String, + pub level: Level, + pub message: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Step { + pub page: String, + pub alternative: String, +} + +/// One alternative on a page that still meets the need, with the +/// cheapest way on from it. +struct Viable { + alternative: String, + path: Vec, + fields: usize, + records_with_action: Option, +} + +type Questions = HashMap; + +fn visible(q: &Question, as_group: Option<&str>) -> bool { + match &q.qualifies { + None => true, + Some(group) => as_group == Some(group.as_str()), + } +} + +/// What a person has to fill in: required fields, less the ones that +/// carry rather than ask - a `record` naming the case an answer is +/// about, a `hidden` preset - which cost nobody anything. +fn required_fields(alt: &Alternative) -> usize { + alt.features + .iter() + .flat_map(|f| &f.requirements) + .filter(|r| !r.optional && r.kind != "record" && r.kind != "hidden") + .count() +} + +/// The alternatives on `page` from which the need is still met within +/// `remaining` submissions. +fn viable( + questions: &Questions, + need: &Need, + page: &Question, + remaining: usize, + entry: bool, + include_disabled: bool, + trail: &mut Vec, +) -> Vec { + let mut out = Vec::new(); + if remaining == 0 { + return out; + } + for alt in page.alternatives.iter().filter(|a| include_disabled || !a.disabled) { + if entry && need.via.as_deref().is_some_and(|via| via != alt.name) { + continue; + } + let step = Step { + page: page.id.clone(), + alternative: alt.name.clone(), + }; + let fields = required_fields(alt); + if alt.record_as.as_deref() == Some(need.lands_in.as_str()) { + out.push(Viable { + alternative: alt.name.clone(), + path: vec![step], + fields, + records_with_action: Some(alt.action.is_some()), + }); + continue; + } + let Some(target) = alt + .action + .as_deref() + .and_then(|a| action_target_id(questions, a)) + .and_then(|id| questions.get(&id)) + else { + continue; + }; + if trail.contains(&target.id) || !visible(target, need.enters_as.as_deref()) { + continue; + } + trail.push(target.id.clone()); + let onward = viable(questions, need, target, remaining - 1, false, include_disabled, trail); + trail.pop(); + if let Some(best) = onward.into_iter().min_by_key(|v| (v.path.len(), v.fields)) { + let mut path = vec![step]; + path.extend(best.path); + out.push(Viable { + alternative: alt.name.clone(), + path, + fields: fields + best.fields, + records_with_action: best.records_with_action, + }); + } + } + out +} + +fn entry_viable(questions: &Questions, need: &Need, include_disabled: bool) -> Option> { + let page = questions.get(&need.enters)?; + let mut trail = vec![page.id.clone()]; + Some(viable(questions, need, page, need.max_steps, true, include_disabled, &mut trail)) +} + +/// The cheapest path that meets the need, if any. +pub fn path_for(questions: &Questions, need: &Need) -> Option> { + entry_viable(questions, need, false)? + .into_iter() + .min_by_key(|v| (v.path.len(), v.fields)) + .map(|v| v.path) +} + +struct Desk { + page: String, + group: Option, + /// (from, to, button label) + moves: Vec<(String, String, String)>, +} + +fn desks_over(questions: &Questions, bucket: &str) -> Vec { + let mut desks = Vec::new(); + for q in questions.values() { + for resource in q + .alternatives + .iter() + .flat_map(|a| &a.features) + .filter_map(|f| f.resource.as_ref()) + .filter(|r| r.bucket() == Some(bucket)) + { + desks.push(Desk { + page: q.id.clone(), + group: resource.requires_group.clone().or_else(|| q.qualifies.clone()), + moves: resource + .transitions + .iter() + .map(|t| (t.from.clone(), t.to.clone(), t.label.clone())) + .collect(), + }); + } + } + desks.sort_by(|a, b| a.page.cmp(&b.page)); + desks +} + +/// Self-service moves into `bucket` - a visitor holding their own +/// record's link, no group involved (unsubscribe). +fn self_moves(questions: &Questions, bucket: &str) -> Vec { + questions + .values() + .flat_map(|q| &q.alternatives) + .filter_map(|a| a.self_transition.as_ref()) + .filter(|s| s.bucket == bucket) + .map(|s| s.to.clone()) + .collect() +} + +/// Structural check of every need. `Fail` findings mean the business +/// need is not met by the content as written. +pub fn check( + file: &NeedsFile, + questions: &Questions, + aggregates: &HashMap, +) -> Vec { + let mut findings = Vec::new(); + let mut seen = HashSet::new(); + for need in &file.needs { + let mut report = |level: Level, message: String| { + findings.push(Finding { + need: need.id.clone(), + level: if need.planned { Level::Warn } else { level }, + message: if need.planned && level == Level::Fail { + format!("planned, not met yet: {message}") + } else { + message + }, + }) + }; + if !seen.insert(need.id.as_str()) { + report(Level::Fail, "declared more than once".to_string()); + continue; + } + + // The visitor's side: can they get their answer recorded? + match questions.get(&need.enters) { + None => report(Level::Fail, format!("entry page {:?} does not exist", need.enters)), + Some(page) if !visible(page, need.enters_as.as_deref()) => report( + Level::Fail, + format!( + "entry page {:?} is gated on {:?}, which this visitor does not hold", + need.enters, page.qualifies + ), + ), + Some(page) => { + if page.responsible.is_none() { + report( + Level::Warn, + format!("entry page {:?} names no one responsible to turn to", need.enters), + ); + } + let best = entry_viable(questions, need, false) + .unwrap_or_default() + .into_iter() + .min_by_key(|v| (v.path.len(), v.fields)); + match best { + None if entry_viable(questions, need, true).is_some_and(|v| !v.is_empty()) => { + let blocked = entry_viable(questions, need, true) + .unwrap_or_default() + .into_iter() + .min_by_key(|v| (v.path.len(), v.fields)) + .map(|v| v.path) + .unwrap_or_default(); + let at = blocked + .iter() + .find(|s| { + questions[&s.page] + .alternatives + .iter() + .any(|a| a.name == s.alternative && a.disabled) + }) + .map(|s| format!("{:?} on {}", s.alternative, s.page)) + .unwrap_or_default(); + report( + Level::Fail, + format!( + "the path exists but is closed: {at} is `disabled`, so the answer is advertised and refused" + ), + ) + } + None => report( + Level::Fail, + format!( + "no path from {:?}{} records into {:?} within {} submission(s)", + need.enters, + need.via + .as_ref() + .map(|v| format!(" via {v:?}")) + .unwrap_or_default(), + need.lands_in, + need.max_steps + ), + ), + Some(best) => { + if let Some(budget) = need.max_fields { + if best.fields > budget { + report( + Level::Fail, + format!( + "the path asks for {} required field(s), over the budget of {budget}", + best.fields + ), + ); + } + } + if best.records_with_action == Some(false) { + report( + Level::Warn, + "the recording alternative has no `action` - the visitor is never told what happens next".to_string(), + ); + } + } + } + } + } + + // The business's side: can the handling group finish it? + let Some(schema) = aggregates.get(&need.lands_in) else { + report( + Level::Fail, + format!("aggregates.yaml declares no state machine for {:?}", need.lands_in), + ); + continue; + }; + let desks = desks_over(questions, &need.lands_in); + let own: Vec<&Desk> = desks + .iter() + .filter(|d| { + d.group.as_deref().is_some_and(|g| g == need.handled_by || need.also_moved_by.iter().any(|m| m == g)) + }) + .collect(); + if !desks.iter().any(|d| d.group.as_deref() == Some(need.handled_by.as_str())) { + let others: Vec = desks + .iter() + .map(|d| format!("{} ({})", d.page, d.group.as_deref().unwrap_or("ungated"))) + .collect(); + report( + Level::Fail, + format!( + "no desk gated on {:?} reads {:?}{}", + need.handled_by, + need.lands_in, + if others.is_empty() { + String::new() + } else { + format!(" - it is read by {}", others.join(", ")) + } + ), + ); + continue; + } + let selfs = self_moves(questions, &need.lands_in); + let exits = |state: &str| -> Vec { + let allowed = schema.allowed(state); + let mut to: Vec = own + .iter() + .flat_map(|d| &d.moves) + .filter(|(from, to, _)| from == state && allowed.contains(to)) + .map(|(_, to, _)| to.clone()) + .collect(); + to.extend(selfs.iter().filter(|to| allowed.contains(to)).cloned()); + to + }; + let done: HashSet<&str> = need.done_when.iter().map(String::as_str).collect(); + // Reachability walks the whole machine (a finished state may + // lead on to another: subscribed, then unsubscribed). Stranding + // only counts on the way to the first finished state - what + // happens to a handled record afterwards is not this need. + let mut reached: HashSet = HashSet::from([schema.initial.clone()]); + let mut queue = VecDeque::from([(schema.initial.clone(), false)]); + let mut stranded = Vec::new(); + while let Some((state, past_done)) = queue.pop_front() { + let past_done = past_done || done.contains(state.as_str()); + let next = exits(&state); + if next.is_empty() && !past_done { + stranded.push(state.clone()); + } + for to in next { + if reached.insert(to.clone()) { + queue.push_back((to, past_done)); + } + } + } + for state in &need.done_when { + if !schema.has_state(state) { + report( + Level::Fail, + format!("done_when names {state:?}, which {:?} has no such state", need.lands_in), + ); + } else if !reached.contains(state) { + report( + Level::Fail, + format!( + "{:?} cannot move a record to {state:?} - no desk of theirs offers the moves", + need.handled_by + ), + ); + } + } + for stage in &need.stages { + if !schema.has_state(stage) { + report( + Level::Fail, + format!( + "the business describes a stage {stage:?} that {:?} has no state for", + need.lands_in + ), + ); + } else if !reached.contains(stage) { + report( + Level::Fail, + format!( + "stage {stage:?} exists but {:?} is offered no moves that reach it", + need.handled_by + ), + ); + } + } + for state in &need.success { + if !need.done_when.contains(state) { + report( + Level::Fail, + format!("success names {state:?}, which is not one of done_when"), + ); + } + } + stranded.sort(); + for state in stranded { + report( + Level::Fail, + format!( + "records strand in {state:?}: {:?} is offered no move out of it", + need.handled_by + ), + ); + } + } + + let need_ids: HashSet<&str> = file.needs.iter().map(|n| n.id.as_str()).collect(); + for persona in &file.personas { + if !need_ids.contains(persona.need.as_str()) { + findings.push(Finding { + need: persona.need.clone(), + level: Level::Fail, + message: format!("persona {:?} points at a need that is not declared", persona.id), + }); + } + } + findings +} + +/// Everything a simulation of the business needs, resolved: each +/// need's path with the choice on every page and the form that gets +/// filled, each bucket's state machine with the buttons every desk +/// offers, and the personas. Portal resolves it because only portal +/// loads content the way the runtime does (derived ids, relative +/// refs, sections); whoever simulates reads plain JSON. +pub fn sim_model( + file: &NeedsFile, + questions: &Questions, + aggregates: &HashMap, +) -> serde_json::Value { + use serde_json::json; + let mut needs = Vec::new(); + let mut buckets = serde_json::Map::new(); + for need in &file.needs { + let Some(path) = path_for(questions, need) else { + continue; + }; + let steps: Vec = path + .iter() + .enumerate() + .map(|(i, step)| { + let page = &questions[&step.page]; + let mut trail: Vec = path[..=i].iter().map(|s| s.page.clone()).collect(); + let expect: Vec = + viable(questions, need, page, need.max_steps - i, i == 0, false, &mut trail) + .into_iter() + .map(|v| v.alternative) + .collect(); + let fields: Vec = page + .alternatives + .iter() + .find(|a| a.name == step.alternative) + .into_iter() + .flat_map(|a| &a.features) + .flat_map(|f| &f.requirements) + .map(|r| { + json!({ + "name": r.name, + "label": r.label.clone().unwrap_or_else(|| r.name.clone()), + "type": r.kind, + "optional": r.optional, + "options": r.options, + }) + }) + .collect(); + json!({ + "page": step.page, + "question": page.name, + "context": page_context(page), + "options": page.alternatives.iter().filter(|a| !a.disabled && leads_somewhere(a)).map(|a| json!({ + "name": a.name, + "description": plain(&a.description), + })).collect::>(), + "expect": expect, + "alternative": step.alternative, + "fields": fields, + }) + }) + .collect(); + needs.push(json!({ + "id": need.id, + "who": need.who, + "wants": need.wants, + "enters_as": need.enters_as, + "lands_in": need.lands_in, + "handled_by": need.handled_by, + "done_when": need.done_when, + "success": need.success, + "steps": steps, + })); + if let Some(schema) = aggregates.get(&need.lands_in) { + buckets.entry(need.lands_in.clone()).or_insert_with(|| { + json!({ + "initial": schema.initial, + "transitions": schema.transitions, + "words": need.stage_words, + "desks": desks_over(questions, &need.lands_in).iter().map(|d| json!({ + "page": d.page, + "group": d.group, + "moves": d.moves.iter().map(|(from, to, label)| json!({ + "from": from, "to": to, "label": label, + })).collect::>(), + })).collect::>(), + "self_moves": self_moves(questions, &need.lands_in), + }) + }); + } + } + json!({ + "needs": needs, + "buckets": buckets, + "personas": file.personas.iter().map(|p| json!({ + "id": p.id, "need": p.need, "text": plain(&p.text), + })).collect::>(), + }) +} + +/// One decision for the answering engine - the shape is laya's +/// `predict(state, questions)` input, so an adapter is a loop and +/// nothing more; any other engine reads the same fields. +#[derive(Debug, Serialize)] +pub struct Task { + pub id: String, + pub persona: String, + pub need: String, + pub page: String, + pub state: BTreeMap<&'static str, String>, + pub questions: BTreeMap<&'static str, ChoiceQuestion>, + /// Every alternative that still meets the need from here. The + /// engine never sees this. + pub expect: Vec, +} + +#[derive(Debug, Serialize)] +pub struct ChoiceQuestion { + #[serde(rename = "type")] + pub kind: &'static str, + pub instructions: String, + pub criteria: BTreeMap, +} + +fn plain(text: &str) -> String { + text.split_whitespace().collect::>().join(" ") +} + +/// A card a visitor can act on: it leads to a page, records an +/// answer, or moves a record. A card with none of those is read, not +/// picked - the opener that says what the business does, the look +/// around - and is context on the page, never an option. +pub fn leads_somewhere(a: &Alternative) -> bool { + a.action.is_some() || a.record_as.is_some() || a.self_transition.is_some() +} + +/// What the page says before its options: every card that leads +/// nowhere, its heading, description and features, as one line each. +pub fn page_context(page: &Question) -> String { + page.alternatives + .iter() + .filter(|a| !a.disabled && !leads_somewhere(a)) + .map(|a| { + let mut line = a.name.clone(); + let description = plain(&a.description); + if !description.is_empty() { + line.push_str(&format!(": {description}")); + } + for f in &a.features { + let text = plain(&f.description); + match (f.name.trim().is_empty(), text.is_empty()) { + (true, true) => {} + (true, false) => line.push_str(&format!(" {text}")), + (false, true) => line.push_str(&format!(" {}.", f.name.trim())), + (false, false) => line.push_str(&format!(" {}: {text}", f.name.trim())), + } + } + line + }) + .collect::>() + .join("\n") +} + +/// Every (persona, page-on-their-path) pair with a real choice on it. +/// `skim` shows the engine only each alternative's heading - what a +/// visitor who never reads the description has to go on. +pub fn tasks(file: &NeedsFile, questions: &Questions, skim: bool) -> Vec { + let mut out = Vec::new(); + for persona in &file.personas { + let Some(need) = file.needs.iter().find(|n| n.id == persona.need) else { + continue; + }; + let Some(path) = path_for(questions, need) else { + continue; + }; + for (i, step) in path.iter().enumerate() { + let page = &questions[&step.page]; + let open: Vec<&Alternative> = page.alternatives.iter().filter(|a| !a.disabled && leads_somewhere(a)).collect(); + if open.len() < 2 { + continue; + } + let context = page_context(page); + let mut trail: Vec = path[..=i].iter().map(|s| s.page.clone()).collect(); + let expect: Vec = viable(questions, need, page, need.max_steps - i, i == 0, false, &mut trail) + .into_iter() + .map(|v| v.alternative) + .collect(); + let criteria = open + .iter() + .map(|a| { + let description = plain(&a.description); + ( + a.name.clone(), + if skim || description.is_empty() { a.name.clone() } else { description }, + ) + }) + .collect(); + out.push(Task { + id: format!("{}@{}", persona.id, step.page), + persona: persona.id.clone(), + need: need.id.clone(), + page: step.page.clone(), + state: if context.is_empty() { + BTreeMap::from([("body", plain(&persona.text))]) + } else { + BTreeMap::from([("body", plain(&persona.text)), ("page", context)]) + }, + questions: BTreeMap::from([( + "pick", + ChoiceQuestion { + kind: "choice", + instructions: format!( + "A visitor reads the page question {:?}. Which alternative do they choose?", + page.name + ), + criteria, + }, + )]), + expect, + }); + } + } + out +} + +#[derive(Debug, Deserialize)] +pub struct Answer { + pub id: String, + pub choice: String, + #[serde(default)] + pub confidence: Option, +} + +#[derive(Debug, Default)] +pub struct Score { + pub total: usize, + pub correct: usize, + /// (task id, picked, expected, confidence) + pub misroutes: Vec<(String, String, Vec, Option)>, + /// Right, but the engine was under `hesitant_below` - wording that + /// works and is still close to a coin flip. + pub hesitant: Vec<(String, f64)>, + pub unanswered: Vec, +} + +impl Score { + pub fn accuracy(&self) -> f64 { + if self.total == 0 { + 1.0 + } else { + self.correct as f64 / self.total as f64 + } + } +} + +pub fn score(tasks: &[Task], answers: &[Answer], hesitant_below: f64) -> Score { + let by_id: HashMap<&str, &Answer> = answers.iter().map(|a| (a.id.as_str(), a)).collect(); + let mut score = Score { + total: tasks.len(), + ..Score::default() + }; + for task in tasks { + match by_id.get(task.id.as_str()) { + None => score.unanswered.push(task.id.clone()), + Some(answer) if task.expect.contains(&answer.choice) => { + score.correct += 1; + if let Some(c) = answer.confidence.filter(|c| *c < hesitant_below) { + score.hesitant.push((task.id.clone(), c)); + } + } + Some(answer) => score.misroutes.push(( + task.id.clone(), + answer.choice.clone(), + task.expect.clone(), + answer.confidence, + )), + } + } + score +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::aggregates::parse_aggregates_yaml; + use crate::content::build_questions; + + fn site(desk_moves: &str) -> (Questions, HashMap) { + let files = vec![ + ( + "index.yaml".to_string(), + "name: What are you here for?\nresponsible: { name: A, contact: a@b.c }\nalternatives:\n - name: Hire us\n description: Bring a problem.\n action: /thanks\n record_as: projects\n features:\n - name: Brief\n requirements:\n - name: email\n type: email\n - name: brief\n type: textarea\n - name: budget\n optional: true\n - name: Follow along\n description: Notes by mail.\n action: /thanks\n record_as: subscribers\n".to_string(), + ), + ("thanks.yaml".to_string(), "name: Thanks\nfollowup: true\n".to_string()), + ( + "desk/index.yaml".to_string(), + format!("name: Desk\nqualifies: owners\nalternatives:\n - name: Projects\n features:\n - name: \"\"\n resource:\n source: {{ kind: kv, bucket: projects }}\n requires_group: owners\n transitions:\n{desk_moves}"), + ), + ]; + let aggregates = parse_aggregates_yaml( + "aggregates:\n - bucket: projects\n initial: open\n states:\n open: { event: submitted }\n quoted: { event: quoted }\n won: { event: won }\n lost: { event: lost }\n transitions:\n open: [quoted, lost]\n quoted: [won, lost]\n - bucket: subscribers\n attended_by: a mailer\n initial: open\n states:\n open: { event: subscribed }\n transitions: {}\n", + ) + .unwrap(); + (build_questions(&files).unwrap(), aggregates) + } + + const FULL_DESK: &str = " - { to: quoted, label: Quote }\n - { to: lost, label: Lose }\n - { from: quoted, to: won, label: Win }\n - { from: quoted, to: lost, label: Lose }\n"; + + fn needs(extra: &str) -> NeedsFile { + parse(&format!( + "needs:\n - id: hire\n who: a client\n wants: work done\n lands_in: projects\n handled_by: owners\n done_when: [won, lost]\n{extra}personas:\n - id: founder\n need: hire\n text: I need someone to build our booking tool.\n" + )) + .unwrap() + } + + fn fails(findings: &[Finding]) -> Vec<&str> { + findings + .iter() + .filter(|f| f.level == Level::Fail) + .map(|f| f.message.as_str()) + .collect() + } + + #[test] + fn a_met_need_is_silent() { + let (questions, aggregates) = site(FULL_DESK); + let findings = check(&needs(" max_fields: 2\n"), &questions, &aggregates); + assert!(findings.is_empty(), "{findings:?}"); + } + + #[test] + fn effort_budget_counts_required_fields_only() { + let (questions, aggregates) = site(FULL_DESK); + let findings = check(&needs(" max_fields: 1\n"), &questions, &aggregates); + assert_eq!(fails(&findings), vec!["the path asks for 2 required field(s), over the budget of 1"]); + // A field that carries rather than asks is not effort: the + // record an answer is about arrives on the link. + let (mut questions, aggregates) = site(FULL_DESK); + let front = questions.get_mut("/").unwrap(); + let brief = &mut front.alternatives[0].features[0].requirements; + let mut carrier = brief[0].clone(); + carrier.name = "case".into(); + carrier.kind = "record".into(); + carrier.of = Some("projects".into()); + carrier.optional = false; + brief.push(carrier); + let findings = check(&needs(" max_fields: 2\n"), &questions, &aggregates); + assert!(fails(&findings).is_empty(), "{findings:?}"); + } + + #[test] + fn a_desk_missing_a_move_strands_records_and_cannot_finish() { + // The desk can quote, but never close a quoted job. + let (questions, aggregates) = site(" - { to: quoted, label: Quote }\n - { to: lost, label: Lose }\n"); + let findings = check(&needs(""), &questions, &aggregates); + assert_eq!( + fails(&findings), + vec![ + "\"owners\" cannot move a record to \"won\" - no desk of theirs offers the moves", + "records strand in \"quoted\": \"owners\" is offered no move out of it", + ] + ); + } + + #[test] + fn a_finished_state_may_lead_on_to_another() { + // quoted counts as handled here, yet won must still be + // reachable past it; lost-after-quoted is not this need's + // business, so nothing strands. + let (questions, aggregates) = site(FULL_DESK); + let mut file = needs(""); + file.needs[0].done_when = vec!["quoted".to_string(), "won".to_string(), "lost".to_string()]; + let findings = check(&file, &questions, &aggregates); + assert!(findings.is_empty(), "{findings:?}"); + } + + #[test] + fn a_disabled_alternative_is_named_as_the_reason() { + let (mut questions, aggregates) = site(FULL_DESK); + questions.get_mut("/").unwrap().alternatives[0].disabled = true; + let findings = check(&needs(""), &questions, &aggregates); + assert_eq!( + fails(&findings), + vec!["the path exists but is closed: \"Hire us\" on / is `disabled`, so the answer is advertised and refused"] + ); + } + + #[test] + fn success_must_be_a_finished_state_and_the_sim_model_carries_the_whole_flow() { + let (questions, aggregates) = site(FULL_DESK); + let mut file = needs(" success: [won]\n"); + assert!(check(&file, &questions, &aggregates).is_empty()); + + let model = sim_model(&file, &questions, &aggregates); + let need = &model["needs"][0]; + assert_eq!(need["success"][0], "won"); + let step = &need["steps"][0]; + assert_eq!(step["question"], "What are you here for?"); + assert_eq!(step["expect"][0], "Hire us"); + assert_eq!(step["options"].as_array().unwrap().len(), 2); + // The form the visitor fills, required and optional alike. + let fields = step["fields"].as_array().unwrap(); + assert_eq!(fields.len(), 3); + assert_eq!(fields[2]["optional"], true); + // The desk's buttons, labelled, per state. + let moves = model["buckets"]["projects"]["desks"][0]["moves"].as_array().unwrap(); + assert!(moves.iter().any(|m| m["from"] == "quoted" && m["to"] == "won" && m["label"] == "Win")); + assert_eq!(model["buckets"]["projects"]["initial"], "open"); + assert_eq!(model["personas"][0]["id"], "founder"); + + // A stage the business named must exist as a state. + file.needs[0].stages = vec!["open".into(), "surveyed".into(), "quoted".into()]; + assert_eq!( + fails(&check(&file, &questions, &aggregates)), + vec!["the business describes a stage \"surveyed\" that \"projects\" has no state for"] + ); + file.needs[0].stages = vec!["open".into(), "quoted".into(), "won".into()]; + assert!(check(&file, &questions, &aggregates).is_empty()); + + file.needs[0].success = vec!["quoted".to_string()]; + assert_eq!( + fails(&check(&file, &questions, &aggregates)), + vec!["success names \"quoted\", which is not one of done_when"] + ); + } + + #[test] + fn a_need_can_be_carried_by_more_than_one_groups_desk() { + // owners may only quote or lose; closing a quoted job is the + // site managers' button, on a desk of their own. + let (mut questions, aggregates) = site(" - { to: quoted, label: Quote }\n - { to: lost, label: Lose }\n"); + let more = build_questions(&[( + "site/index.yaml".to_string(), + "name: Site desk\nqualifies: site_managers\nalternatives:\n - name: Projects\n features:\n - name: \"\"\n resource:\n source: { kind: kv, bucket: projects }\n requires_group: site_managers\n transitions:\n - { from: quoted, to: won, label: Win }\n - { from: quoted, to: lost, label: Lose }\n".to_string(), + )]) + .unwrap(); + questions.extend(more); + + let mut file = needs(""); + // Alone, owners cannot finish it... + assert_eq!(fails(&check(&file, &questions, &aggregates)).len(), 2); + // ...with the site managers' buttons counted, they can. + file.needs[0].also_moved_by = vec!["site_managers".to_string()]; + assert!(check(&file, &questions, &aggregates).is_empty()); + // But someone has to be answerable: handled_by needs its own desk. + file.needs[0].handled_by = "nobody".to_string(); + assert!(fails(&check(&file, &questions, &aggregates))[0].starts_with("no desk gated on \"nobody\"")); + } + + #[test] + fn the_wrong_group_holding_the_desk_is_a_failure() { + let (questions, aggregates) = site(FULL_DESK); + let mut file = needs(""); + file.needs[0].handled_by = "sales".to_string(); + let findings = check(&file, &questions, &aggregates); + assert_eq!( + fails(&findings), + vec!["no desk gated on \"sales\" reads \"projects\" - it is read by /desk (owners)"] + ); + } + + #[test] + fn via_pins_the_entry_alternative() { + let (questions, aggregates) = site(FULL_DESK); + let mut file = needs(""); + file.needs[0].via = Some("Follow along".to_string()); + let findings = check(&file, &questions, &aggregates); + assert_eq!( + fails(&findings), + vec!["no path from \"/\" via \"Follow along\" records into \"projects\" within 2 submission(s)"] + ); + } + + #[test] + fn tasks_carry_the_page_as_a_choice_and_score_separates_misroutes() { + let (questions, _) = site(FULL_DESK); + let file = needs(""); + let tasks = tasks(&file, &questions, false); + let skimmed = super::tasks(&file, &questions, true); + assert_eq!(skimmed[0].questions["pick"].criteria["Follow along"], "Follow along"); + assert_eq!(tasks.len(), 1); + let task = &tasks[0]; + assert_eq!(task.id, "founder@/"); + assert_eq!(task.expect, vec!["Hire us"]); + let q = &task.questions["pick"]; + assert_eq!(q.kind, "choice"); + assert_eq!(q.criteria["Follow along"], "Notes by mail."); + assert!(q.instructions.contains("What are you here for?")); + + let right = vec![Answer { id: "founder@/".into(), choice: "Hire us".into(), confidence: Some(0.55) }]; + let s = score(&tasks, &right, 0.6); + assert_eq!((s.correct, s.misroutes.len(), s.hesitant.len()), (1, 0, 1)); + + let wrong = vec![Answer { id: "founder@/".into(), choice: "Follow along".into(), confidence: Some(0.9) }]; + let s = score(&tasks, &wrong, 0.6); + assert_eq!((s.correct, s.misroutes.len()), (0, 1)); + assert_eq!(s.accuracy(), 0.0); + + assert_eq!(score(&tasks, &[], 0.6).unanswered, vec!["founder@/"]); + } +} diff --git a/src/lint/replay.rs b/src/lint/replay.rs new file mode 100644 index 0000000..dc6f09d --- /dev/null +++ b/src/lint/replay.rs @@ -0,0 +1,105 @@ +//! Makes a simulation's cases real: every case becomes an aggregate in +//! its bucket, created and moved by the same code the running site +//! uses - `answers::store_answer` for the submission, +//! `aggregates::transition` for each desk move, against a real +//! JetStream with its event log and its refusal of undeclared moves. +//! Then the buckets are read back by replaying each record's events, +//! and reported per need against `done_when` and `success`. +//! +//! The report half runs on its own (`--report-only`) against any +//! JetStream, which is how the same scorecard is taken from a live +//! site's buckets. +//! +//! Point it at a throwaway NATS, never a site's own: it writes. + +use std::collections::{BTreeMap, HashMap}; + +use futures::StreamExt; +use crate::events::store::{ensure_stream, load_events}; +use crate::lint::local::{load_aggregates_from_dir, load_from_dir, load_needs_from_dir}; +use crate::lint::needs; +use crate::{aggregates, answers, content}; + +pub async fn run(argv: Vec) -> anyhow::Result<()> { + let value = |flag: &str| argv.iter().position(|a| a == flag).and_then(|i| argv.get(i + 1)).cloned(); + let usage = "usage: iris replay --path --nats (--cases | --report-only)"; + let dir = value("--path").ok_or_else(|| anyhow::anyhow!(usage))?; + let nats_url = value("--nats").ok_or_else(|| anyhow::anyhow!(usage))?; + let cases_path = value("--cases"); + if cases_path.is_none() && !argv.iter().any(|a| a == "--report-only") { + anyhow::bail!(usage); + } + + let questions = load_from_dir(&dir)?; + let schemas = content::with_builtin_aggregates(load_aggregates_from_dir(&dir)?); + let file = needs::parse(&load_needs_from_dir(&dir)?.ok_or_else(|| anyhow::anyhow!("the repo has no needs.yaml"))?)?; + let js = async_nats::jetstream::new(async_nats::connect(&nats_url).await?); + // The same call the site makes at boot. + ensure_stream(&js).await?; + + if let Some(path) = cases_path { + let raw = std::fs::read_to_string(&path).map_err(|e| anyhow::anyhow!("reading {path}: {e}"))?; + let (mut created, mut moved, mut refused) = (0, 0, 0); + for (n, line) in raw.lines().filter(|l| !l.trim().is_empty()).enumerate() { + let case: serde_json::Value = serde_json::from_str(line)?; + let Some(need) = file.needs.iter().find(|need| case["need"] == need.id.as_str()) else { continue }; + let Some(schema) = schemas.get(&need.lands_in) else { continue }; + // Only a case that got as far as submitting leaves a record. + let states: Vec<&str> = case["states"].as_array().into_iter().flatten().filter_map(|s| s.as_str()).collect(); + if states.is_empty() { + continue; + } + let step = needs::path_for(&questions, need).and_then(|p| p.last().cloned()); + let (page, alternative) = step.map(|s| (s.page, s.alternative)).unwrap_or_default(); + let id = format!("sim-{}-{}-{n}", case["persona"].as_str().unwrap_or("x"), case["run"]); + let now = chrono::Utc::now().timestamp_millis(); + answers::store_answer(&js, &schemas, &need.lands_in, id.clone(), &page, &alternative, &case["record"], now, 0).await?; + created += 1; + for target in &states[1..] { + match aggregates::transition(&js, schema, &id, target, serde_json::json!({"by": "simulation"}), now).await { + Ok(_) => moved += 1, + Err(e) => { + refused += 1; + eprintln!("REFUSED by the engine: {id} -> {target}: {e:?}"); + break; + } + } + } + } + println!("{created} aggregate(s) created, {moved} transition(s) accepted, {refused} refused by the engine\n"); + } + + // Read every bucket back the way the engine knows it: by replaying + // each record's own event log. + let mut by_bucket: HashMap> = HashMap::new(); + for bucket in file.needs.iter().map(|n| n.lands_in.clone()).collect::>() { + let (Some(schema), Ok(store)) = (schemas.get(&bucket), js.get_key_value(&bucket).await) else { continue }; + let mut keys = store.keys().await?; + while let Some(Ok(id)) = keys.next().await { + let events = load_events(&js, &bucket, &id).await?; + if let Some(record) = aggregates::replay(schema, &id, &events) { + *by_bucket.entry(bucket.clone()).or_default().entry(record.state).or_default() += 1; + } + } + } + println!("{:<18} {:>8} {:>9} {:>9} {}", "bucket", "records", "finished", "success", "by state"); + let mut buckets: Vec<_> = by_bucket.iter().collect(); + buckets.sort(); + for (bucket, states) in buckets { + let needs_here: Vec<&needs::Need> = file.needs.iter().filter(|n| &n.lands_in == bucket).collect(); + let is = |pick: fn(&needs::Need) -> &Vec, state: &String| needs_here.iter().any(|n| pick(n).contains(state)); + let total: usize = states.values().sum(); + let finished: usize = states.iter().filter(|(s, _)| is(|n| &n.done_when, s)).map(|(_, c)| c).sum(); + let success: usize = states.iter().filter(|(s, _)| is(|n| &n.success, s)).map(|(_, c)| c).sum(); + let pct = |n: usize| if total == 0 { "-".to_string() } else { format!("{:.0}%", n as f64 * 100.0 / total as f64) }; + println!( + "{:<18} {:>8} {:>9} {:>9} {}", + bucket, + total, + pct(finished), + pct(success), + states.iter().map(|(s, c)| format!("{s} {c}")).collect::>().join(", ") + ); + } + Ok(()) +} diff --git a/src/main.rs b/src/main.rs index 7e94b3e..5aaf366 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,31 @@ +/// One binary, three jobs: `portal` (or `portal serve`) runs a site; +/// `portal lint` checks a content repo the way the site reads it; +/// `portal plan`, `build`, `report` and the rest train one. The lint +/// and the trainer are the same code as the server, at the same +/// version, so they never disagree about what content means. #[cfg(feature = "ssr")] #[tokio::main] async fn main() -> anyhow::Result<()> { + let argv: Vec = std::env::args().skip(1).collect(); + match argv.first().map(String::as_str) { + Some("lint") => return portal::lint::run(argv[1..].to_vec()).await, + Some("plan" | "build" | "answer" | "rate" | "prefer" | "score" | "simulate" | "compare" | "report" | "generate") => { + // The trainer runs models through shell commands and waits + // on them; off the runtime's threads, as a CLI should. + return tokio::task::spawn_blocking(move || portal::trainer::run(argv)).await?; + } + Some("--version" | "-V") => { + println!("portal v{}", env!("CARGO_PKG_VERSION")); + return Ok(()); + } + Some("serve") | None => {} + Some(other) => anyhow::bail!("portal: unknown command {other:?}; one of serve, lint, plan, build, answer, rate, prefer, score, simulate, compare, report, generate"), + } + serve().await +} + +#[cfg(feature = "ssr")] +async fn serve() -> anyhow::Result<()> { use axum::{body::Body, extract::State, http::Request, response::IntoResponse, routing::{any, get, post}, Router}; use leptos::prelude::*; use leptos_axum::{generate_route_list, LeptosRoutes}; diff --git a/src/trainer/build.rs b/src/trainer/build.rs new file mode 100644 index 0000000..7d63bc1 --- /dev/null +++ b/src/trainer/build.rs @@ -0,0 +1,3473 @@ +//! Structure from the plan, words from the model. +//! +//! A `plan.yaml` is a needs spec that also says what each form collects. +//! From it, with no model involved, come `needs.yaml`, +//! `aggregates.yaml`, `site.yaml`, the front door, a followup page per +//! need, a directory per group (gated by `_section.yaml`) holding its +//! desk and the pages only that group may open, and every desk button +//! the state machine allows. What the business said determines all of +//! that; a model has nothing to decide there and much to get wrong +//! (measured: a dropped stage from a strong model, no loadable site at +//! all from a small one). +//! +//! The model writes the words: one small JSON object per page - the +//! question, headings, descriptions, field and button labels - in the +//! plan's language. Words are merged over built-in fallbacks key by +//! key, so a reply that is missing, malformed or partial costs wording, +//! never a working site. + +use std::collections::{BTreeMap, BTreeSet}; +use std::path::Path; + +use anyhow::{anyhow, bail, Context}; +use serde::Deserialize; +use serde_json::{json, Map, Value}; + +use crate::trainer::simulate::first_object; + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Plan { + pub business: String, + #[serde(default = "english")] + pub language: String, + pub contact: Contact, + /// Where the site keeps its people: `kanidm` (the default) makes an + /// account per invite and holds the groups; `memberships` keeps the + /// groups in portal (its `portal_memberships` bucket) and lets any + /// sign-in provider say who someone is - a person is then their + /// phone number first, so invite forms ask for one. + #[serde(default = "kanidm")] + pub people: String, + /// The storylines the site is told in, one tone of voice each: + /// "plain and brief, like a good notice on the door", "warm, as a + /// neighbour would put it". The words model writes every slot once + /// per tone, the content carries the words as lists, and portal + /// gives each visitor one storyline and counts what each brought + /// in (portal docs, Storylines). One tone, or none, is one story. + #[serde(default)] + pub tones: Vec, + /// The sender of every mail the site sends (`mail:` in site.yaml). + /// Required as soon as any need has `mails:`. + #[serde(default)] + pub mail: Option, + /// What the business does, in its own words - context for the + /// model's wording, never structure. + #[serde(default)] + pub about: String, + /// A first question before the forms. Each door is one alternative + /// on the front door leading to a page of its own, where the needs + /// that name it sit. Five or six needs on one page is more than a + /// visitor sorts through; two or three doors of two or three each + /// is what the page-load limit asks for. + #[serde(default)] + pub doors: Vec, + /// Groups with a label the public may see: the committees a + /// person can ask to join. A select field with `options_from: + /// groups` offers exactly these, so the form and the desks can + /// never disagree about what the committees are. + #[serde(default)] + pub groups: Vec, + /// Groups that may invite people into every group with a desk (the + /// board), besides each group inviting into itself. An inviting + /// group's desk gets one invite form per group it may fill (portal + /// creates the account and a one-time credential link) and a list + /// of the invites sent. This is onboarding from the content side: + /// no admin session, a member of the business vouching for a person. + #[serde(default)] + pub invites_by: Vec, + /// What the business does, said plainly, one feature each: the + /// front door's first card, which asks for nothing. A stranger + /// reads it and decides whether to trust the place before any + /// button is offered. Empty means one feature per public need, + /// from its `wants`. + #[serde(default)] + pub does: Vec, + /// Things a visitor may look at before asking for anything - the + /// window shopping: a list of what has been built, releases, a + /// live count - each a portal resource block, passed through as + /// written. Public views of buckets (`watched_by: [public]`) are + /// listed here on their own. + #[serde(default)] + pub shows: Vec, + pub needs: Vec, + #[serde(default)] + pub personas: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Does { + pub id: String, + /// The fallback description, in the business's words. + pub text: String, + /// The need this feature speaks for: one role, so the opener is + /// one or two roles meeting, not a catalogue. + #[serde(default, rename = "for")] + pub for_need: Option, +} + +/// Features the opener may carry. One or two, each for a role: past +/// that the opener is a menu again, and a menu is what it is there +/// to spare the visitor. +const MAX_OPENER: usize = 2; + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Show { + pub id: String, + #[serde(default)] + pub text: String, + /// Portal's `resource:` block for a feature, verbatim (source, + /// jq, public, empty). The lint holds it to portal's schema. + pub resource: Value, +} + +fn english() -> String { + "en".to_string() +} + +/// `moves_by: { checking: submitter }` is what a model writes for a +/// single mover, and means the same as `[submitter]`; a bare word or +/// a list are both read. +fn one_or_many_by_key<'de, D: serde::Deserializer<'de>>(d: D) -> Result>, D::Error> { + #[derive(Deserialize)] + #[serde(untagged)] + enum OneOrMany { + One(String), + Many(Vec), + } + let raw: BTreeMap = BTreeMap::deserialize(d)?; + Ok(raw + .into_iter() + .map(|(k, v)| (k, match v { OneOrMany::One(s) => vec![s], OneOrMany::Many(l) => l })) + .collect()) +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct GroupDef { + pub id: String, + /// The name the public knows the group by; empty means the id + /// made readable. + #[serde(default)] + pub label: String, + /// Groups that may invite people into this one, besides itself and + /// the plan's `invites_by`. + #[serde(default)] + pub invited_by: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Door { + pub id: String, + /// Who comes through it, in plain words: context for the model's + /// heading, and the fallback description. + pub who: String, +} + +#[derive(Debug, Deserialize)] +pub struct Contact { + pub name: String, + pub email: String, + /// The group the contact is: a board's inbox, not a person. Then + /// portal makes no account for the address, and every member of + /// the group may suggest changes to the pages it answers for. + #[serde(default)] + pub group: Option, +} + +/// Who the site's mail comes from: portal sends nothing without it, +/// and gdo on the host has to be allowed to send for the domain. +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PlanMail { + pub from: String, + #[serde(default)] + pub reply_to: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PlanNeed { + pub id: String, + pub who: String, + pub wants: String, + #[serde(default)] + pub enters_as: Option, + /// The door this public need sits behind; none means straight on + /// the front door. With `enters_as` too, the door carries a card + /// that leads to the group's page, where the form is: a seat on a + /// committee is offered to everyone and open to members. + #[serde(default)] + pub door: Option, + /// One answer per record of another need: a neighbour answering a + /// notice on the board. The form sits on a page of its own per + /// record (`/status//[key]`), reached from that record's + /// row on the public list, and carries the record's id back in, + /// so `mails: { open: owner }` reaches whoever sent that record. + /// The other need must be `watched_by: [public]` and collect an + /// `email` for `owner` to go anywhere. + #[serde(default)] + pub about: Option, + pub lands_in: String, + pub handled_by: String, + /// Every stage, first to last, finished states included. + pub stages: Vec, + pub done_when: Vec, + #[serde(default)] + pub success: Vec, + /// Stages a case may pass over ("awaiting papers" when the papers + /// were complete). + #[serde(default)] + pub skippable: Vec, + /// The unfinished stages a case may still be turned down from; + /// empty means every one. An applicant is declined before they + /// start and not after, where they depart instead. + #[serde(default)] + pub turned_down_from: Vec, + /// Where a stage is entered from, when the line does not say it: + /// `{ incomplete: [complete, on_hearing] }` sends a case back for + /// papers it was already past, and `{ changed_by_us: [reconsidered] }` + /// puts a good ending somewhere other than the end of the line. + /// Naming a stage here replaces the moves into it that the line + /// would have given, and only those - every other stage keeps its + /// own. A regulated case needs both: an authority may refuse only + /// once the papers are complete, while the applicant may withdraw + /// from anywhere, and the two cannot be one list. + #[serde(default)] + #[serde(deserialize_with = "one_or_many_by_key")] + pub reached_from: BTreeMap>, + /// What the person must already have or be for this alternative to + /// work: a membership, a reference number, the link from a mail, + /// an account. Stated on the card before the form, because a + /// condition a reader meets only after filling something in has + /// already cost them the filling in. Empty when there is none. + #[serde(default)] + pub requires: String, + /// Who presses the button that moves a case INTO a stage, where it + /// is not `handled_by`: another group, several groups, or + /// `submitter` - the person who sent the form, through their own + /// link (the form must then collect a required `email`). This is + /// how roles overlap on one aggregate: a project manager prices a + /// change, the client approves it, a site manager completes it. + #[serde(default)] + #[serde(deserialize_with = "one_or_many_by_key")] + pub moves_by: BTreeMap>, + /// Groups that see these cases on their desk and move nothing. + #[serde(default)] + pub watched_by: Vec, + /// The event name a stage is entered by, where it must not be the + /// builder's default (the stage's own name; `received` for the + /// first). A live site replays its records by event name, so a + /// rebuild of a site that already holds records has to keep the + /// names its aggregates.yaml has, or every record vanishes from + /// its desk. + #[serde(default)] + pub events: BTreeMap, + /// Each stage in plain words, in the plan's language, where the + /// id will not do: ids are ASCII, so a Norwegian `ikke_lost` reads + /// as English in a mail, a road line or a desk score. Used by the + /// fallback mails, the "what happens next" line, the desk + /// buttons, and written into needs.yaml for the scorer. + #[serde(default)] + pub stages_text: BTreeMap, + /// Who moves cases that no desk button moves - an automation + /// listening on the bucket's events. Written into aggregates.yaml + /// for the reader, as portal's `attended_by` annotation. + #[serde(default)] + pub attended_by: Option, + /// Who hears when a case enters a stage: `submitter` (the person + /// who sent the form, at the email it collected), `owner` (whoever + /// sent the record this need is `about`) or one address (the + /// treasurer, the board). Portal sends it the moment the case + /// moves; the words model writes the subject and body. + #[serde(default)] + pub mails: BTreeMap, + /// A stage that makes the person a member of a group when their + /// case arrives there (portal's `grants:`): an approval that means + /// membership, with no second step to forget. The form must then + /// collect a required `name` and `email`, since by the time a desk + /// presses approve there is nobody left to ask. + #[serde(default)] + pub grants: BTreeMap, + /// A stage a clock moves on from (portal's `after:`): a case left + /// in it that many days, measured from its last move, goes on to + /// `to`. Only along a move the state machine already has; a clock + /// may do only what a person could have done. + #[serde(default)] + pub deadlines: BTreeMap, + /// A stage a case may not sit in unattended: after that many days + /// it goes up to another group. `{ open: { days: 7, to: styret } }` + /// adds a stage `open_escalated`, entered by the clock, with the + /// same moves out of it as `open` has, and a card on styret's desk + /// showing exactly those cases with those buttons. The desk that + /// had it keeps it too, and may press the move itself. A stage can + /// escalate or end on a deadline, not both. + #[serde(default)] + pub escalate: BTreeMap, + /// With `watched_by: [public]`, the stages the public list shows; + /// empty shows all. A noticeboard shows what is up, not what was + /// taken down; a register shows what was decided. + #[serde(default)] + pub public_states: Vec, + pub collects: Vec, + /// How many required fields this person will tolerate: what the + /// business said about them, not what the form happens to ask. + /// The form is refused when it asks for more, and the verifier is + /// told this number, so the check means something after handover. + pub max_fields: usize, +} + +#[derive(Debug, Deserialize, Clone)] +#[serde(deny_unknown_fields)] +pub struct Field { + pub name: String, + #[serde(default = "text_kind", rename = "type")] + pub kind: String, + #[serde(default)] + pub optional: bool, + #[serde(default)] + pub options: Vec, + /// `select` only: pick any number instead of exactly one. + #[serde(default)] + pub multiple: bool, + /// `select` only: `groups` takes the options from the plan's + /// labelled groups instead of a list here. + #[serde(default)] + pub options_from: Option, + /// Never shown on a public view of the bucket. `email` and `tel` + /// fields are private whether or not this is set. + #[serde(default)] + pub private: bool, +} + +impl Field { + /// What a public listing may show: everything but contact details + /// and what the plan marks private. + pub fn is_public(&self) -> bool { + !self.private && self.kind != "email" && self.kind != "tel" + } +} + +#[derive(Debug, Deserialize, Clone, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct Deadline { + pub days: u32, + pub to: String, +} + +/// After `days` in a stage, up to group `to` (see `PlanNeed.escalate`). +#[derive(Debug, Deserialize, Clone, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct Escalation { + pub days: u32, + pub to: String, +} + +/// The stage a case escalates into from `stage`. +pub fn escalated_stage(stage: &str) -> String { + format!("{stage}_escalated") +} + +/// The name a plan uses in `watched_by` for everyone: a read-only page +/// of the bucket, no login. +pub const PUBLIC: &str = "public"; + +fn text_kind() -> String { + "text".to_string() +} + +fn kanidm() -> String { + "kanidm".to_string() +} + +impl Plan { + fn memberships(&self) -> bool { + self.people == "memberships" + } +} + +const FIELD_TYPES: &[&str] = &["text", "email", "tel", "textarea", "date", "select", "location"]; + +fn is_id(s: &str) -> bool { + !s.is_empty() && s.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-') +} + +impl Plan { + pub fn validate(&self) -> anyhow::Result<()> { + if !["kanidm", "memberships"].contains(&self.people.as_str()) { + bail!("people must be `kanidm` or `memberships`, not {:?}", self.people); + } + if self.tones.iter().any(|t| t.trim().is_empty()) { + bail!("tones: every tone says something about the voice; an empty one says nothing"); + } + // A contact that is a group has to be one with a desk, or + // nobody could sign in as it. + if let Some(group) = &self.contact.group { + if !groups(self).contains(group) { + bail!("contact: group {group:?} has no desk: it must handle, watch or move something"); + } + } + let mut doors = BTreeSet::new(); + for door in &self.doors { + if !is_id(&door.id) || !doors.insert(&door.id) { + bail!("door {:?}: id must be unique ASCII lowercase with - or _", door.id); + } + if !self.needs.iter().any(|n| n.door.as_ref() == Some(&door.id)) { + bail!("door {:?} leads nowhere: no need names it", door.id); + } + if groups(self).contains(&door.id) { + bail!("door {:?} is also a group; a door's page is public and a group's is gated", door.id); + } + if door.id == "status" || door.id == "received" || door.id == "decide" { + bail!("door {:?}: that path is taken by the builder's own pages", door.id); + } + } + let mut cards = BTreeSet::new(); + for id in self.does.iter().map(|d| &d.id).chain(self.shows.iter().map(|s| &s.id)) { + if !is_id(id) || !cards.insert(id) { + bail!("does/shows {id:?}: id must be unique ASCII lowercase with - or _"); + } + } + for show in &self.shows { + if !show.resource.is_object() || show.resource.get("source").is_none() { + bail!("shows {:?}: resource must be portal's resource block, with a source", show.id); + } + } + if self.does.len() > MAX_OPENER { + bail!("does: the opener advertises one or two features, one role each; {} is a menu", self.does.len()); + } + for d in &self.does { + if let Some(need) = &d.for_need { + if !self.needs.iter().any(|n| &n.id == need) { + bail!("does {:?}: `for` names {need:?}, which is not one of the needs", d.id); + } + } + } + let mut labelled = BTreeSet::new(); + for g in &self.groups { + if !is_id(&g.id) || !labelled.insert(&g.id) { + bail!("group {:?}: id must be unique ASCII lowercase with - or _", g.id); + } + } + for g in self.invites_by.iter().chain(self.groups.iter().flat_map(|g| g.invited_by.iter())) { + if !groups(self).contains(g) { + bail!("{g:?} may invite, but has no desk to invite from: it must handle, watch or move something"); + } + } + let mut seen = BTreeSet::new(); + for need in &self.needs { + let at = |what: &str| format!("need {:?}: {what}", need.id); + if !is_id(&need.id) || !seen.insert(&need.id) { + bail!(at("id must be unique ASCII lowercase with - or _")); + } + for id in [&need.lands_in, &need.handled_by].into_iter().chain(&need.enters_as).chain(&need.stages) { + if !is_id(id) { + bail!(at(&format!("{id:?} is an identifier and must be ASCII lowercase with - or _"))); + } + } + if need.stages.len() < 2 { + bail!(at("stages needs a first stage and at least one finished one")); + } + if let Some(door) = &need.door { + if !doors.contains(door) { + bail!(at(&format!("door {door:?} is not one of the plan's doors"))); + } + // With enters_as as well, the door only points the way: + // the form sits on the group's page, behind sign-in. + } + if let Some(about) = &need.about { + let Some(parent) = self.needs.iter().find(|n| &n.id == about) else { + bail!(at(&format!("about {about:?} is not one of the plan's needs"))); + }; + if parent.about.is_some() { + bail!(at("about names a need that itself answers another; one level is what a board is")); + } + if !parent.watched_by.iter().any(|g| g == PUBLIC) { + bail!(at(&format!("about {about:?}: its records are answered from the public list, so it needs watched_by: [public]"))); + } + if need.door.is_some() || need.enters_as.is_some() { + bail!(at("a need that answers another is reached from that one's list, not a door or a group's page")); + } + if need.mails.values().any(|to| to == "owner") && !parent.collects.iter().any(|f| f.name == "email" && f.kind == "email" && !f.optional) { + bail!(at(&format!("mails owner, but {about:?} does not collect a required email to reach them at"))); + } + } else if need.mails.values().any(|to| to == "owner") { + bail!(at("mails owner, but answers no need: owner is whoever sent the record this need is about")); + } + for (state, movers) in &need.moves_by { + if !need.stages[1..].contains(state) { + bail!(at(&format!("moves_by names {state:?}, which is not a stage a case can move into"))); + } + if movers.is_empty() || movers.iter().any(|m| !is_id(m)) { + bail!(at(&format!("moves_by {state:?} must list groups or `submitter`"))); + } + if movers.iter().any(|m| m == SUBMITTER) + && !need.collects.iter().any(|f| f.name == "email" && f.kind == "email" && !f.optional) + { + bail!(at("a move by the submitter is authorized by their email, so the form must collect a required `email` of type email")); + } + } + if need.watched_by.iter().any(|g| !is_id(g)) { + bail!(at("watched_by must list group identifiers, or `public`")); + } + // A public view is of a bucket, not of a need. A role's own + // need may be watched by the public when the public files + // into the same bucket: a permit register holds the owner's + // small application and the firm's large one, and is one + // page either way. A bucket only a role ever writes to has + // its group's desk and nothing to publish. + if need.watched_by.iter().any(|g| g == PUBLIC) + && need.enters_as.is_some() + && !self + .needs + .iter() + .any(|n| n.lands_in == need.lands_in && n.enters_as.is_none()) + { + bail!(at("a public view is for what the public sent; an internal need whose bucket the public never files into has its group's desk")); + } + for (target, froms) in &need.reached_from { + if !need.stages.contains(target) || target == &need.stages[0] { + bail!(at(&format!("reached_from names {target:?}, which is not one of its stages after the first"))); + } + if froms.is_empty() { + bail!(at(&format!("reached_from {target:?}: a stage reached from nowhere can never be entered"))); + } + for from in froms { + if !need.stages.contains(from) || need.done_when.contains(from) { + bail!(at(&format!("reached_from {target:?} names {from:?}, which is not one of its unfinished stages"))); + } + if from == target { + bail!(at(&format!("reached_from {target:?} names itself"))); + } + } + } + for state in &need.turned_down_from { + if need.done_when.contains(state) || !need.stages.contains(state) { + bail!(at(&format!("turned_down_from names {state:?}, which is not one of its unfinished stages"))); + } + } + for state in need.done_when.iter().chain(&need.skippable) { + if !need.stages.contains(state) { + bail!(at(&format!("{state:?} is not one of its stages"))); + } + } + for (state, to) in &need.mails { + if !need.stages.contains(state) { + bail!(at(&format!("mails names {state:?}, which is not one of its stages"))); + } + if to == SUBMITTER { + if !need.collects.iter().any(|f| f.name == "email" && f.kind == "email" && !f.optional) { + bail!(at("a mail to the submitter goes to the email the form collected, so the form must collect a required `email` of type email")); + } + } else if to == "owner" { + // Checked with `about` below: owner is whoever sent + // the record this need answers. + } else if !to.contains('@') || to.contains(char::is_whitespace) { + bail!(at(&format!("mails {state:?} must go to `submitter`, `owner` or one address, not {to:?}"))); + } + if self.mail.is_none() { + bail!(at("has mails, so the plan needs `mail: { from: ... }` saying who they come from")); + } + } + for (state, group) in &need.grants { + if self.mail.is_none() { + bail!(at("grants a group, so the plan needs `mail: { from: ... }`: the new member's sign-in link is sent by mail, and without a sender it reaches nobody")); + } + if !need.stages[1..].contains(state) { + bail!(at(&format!("grants names {state:?}, which is not a stage a case can move into"))); + } + if !is_id(group) || group == PUBLIC || group == SUBMITTER { + bail!(at(&format!("grants {state:?} must name a group, not {group:?}"))); + } + let required = |name: &str, kind: &str| need.collects.iter().any(|f| f.name == name && f.kind == kind && !f.optional); + if self.memberships() { + if !required("name", "text") || !(required("email", "email") || required("phone", "tel")) { + bail!(at("a stage that grants a group makes the person a member, so the form must collect a required `name` (text) and a required `phone` (type tel) or `email` (type email)")); + } + } else if !required("name", "text") || !required("email", "email") { + bail!(at("a stage that grants a group makes an account for the person, so the form must collect a required `name` (text) and a required `email` (type email)")); + } + } + for (stage, e) in &need.escalate { + if e.days == 0 { + bail!(at(&format!("escalate from {stage:?} must wait at least one day"))); + } + if !need.stages.contains(stage) || need.done_when.contains(stage) { + bail!(at(&format!("escalate names {stage:?}, which is not a stage a case waits in"))); + } + if !is_id(&e.to) || e.to == need.handled_by { + bail!(at(&format!("escalate from {stage:?} must go up to another group, not {:?}", e.to))); + } + if need.deadlines.contains_key(stage) { + bail!(at(&format!("{stage:?} has both a deadline and an escalation; a stage ends on the clock or goes up on it, not both"))); + } + } + for (state, d) in &need.deadlines { + if d.days == 0 { + bail!(at(&format!("deadline on {state:?} must wait at least one day"))); + } + if !machine(need).get(state).is_some_and(|tos| tos.contains(&d.to)) { + bail!(at(&format!("deadline on {state:?} goes to {:?}, which is not a move the state machine has from there", d.to))); + } + } + if !need.public_states.is_empty() { + if !need.watched_by.iter().any(|g| g == PUBLIC) { + bail!(at("public_states says what the public list shows, and the need has no `public` in watched_by")); + } + if let Some(bad) = need.public_states.iter().find(|s| !need.stages.contains(s)) { + bail!(at(&format!("public_states names {bad:?}, which is not one of its stages"))); + } + } + for (state, text) in &need.stages_text { + if !all_stages(need).contains(state) { + bail!(at(&format!("stages_text names {state:?}, which is not one of its stages"))); + } + if text.trim().is_empty() { + bail!(at(&format!("stages_text for {state:?} is empty"))); + } + } + for (state, event) in &need.events { + if !need.stages.contains(state) { + bail!(at(&format!("events names {state:?}, which is not one of its stages"))); + } + if !is_id(event) { + bail!(at(&format!("event {event:?} for {state:?} must be ASCII lowercase with - or _"))); + } + } + for state in &need.success { + if !need.done_when.contains(state) { + bail!(at(&format!("success {state:?} is not one of done_when"))); + } + } + if need.done_when.is_empty() || need.done_when.contains(&need.stages[0]) { + bail!(at("done_when must name finished states, and the first stage cannot be one")); + } + let required = need.collects.iter().filter(|f| !f.optional).count(); + if need.max_fields == 0 { + bail!(at("max_fields is how many required fields this person will tolerate, and must be at least 1")); + } + if required > need.max_fields { + bail!(at(&format!("the form asks for {required} required field(s) and this person will tolerate {}; mark some optional or drop them", need.max_fields))); + } + for field in &need.collects { + if !is_id(&field.name) || !FIELD_TYPES.contains(&field.kind.as_str()) { + bail!(at(&format!("field {:?}: name must be an identifier and type one of {FIELD_TYPES:?}", field.name))); + } + match (field.kind.as_str(), field.options_from.as_deref()) { + ("select", Some("groups")) => { + if self.groups.is_empty() { + bail!(at(&format!("field {:?} takes its options from groups, and the plan has no labelled `groups`", field.name))); + } + if !field.options.is_empty() { + bail!(at(&format!("field {:?} has both options and options_from", field.name))); + } + } + ("select", None) if field.options.is_empty() => { + bail!(at(&format!("field {:?} is a select and lists no options", field.name))); + } + ("select", None) => {} + (_, Some(_)) => bail!(at(&format!("field {:?}: options_from is for select fields, and only `groups`", field.name))), + (kind, None) if field.multiple && kind != "select" => { + bail!(at(&format!("field {:?}: multiple is for select fields", field.name))) + } + _ => {} + } + } + } + // Needs sharing a bucket share its machine. + for (bucket, needs) in self.by_bucket() { + let first = needs[0]; + for other in &needs[1..] { + if other.stages != first.stages + || other.done_when != first.done_when + || other.skippable != first.skippable + || other.moves_by != first.moves_by + || other.watched_by != first.watched_by + || other.handled_by != first.handled_by + || other.events != first.events + || other.attended_by != first.attended_by + || other.mails != first.mails + || other.grants != first.grants + || other.deadlines != first.deadlines + || other.public_states != first.public_states + || other.stages_text != first.stages_text + || other.turned_down_from != first.turned_down_from + || other.reached_from != first.reached_from + { + bail!("needs {:?} and {:?} both land in {bucket:?} and must agree on stages, done_when, skippable, events, attended_by, mails, grants, deadlines, public_states and who moves and watches it", first.id, other.id); + } + } + } + Ok(()) + } + + /// The house and its players: a role the opener invites in has to + /// see its win, and the machine has to give it something to do + /// after it sits down - a move of its own (`moves_by: submitter`) + /// or at least a view of the table (`watched_by: [public]`). A + /// featured role with neither is a spectator, and the build says + /// so; it does not refuse, since the plan may mean it. + pub fn spectators(&self) -> Vec { + self.does + .iter() + .filter_map(|d| d.for_need.as_ref().and_then(|id| self.needs.iter().find(|n| &n.id == id))) + .filter(|n| !n.moves_by.values().any(|m| m.iter().any(|w| w == SUBMITTER)) && !n.watched_by.iter().any(|g| g == PUBLIC)) + .map(|n| { + format!( + "the opener invites {} in, and after sending they play nothing: give need {:?} a move of theirs (moves_by: {{: [submitter, ...]}}) or a public view (watched_by: [public])", + n.who, n.id + ) + }) + .collect() + } + + /// What sits on the front door: each door, and each public need + /// with no door. + fn front_door_count(&self) -> usize { + self.doors.len() + self.needs.iter().filter(|n| n.on_front_door()).count() + } + + /// The need this one answers records of, if any. + fn parent_of<'a>(&'a self, need: &PlanNeed) -> Option<&'a PlanNeed> { + let id = need.about.as_deref()?; + self.needs.iter().find(|n| n.id == id) + } + + /// The needs that answer records of `need`, in plan order. + fn answers_to<'a>(&'a self, need: &PlanNeed) -> Vec<&'a PlanNeed> { + self.needs.iter().filter(|n| n.about.as_deref() == Some(need.id.as_str())).collect() + } + + fn needs_behind(&self, door: &str) -> Vec<&PlanNeed> { + self.needs.iter().filter(|n| n.door.as_deref() == Some(door)).collect() + } + + fn by_bucket(&self) -> BTreeMap<&str, Vec<&PlanNeed>> { + let mut map: BTreeMap<&str, Vec<&PlanNeed>> = BTreeMap::new(); + for need in &self.needs { + map.entry(&need.lands_in).or_default().push(need); + } + map + } +} + +/// The moves out of every unfinished stage. The unfinished stages form +/// the line a case walks; from each, the next stage, plus whatever lies +/// beyond a run of skippable ones, plus every finished state that is +/// not a success (a case can be turned down at any point). Success is +/// only reachable from the end of the line. +/// +/// That line is what most work looks like, and it is not what a +/// regulated case looks like. `reached_from` says where a named stage +/// is entered from instead: back to an earlier one when a case is sent +/// for the papers it was missing, or into a good ending partway along +/// when an authority changes its own mind rather than passing the case +/// on. A stage named there is entered from exactly those stages and +/// nowhere else; every stage not named keeps the line's own answer. +pub fn machine(need: &PlanNeed) -> BTreeMap> { + let line: Vec<&String> = need.stages.iter().filter(|s| !need.done_when.contains(s)).collect(); + let ends_well: Vec<&String> = if need.success.is_empty() { need.done_when.iter().collect() } else { need.success.iter().collect() }; + let ends_badly: Vec<&String> = need.done_when.iter().filter(|s| !ends_well.contains(s)).collect(); + let mut moves = BTreeMap::new(); + for (i, stage) in line.iter().enumerate() { + let mut to: Vec = Vec::new(); + let mut j = i + 1; + loop { + match line.get(j) { + Some(next) => { + to.push((*next).clone()); + if !need.skippable.contains(next) { + break; + } + j += 1; + } + None => { + to.extend(ends_well.iter().map(|s| (*s).clone())); + break; + } + } + } + if need.turned_down_from.is_empty() || need.turned_down_from.contains(stage) { + to.extend(ends_badly.iter().map(|s| (*s).clone())); + } + moves.insert((*stage).clone(), to); + } + // A stage the plan places by hand is entered from exactly the + // stages it names: take it out of the line's answer everywhere + // first, so that saying where a refusal may come from also says + // where it may not. + for (target, froms) in &need.reached_from { + for tos in moves.values_mut() { + tos.retain(|to| to != target); + } + for from in froms { + if let Some(tos) = moves.get_mut(from) { + if !tos.contains(target) { + tos.push(target.clone()); + } + } + } + } + // An escalated stage is the stage it came from, seen by one more + // group: the same moves out of it, and one move in, the clock's. + for stage in need.escalate.keys() { + let up = escalated_stage(stage); + let Some(tos) = moves.get(stage).cloned() else { continue }; + moves.entry(stage.clone()).or_default().push(up.clone()); + moves.insert(up, tos); + } + moves +} + +/// Every stage a case can be in: the plan's, and the escalated ones. +pub fn all_stages(need: &PlanNeed) -> Vec { + let mut out = Vec::new(); + for stage in &need.stages { + out.push(stage.clone()); + if need.escalate.contains_key(stage) { + out.push(escalated_stage(stage)); + } + } + out +} + +/// The group a stage escalated to, if it is an escalated stage. +fn escalated_to<'a>(need: &'a PlanNeed, stage: &str) -> Option<&'a str> { + need.escalate.iter().find(|(from, _)| escalated_stage(from) == stage).map(|(_, e)| e.to.as_str()) +} + +/// The event that enters a stage: what the plan says, else the stage's +/// own name, with the first stage entered by `received` (or `submitted` +/// when a stage is itself called received). +pub fn event_for(need: &PlanNeed, stage: &str) -> String { + if let Some(event) = need.events.get(stage) { + return event.clone(); + } + if stage == need.stages[0] { + return if need.stages.iter().any(|st| st == "received") { "submitted" } else { "received" }.to_string(); + } + stage.to_string() +} + +/// The stages a case goes through, as one sentence: "It is received, +/// then surveyed, then quoted, and ends won or lost." +/// A stage in words: what the plan says, else its id made readable. +pub fn stage_text(need: &PlanNeed, stage: &str) -> String { + if let Some(text) = need.stages_text.get(stage) { + return text.clone(); + } + match escalated_to(need, stage) { + Some(group) => format!("escalated to {}", humanize(group).to_lowercase()), + None => humanize(stage).to_lowercase(), + } +} + +fn stages_sentence(need: &PlanNeed) -> String { + let line: Vec = need.stages.iter().filter(|s| !need.done_when.contains(s)).map(|s| stage_text(need, s)).collect(); + let ends: Vec = need.done_when.iter().map(|s| stage_text(need, s)).collect(); + let mut out = String::from("It is "); + out.push_str(&line.join(", then ")); + if ends.len() == 1 { + out.push_str(&format!(", and ends {}.", ends[0])); + } else { + out.push_str(&format!(", and ends {} or {}.", ends[..ends.len() - 1].join(", "), ends[ends.len() - 1])); + } + out +} + +fn capitalize(text: &str) -> String { + let mut chars = text.chars(); + match chars.next() { + Some(first) => first.to_uppercase().collect::() + chars.as_str(), + None => String::new(), + } +} + +fn humanize(id: &str) -> String { + let mut words = id.replace(['_', '-'], " "); + if let Some(first) = words.get_mut(0..1) { + first.make_ascii_uppercase(); + } + words +} + +impl PlanNeed { + /// A public need that is not behind a door and answers nothing: + /// its form is on the front door itself. + fn on_front_door(&self) -> bool { + self.enters_as.is_none() && self.door.is_none() && self.about.is_none() + } +} + +/// The page where one record of `bucket` is answered: under the +/// public list, so the way down the tree leads back to the board. +fn answer_page(bucket: &str) -> String { + format!("/status/{bucket}/[key]") +} + +fn answer_page_path(bucket: &str) -> String { + format!("questions/status/{bucket}/[key].yaml") +} + +fn public_followup(need: &PlanNeed) -> String { + format!("/received/{}", need.id) +} + +fn internal_page(need: &PlanNeed, group: &str) -> String { + format!("/{group}/{}", need.id) +} + +/// The words every page needs, as built-in fallbacks: plain, English, +/// made from the plan's own text. Keyed by file path, then the same +/// JSON shape the model is asked for. +/// A generic line in the plan's language: English, or Norwegian for +/// `no`/`nb`/`nn`. For the few fallbacks no model is asked about +/// before a page ships (list notes, empty lists), so a Norwegian site +/// does not show English where its own words have not reached yet. +fn loc(lang: &str, en: &str, no: &str) -> String { + if lang.starts_with("no") || lang.starts_with("nb") || lang.starts_with("nn") { no } else { en }.to_string() +} + +pub fn fallback_words(plan: &Plan) -> BTreeMap { + let lang = plan.language.as_str(); + // Every part of the card portal renders has a slot: the model + // gets to write the button, an encouragement, the section's icon + // and guidance line, and a placeholder per field, not only the + // heading and description. Empty fallbacks render as absent. + let form = |need: &PlanNeed| { + json!({ + "heading": need.wants, + "description": format!("For {}.", need.who), + "button": "Send", + "encouragement": "", + "icon": "", + // Three sections of context the plan already knows: what + // the person must have for this to work, the road a case + // travels, and who is at the other end. The fallbacks are + // built from the plan, so they are true before a model has + // said a word. + "requires_title": if need.requires.trim().is_empty() { String::new() } else { "What you need".to_string() }, + "requires_description": need.requires.trim(), + "requires_icon": if need.requires.trim().is_empty() { "" } else { "key" }, + "road_title": "What happens next", + "road_description": stages_sentence(need), + "road_icon": "route", + "who_title": "Who reads it", + "who_description": format!("{}. Questions go to {} at {}.", humanize(&need.handled_by), plan.contact.name, plan.contact.email), + "who_icon": "users", + "form_title": "Details", + "form_description": "", + "labels": need.collects.iter().map(|f| (f.name.clone(), Value::String(humanize(&f.name)))).collect::>(), + "hints": need.collects.iter().map(|f| (f.name.clone(), Value::String(String::new()))).collect::>(), + }) + }; + let mut words = BTreeMap::new(); + let direct: Vec<&PlanNeed> = plan.needs.iter().filter(|n| n.on_front_door()).collect(); + // The opener: what the business does, asking for nothing. From + // the plan's `does`, or one feature per public need. + let does: Map = if plan.does.is_empty() { + // One or two roles, the first the plan names: the visitor the + // business exists for, and the next one it serves. + plan.needs + .iter() + .filter(|n| n.enters_as.is_none()) + .take(MAX_OPENER) + .map(|n| (n.id.clone(), json!({ "heading": humanize(&n.wants), "description": format!("For {}.", n.who), "icon": "" }))) + .collect() + } else { + // A feature for a role is headed by that role's win: what + // the need says they want. + plan.does + .iter() + .map(|d| { + let heading = d + .for_need + .as_ref() + .and_then(|id| plan.needs.iter().find(|n| &n.id == id)) + .map(|n| humanize(&n.wants)) + .unwrap_or_else(|| humanize(&d.id)); + (d.id.clone(), json!({ "heading": heading, "description": d.text, "icon": "" })) + }) + .collect() + }; + let mut shopping: Map = + plan.shows.iter().map(|s| (s.id.clone(), json!({ "heading": humanize(&s.id), "description": s.text, "icon": "" }))).collect(); + for (bucket, needs) in public_buckets(plan) { + shopping.insert( + format!("status-{bucket}"), + json!({ + "heading": format!("{} {}", loc(lang, "Latest", "Siste"), humanize(bucket).to_lowercase()), + "description": public_blurb(&needs), + "link": format!("{} {}", loc(lang, "All", "Alle"), humanize(bucket).to_lowercase()), + "empty": loc(lang, "Nothing here yet.", "Ingenting her ennå."), + "icon": "list", + }), + ); + } + words.insert( + "questions/index.yaml".to_string(), + json!({ + "question": "What brings you here?", + "description": plan.about, + "opener": { "heading": "What we do", "description": "", "features": does }, + // Named for what it shows, never an invitation to look. + "shopping": { "heading": format!("{} {}", loc(lang, "Latest from", "Siste fra"), plan.business), "description": "", "items": shopping }, + // A door's feature says what is behind it, from the needs + // that sit there: a card that leads somewhere has to say + // where, or it is a button with decoration around it. + "doors": plan.doors.iter().map(|d| (d.id.clone(), json!({ + "heading": humanize(&d.id), "description": format!("For {}.", d.who), "button": "Continue", + "icon": "", "for_whom": "", + "behind": format!("{}.", capitalize(&plan.needs_behind(&d.id).iter().map(|n| n.wants.as_str()).collect::>().join(", "))), + }))).collect::>(), + "needs": direct.iter().map(|n| (n.id.clone(), form(n))).collect::>(), + }), + ); + for door in &plan.doors { + words.insert( + door_path(door), + json!({ + "question": "What do you need?", + "description": format!("For {}.", door.who), + "needs": plan.needs_behind(&door.id).iter().map(|n| (n.id.clone(), form(n))).collect::>(), + }), + ); + } + // A page per record of a public need, where other needs answer it + // - an objection and an appeal both belong to the application. The + // record's own words come from the record, so the page only has to + // ask and hold the forms, one per answering need. + for parent in &plan.needs { + let answers = plan.answers_to(parent); + let Some(first) = answers.first() else { continue }; + words.insert( + answer_page_path(&parent.lands_in), + json!({ + "question": format!("{}?", capitalize(&first.wants)), + "description": format!("For {}.", answers.iter().map(|n| n.who.as_str()).collect::>().join(", or ")), + "forms": answers.iter().map(|n| (n.id.clone(), form(n))).collect::>(), + }), + ); + } + for need in &plan.needs { + let path = match &need.enters_as { + None => format!("questions/received/{}.yaml", need.id), + Some(group) => format!("questions/{group}/{}-sent.yaml", need.id), + }; + let mut page = json!({ + "question": "What happens now?", + "description": format!("It has reached our {}.", humanize(&need.handled_by).to_lowercase()), + "next_heading": "We read it and come back to you", + "next_description": format!("Questions in the meantime go to {} at {}.", plan.contact.name, plan.contact.email), + }); + if need.watched_by.iter().any(|g| g == PUBLIC) { + page["see_all"] = json!({ "heading": format!("See all {}", humanize(&need.lands_in).to_lowercase()), "description": "", "button": "Open" }); + } + words.insert(path, page); + if let Some(group) = &need.enters_as { + let mut page = form(need); + page["question"] = json!(format!("{}?", humanize(&need.id))); + page["page_description"] = json!(""); + words.insert(format!("questions/{group}/{}.yaml", need.id), page); + } + } + for group in groups(plan) { + let buckets: Map = plan + .by_bucket() + .into_iter() + .filter(|(_, needs)| on_desk(needs[0], &group)) + .map(|(bucket, needs)| { + // Only the buttons this group presses; a watcher gets none. + let moves: Map = moves_of(needs[0], &group) + .into_iter() + .map(|(from, to)| (format!("{from}->{to}"), Value::String(capitalize(&stage_text(needs[0], &to))))) + .collect(); + // The list itself says what a row is and what the + // buttons under it do; the card's own description says + // what the cases are. + let note = if moves.is_empty() { + loc(lang, "Each row is one case and the stage it has reached. Another desk moves these.", "Hver rad er én sak og hvor langt den har kommet. En annen pult flytter disse.") + } else { + loc(lang, "Each row is one case. The buttons record what you have just done with it.", "Hver rad er én sak. Knappene fører opp det du nettopp har gjort med den.") + }; + let from_whom = format!("Sent in by {}.", needs.iter().map(|n| n.who.as_str()).collect::>().join(" or ")); + (bucket.to_string(), json!({ "heading": humanize(bucket), "description": from_whom, "moves": moves, "list_title": humanize(bucket), "list_note": note })) + }) + .collect(); + let links: Map = plan + .needs + .iter() + .filter(|n| n.enters_as.as_deref() == Some(group.as_str())) + .map(|n| (n.id.clone(), json!({ "heading": n.wants, "description": format!("For {}.", n.who), "button": "Open" }))) + .collect(); + // One invite form per group this desk may fill, and the list of + // invites sent, when it may fill any. + let invites: Map = groups(plan) + .iter() + .filter(|g| inviters(plan, g).contains(&group)) + .map(|g| { + (g.clone(), json!({ + "heading": format!("Invite someone into {}", label_of(plan, g)), + "description": format!("They get an account, a place in {}, and a one-time link to set their own password or passkey.", label_of(plan, g)), + "button": "Invite", + "form_title": "Who", + "form_description": if plan.memberships() { + loc(lang, "Their name and phone number; an address too, if they have one. Their place is there when they sign in.", "Navnet og telefonnummeret deres, og e-post om de har. Plassen er der når de logger inn.") + } else { + loc(lang, "Their name, and the address the account and its one-time link go to.", "Navnet deres, og adressen kontoen og engangslenken går til.") + }, + "labels": { "name": "Name", "phone": loc(lang, "Phone", "Telefon"), "email": "Email" }, + })) + }) + .collect(); + let invited = if invites.is_empty() { + Value::Null + } else if plan.memberships() { + json!({ + "heading": loc(lang, "Who is in", "Hvem som er med"), + "description": loc(lang, "Everyone invited into a group this desk may fill, and whether they have come.", "Alle som er invitert inn i en gruppe denne pulten kan fylle, og om de har kommet."), + "done": loc(lang, "Take out", "Ta ut"), + "withdraw": loc(lang, "Withdraw", "Trekk tilbake"), + "again": loc(lang, "Invite again", "Inviter igjen"), + "list_title": loc(lang, "Members and invitations", "Medlemmer og invitasjoner"), + "list_note": loc(lang, "Each row is one person in one group: invited until they sign in, then in. Taking someone out keeps the row, so they can be invited again.", "Hver rad er én person i én gruppe: invitert til de logger inn, så med. Å ta noen ut beholder raden, så de kan inviteres igjen."), + }) + } else { + json!({ + "heading": "Invitations sent", + "description": loc(lang, "Each invite and where it stands. A link that runs out unused can be sent again.", "Hver invitasjon og hvor den står. En lenke som går ut ubrukt, kan sendes på nytt."), + "done": "Is in", + // Portal moves an invite whose link ran out to + // `expired`; from there the desk may send it again. + "again": loc(lang, "Send again", "Send på nytt"), + "list_title": loc(lang, "Who has been invited", "Hvem som er invitert"), + "list_note": loc(lang, "Each row is one invitation. It is done by itself when the person signs in, and expired when the link ran out unused.", "Hver rad er én invitasjon. Den blir ferdig av seg selv når personen logger inn, og står som utløpt når lenken gikk ut ubrukt."), + }) + }; + // What the people named as responsible for a page suggest + // changing: portal files each suggestion as a case, and the + // desk that brings people in is the one that settles them. + let suggestions = if suggestions_desk(plan).as_deref() == Some(group.as_str()) { + json!({ + "heading": loc(lang, "Suggested changes to the site", "Forslag til endringer på siden"), + "description": loc(lang, "What the people listed as responsible for a question have suggested changing. They are told each time a suggestion moves.", "Det de som står som ansvarlige for et spørsmål, har foreslått å endre. De får beskjed når forslaget flyttes."), + "list_title": loc(lang, "Suggestions", "Forslag"), + "list_note": loc(lang, "Each row is one suggestion, with the page it is about. The buttons record what you have just done with it.", "Hver rad er ett forslag, med siden det gjelder. Knappene fører opp det du nettopp har gjort med det."), + "looking": loc(lang, "We are looking at it", "Vi ser på det"), + "changed": loc(lang, "The change is made", "Endringen er gjort"), + "declined": loc(lang, "Will not be done", "Gjøres ikke"), + }) + } else { + Value::Null + }; + words.insert( + format!("questions/{group}/index.yaml"), + json!({ "question": "What needs doing?", "description": format!("Everything {} looks after.", label_of(plan, &group).to_lowercase()), "confirm": "Confirm", "buckets": buckets, "links": links, "invites": invites, "invited": invited, "suggestions": suggestions }), + ); + } + // A public view per bucket the plan opens: a read-only list. + for (bucket, needs) in public_buckets(plan) { + words.insert( + public_view_path(bucket), + json!({ + "question": format!("What has been sent in about {}?", humanize(bucket).to_lowercase()), + "description": public_blurb(&needs), + "heading": humanize(bucket), + "list_description": "", + // A public list says what a row is and what it leaves + // out, since the reader cannot know what was pruned. + "list_title": humanize(bucket), + "list_note": loc(lang, "Each row is one case and the stage it has reached. Names and contact details are not shown here.", "Hver rad er én sak og hvor langt den har kommet. Navn og kontaktopplysninger vises ikke her."), + "empty": loc(lang, "Nothing here yet.", "Ingenting her ennå."), + // The link on every row to the page where another + // need answers it, when one does: what the reader + // presses, so it says what they get to do. + "answer": needs.iter().flat_map(|n| plan.answers_to(n)).next().map(|a| capitalize(&a.wants)).unwrap_or_default(), + }), + ); + } + + // The invite mail, when people are brought in through the site + // and it sends mail: the first thing a new member reads from it. + if plan.mail.is_some() && has_onboarding(plan) { + words.insert("site.yaml".to_string(), json!({ "invite": invite_fallback(&plan.language) })); + } + + // The mails: one subject and body per stage that sends one, keyed + // by bucket. Templates in portal's terms: {field} for a field of + // the record, {site}, {chain}, {email}. The fallback says what + // happened and, where the submitter has a move from that stage, + // where to make it. + let mut mails: Map = Map::new(); + for (bucket, needs) in plan.by_bucket() { + let need = needs[0]; + if need.mails.is_empty() { + continue; + } + // The fallback is true in the plan's language before a model + // has written a word; the phrases live in one table below. + let t = Phrases::for_language(&plan.language); + let greeting = match need.collects.iter().find(|f| f.name == "name") { + Some(f) => format!("{} {{{}}},\n\n", t.hello, f.name), + None => format!("{},\n\n", t.hello), + }; + let mut per_stage: Map = Map::new(); + for (stage, to) in &need.mails { + let stage_word = stage_text(need, stage); + let what = if stage == &need.stages[0] { + format!("{} {}.", t.received_what_you_sent_about, need.wants) + } else { + format!("{} {} {} {stage_word}.", t.what_you_sent_about, need.wants, t.is_now) + }; + let mut body = if to == SUBMITTER { + format!("{greeting}{what}") + } else { + format!("{} {} {} {stage_word}: {{chain}}.", t.a_case_in, humanize(bucket).to_lowercase(), t.is_now) + }; + if to == SUBMITTER && moves_of(need, SUBMITTER).iter().any(|(from, _)| from == stage) { + body.push_str(&format!("\n\n{}: {{site}}/decide/{}?chain={{chain}}&email={{email}}", t.what_you_can_do_next, need.id)); + } + body.push_str(&format!("\n\n{} {} {}", plan.contact.name, t.at, plan.contact.email)); + per_stage.insert(stage.clone(), json!({ "subject": format!("{}: {}", plan.business, humanize(stage)), "body": body })); + } + mails.insert(bucket.to_string(), Value::Object(per_stage)); + } + if !mails.is_empty() { + words.insert("aggregates.yaml".to_string(), Value::Object(mails)); + } + + // The submitter's own page: reached by their private link, it + // offers the moves that are theirs to make. + for need in &plan.needs { + let theirs = moves_of(need, SUBMITTER); + if theirs.is_empty() { + continue; + } + let targets: BTreeSet<&String> = theirs.iter().map(|(_, to)| to).collect(); + words.insert( + decide_path(need), + json!({ + "question": "What would you like to do?", + "description": "", + "moves": targets.iter().map(|to| ((*to).clone(), json!({ + "heading": humanize(to), "description": "", "button": humanize(to), + }))).collect::>(), + }), + ); + } + words +} + +/// Whether anyone is brought in through the site: invites from a desk, +/// or a stage that grants a group. Both send portal's invite mail. +fn has_onboarding(plan: &Plan) -> bool { + !plan.invites_by.is_empty() || plan.groups.iter().any(|g| !g.invited_by.is_empty()) || plan.needs.iter().any(|n| !n.grants.is_empty()) +} + +/// The invite mail's fallback words, in the plan's language: the +/// person, the group, the one-time link and when it runs out. Portal +/// fills `{name}`, `{username}`, `{email}`, `{group}`, `{link}`, +/// `{site}`, `{site_name}` and `{expires}`. +fn invite_fallback(lang: &str) -> Value { + if lang.starts_with("no") || lang.starts_with("nb") || lang.starts_with("nn") { + json!({ + "subject": "Din konto hos {site_name}", + "body": "Hei {name},\n\n{site_name} har laget en konto til deg og lagt deg til i {group}. Brukernavnet ditt er {username}.\n\nVelg passord her før {expires}:\n{link}\n\nEtterpå logger du inn på {site}.", + }) + } else { + json!({ + "subject": "Your account at {site_name}", + "body": "Hello {name},\n\n{site_name} has made you an account and added you to {group}. Your username is {username}.\n\nSet your password here before {expires}:\n{link}\n\nAfter that you sign in at {site}.", + }) + } +} + +/// The invite mail without its link cannot be used: a model's reword +/// that dropped `{link}` gets the fallback's link line back. +fn keep_invite_link(lang: &str, merged: &mut Value) { + let Some(body) = merged.pointer_mut("/invite/body") else { return }; + let Some(text) = body.as_str() else { return }; + if text.contains("{link}") { + return; + } + let fallback = invite_fallback(lang); + let link_line = s(&fallback["body"]).lines().filter(|l| l.contains("{link}") || l.contains("{expires}")).collect::>().join("\n"); + *body = Value::String(format!("{}\n\n{link_line}", text.trim_end())); +} + +fn door_path(door: &Door) -> String { + format!("questions/{}/index.yaml", door.id) +} + +fn decide_path(need: &PlanNeed) -> String { + format!("questions/decide/{}.yaml", need.id) +} + +const SUBMITTER: &str = "submitter"; + +/// The words of the fallback mails, per language the plans come in. +/// The only place a language other than English appears in this +/// code: everything else the site says in its own language is a word +/// slot the model or a person fills. +struct Phrases { + hello: &'static str, + received_what_you_sent_about: &'static str, + what_you_sent_about: &'static str, + is_now: &'static str, + a_case_in: &'static str, + what_you_can_do_next: &'static str, + at: &'static str, +} + +impl Phrases { + fn for_language(lang: &str) -> Phrases { + if lang.starts_with("no") || lang.starts_with("nb") || lang.starts_with("nn") { + Phrases { + hello: "Hei", + received_what_you_sent_about: "Vi har mottatt det du sendte om", + what_you_sent_about: "Det du sendte om", + is_now: "er nå", + a_case_in: "En sak i", + what_you_can_do_next: "Det du kan gjøre nå", + at: "på", + } + } else { + Phrases { + hello: "Hello", + received_what_you_sent_about: "We have received what you sent about", + what_you_sent_about: "What you sent about", + is_now: "is now", + a_case_in: "A case in", + what_you_can_do_next: "What you can do next", + at: "at", + } + } + } +} + +/// Alternatives on one page before a visitor stops reading them. +const MAX_FRONT_DOOR: usize = 7; + +/// Who presses the button into `to`. +fn movers<'a>(need: &'a PlanNeed, to: &str) -> Vec<&'a str> { + match need.moves_by.get(to) { + Some(list) => list.iter().map(String::as_str).collect(), + None => vec![need.handled_by.as_str()], + } +} + +/// The (from, to) moves of a bucket that `who` may make. +fn moves_of(need: &PlanNeed, who: &str) -> Vec<(String, String)> { + machine(need) + .into_iter() + .flat_map(|(from, tos)| tos.into_iter().map(move |to| (from.clone(), to))) + .filter(|(from, to)| movers(need, to).contains(&who) || escalated_to(need, from) == Some(who)) + .collect() +} + +/// The stages of a bucket a group sees only because they escalated to +/// it: its card lists those alone. Empty for a group that has the +/// bucket for its own reasons, whose card lists everything. +fn escalated_states_for(need: &PlanNeed, group: &str) -> Vec { + let own = need.handled_by == group + || need.watched_by.iter().any(|g| g == group) + || need.moves_by.values().flatten().any(|m| m == group); + if own { + return Vec::new(); + } + need.escalate.iter().filter(|(_, e)| e.to == group).map(|(stage, _)| escalated_stage(stage)).collect() +} + +/// A group's desk shows a bucket when it moves something in it, is +/// answerable for it, or watches it. +fn on_desk(need: &PlanNeed, group: &str) -> bool { + group != PUBLIC && (need.handled_by == group || need.watched_by.iter().any(|g| g == group) || !moves_of(need, group).is_empty()) +} + +fn groups(plan: &Plan) -> BTreeSet { + plan.needs + .iter() + .flat_map(|n| { + [Some(n.handled_by.clone()), n.enters_as.clone()] + .into_iter() + .flatten() + .chain(n.watched_by.iter().filter(|g| *g != PUBLIC).cloned()) + .chain(n.moves_by.values().flatten().filter(|m| *m != SUBMITTER).cloned()) + .chain(n.escalate.values().map(|e| e.to.clone())) + }) + .collect() +} + +/// The groups whose desks carry an invite form into `group`: itself, +/// the plan's `invites_by`, and its own `invited_by`, each of which +/// must have a desk to carry it on. +fn inviters(plan: &Plan, group: &str) -> BTreeSet { + let desks = groups(plan); + // A group a case grants is entered by that case, not by a member + // waving somebody in: members do not make members. + let granted = plan.needs.iter().any(|n| n.grants.values().any(|g| g == group)); + (if granted { None } else { Some(group.to_string()) }) + .into_iter() + .chain(plan.invites_by.iter().cloned()) + .chain(plan.groups.iter().filter(|g| g.id == group).flat_map(|g| g.invited_by.iter().cloned())) + .filter(|g| desks.contains(g)) + .collect() +} + +/// The desk that settles suggested changes: the one that may invite +/// into the most groups, which is the group portal seeds the first +/// person into. The first by name on a tie; none on a site with no +/// desk. +fn suggestions_desk(plan: &Plan) -> Option { + let desks = groups(plan); + desks + .iter() + .map(|desk| (desks.iter().filter(|g| inviters(plan, g).contains(desk)).count(), desk)) + .filter(|(reach, _)| *reach > 0) + .max_by(|(a, x), (b, y)| a.cmp(b).then_with(|| y.cmp(x))) + .map(|(_, desk)| desk.clone()) +} + +fn label_of(plan: &Plan, group: &str) -> String { + plan.groups.iter().find(|g| g.id == group).map(|g| g.label.trim().to_string()).filter(|l| !l.is_empty()).unwrap_or_else(|| humanize(group)) +} + +/// Buckets the public may read, with the needs that fill them. +fn public_buckets(plan: &Plan) -> Vec<(&str, Vec<&PlanNeed>)> { + plan.by_bucket().into_iter().filter(|(_, needs)| needs.iter().any(|n| n.watched_by.iter().any(|g| g == PUBLIC))).collect() +} + +/// The stages a public list is limited to, in words: "approved", or +/// "approved or refused". Empty when it shows every stage. +fn shown_stages(need: &PlanNeed) -> String { + let words: Vec = need.public_states.iter().map(|st| stage_text(need, st)).collect(); + match words.len() { + 0 => String::new(), + 1 => words[0].clone(), + n => format!("{} or {}", words[..n - 1].join(", "), words[n - 1]), + } +} + +/// How many of a public list's newest records the front door shows. +const FRONT_DOOR_LATEST: usize = 3; + +/// The read-only resource over a public bucket: newest first, only the +/// stages the plan opens, contact details and private fields pruned, +/// and at most `limit` records when given. Portal filters by `states` +/// while listing, before `jq` runs, so the limit counts shown records. +fn public_resource(bucket: &str, needs: &[&PlanNeed], empty: &str, limit: Option, answer: &str) -> Value { + let mut shown: BTreeSet<&str> = BTreeSet::new(); + for n in needs { + shown.extend(n.collects.iter().filter(|f| f.is_public()).map(|f| f.name.as_str())); + } + let take = limit.map(|n| format!(" | .[:{n}]")).unwrap_or_default(); + // One record per yielded value: portal collects what the filter + // yields into the list, so `map` would nest it once more + // (measured: an empty bucket rendered as `[[]]`). + let jq = format!( + "sort_by(.submitted_ms) | reverse{take} | .[] | .responses |= {{{}}}", + shown.iter().map(|f| format!("{f}: .{f}")).collect::>().join(", ") + ); + let mut resource = json!({ + "source": { "kind": "kv", "bucket": bucket }, + "public": true, + "states": needs[0].public_states, + "empty": empty, + "jq": jq, + }); + // Every row links to the page where a record is answered, when a + // need answers this one's records. + if !answer.trim().is_empty() { + resource["link"] = json!({ "to": answer_page(bucket).replace("[key]", "{id}"), "label": answer.trim() }); + } + resource +} + +/// What a public list holds, in one sentence, honest about its filter. +fn public_blurb(needs: &[&PlanNeed]) -> String { + match shown_stages(needs[0]).as_str() { + "" => format!("Everything sent in by {}, and where each one stands.", needs.iter().map(|n| n.who.as_str()).collect::>().join(" or ")), + only => format!("What {} sent in that is now {only}.", needs.iter().map(|n| n.who.as_str()).collect::>().join(" or ")), + } +} + +fn public_view_path(bucket: &str) -> String { + format!("questions/status/{bucket}/index.yaml") +} + +/// The options of a select: its own list, or the plan's labelled groups. +fn select_options(plan: &Plan, field: &Field) -> Vec { + match field.options_from.as_deref() { + Some("groups") => plan.groups.iter().map(|g| label_of(plan, &g.id)).collect(), + _ => field.options.clone(), + } +} + +/// Takes the model's strings where it gave them, key by key, and keeps +/// the fallback everywhere else. The shape always comes from the +/// fallback, so a reply can never add, drop or restructure anything. +pub fn merge_words(fallback: &Value, given: &Value) -> Value { + match (fallback, given) { + (Value::Object(f), Value::Object(g)) => { + Value::Object(f.iter().map(|(k, v)| (k.clone(), g.get(k).map_or_else(|| v.clone(), |gv| merge_words(v, gv)))).collect()) + } + // An unfilled slot echoed back is no answer. + (Value::String(_), Value::String(s)) if !s.trim().is_empty() && !s.contains(SLOT) => { + Value::String(s.trim().to_string()) + } + // One string per storyline, every one of them said. + (Value::String(_) | Value::Array(_), Value::Array(items)) if !items.is_empty() && items.iter().all(|i| i.as_str().is_some_and(|x| !x.trim().is_empty() && !x.contains(SLOT))) => { + Value::Array(items.iter().map(|i| json!(i.as_str().unwrap_or_default().trim())).collect()) + } + (Value::Array(_), Value::String(s)) if !s.trim().is_empty() && !s.contains(SLOT) => { + Value::String(s.trim().to_string()) + } + _ => fallback.clone(), + } +} + +/// One page's words as told in each tone, folded into one: a slot the +/// tones agree on stays one string, a slot they tell differently +/// becomes the list, in the tones' order. +pub fn fold_tones(told: &[Value]) -> Value { + match told.first() { + Some(Value::Object(first)) => Value::Object( + first + .keys() + .map(|k| (k.clone(), fold_tones(&told.iter().map(|t| t.get(k).cloned().unwrap_or(Value::Null)).collect::>()))) + .collect(), + ), + Some(Value::String(_)) => { + let each: Vec<&str> = told.iter().map(|t| t.as_str().unwrap_or_default()).collect(); + if each.iter().all(|x| *x == each[0]) { + json!(each[0]) + } else { + Value::Array(each.iter().map(|x| json!(x)).collect()) + } + } + Some(other) => other.clone(), + None => Value::Null, + } +} + +const SLOT: &str = " Option<&'static str> { + const NORWEGIAN: &[&str] = &["og", "er", "du", "det", "ikke", "til", "som", "med", "på", "har", "vi", "deg", "en", "et"]; + const ENGLISH: &[&str] = &["the", "and", "you", "is", "are", "we", "to", "of", "your", "it", "in", "with", "for", "a"]; + fn collect(v: &Value, out: &mut String) { + match v { + Value::String(s) => { + out.push(' '); + out.push_str(s); + } + Value::Object(m) => m.values().for_each(|v| collect(v, out)), + Value::Array(a) => a.iter().for_each(|v| collect(v, out)), + _ => {} + } + } + let mut text = String::new(); + collect(reply, &mut text); + let words: Vec = text.split(|c: char| !c.is_alphabetic()).filter(|w| !w.is_empty()).map(|w| w.to_lowercase()).collect(); + if words.len() < 8 { + return None; + } + let no = words.iter().filter(|w| NORWEGIAN.contains(&w.as_str())).count(); + let en = words.iter().filter(|w| ENGLISH.contains(&w.as_str())).count(); + let plan_is_norwegian = lang.starts_with("no") || lang.starts_with("nb") || lang.starts_with("nn"); + let plan_is_english = lang.starts_with("en"); + match (plan_is_norwegian, plan_is_english) { + (true, _) if en > no * 2 && en >= 4 => Some("English"), + (_, true) if no > en * 2 && no >= 4 => Some("Norwegian"), + _ => None, + } +} + +/// A mail's link to the submitter's own move is structure, not +/// wording: a model told to keep it still drops it (Sonnet replaced +/// the whole line with a bare `{chain}`). Wherever the fallback body +/// had a `{site}/decide/…` line and the model's has none, the line is +/// put back at the end, and a `{chain}` left on a line of its own is +/// removed. +fn keep_links(fallback: &Value, merged: &mut Value) { + let (Some(fb), Some(mb)) = (fallback.as_object(), merged.as_object_mut()) else { return }; + for (bucket, stages) in fb { + let Some(ms) = mb.get_mut(bucket).and_then(Value::as_object_mut) else { continue }; + for (stage, spec) in stages.as_object().into_iter().flatten() { + let link = spec["body"].as_str().and_then(|b| b.lines().find(|l| l.contains("{site}/decide/"))); + let Some(body) = ms.get_mut(stage).and_then(|m| m.get_mut("body")) else { continue }; + let Some(text) = body.as_str() else { continue }; + // A bare `{chain}` outside a link is the record id pasted + // into prose, which a model does when told to keep + // placeholders; it goes, on its own line or inline. + let cleaned: Vec = text + .lines() + .map(|l| if l.contains("/decide/") { l.to_string() } else { l.replace("{chain}", "").split_whitespace().collect::>().join(" ") }) + .collect(); + let mut cleaned = cleaned.join("\n").trim_end().to_string(); + if let Some(link) = link { + if !cleaned.contains("/decide/") { + cleaned = format!("{cleaned}\n\n{link}"); + } + } + *body = Value::String(cleaned); + } + } +} + +/// The page's wording with every string replaced by a slot that says +/// what belongs there. Shown to the model in place of the fallbacks: +/// fallbacks read like finished copy, and a small model hands finished +/// copy straight back (measured: nemotron returned the front door +/// unchanged). A slot cannot be mistaken for an answer. +fn slots(value: &Value, key: &str) -> Value { + slots_in(value, key, "") +} + +fn slots_in(value: &Value, key: &str, parent: &str) -> Value { + match value { + Value::Object(map) => Value::Object(map.iter().map(|(k, v)| (k.clone(), slots_in(v, k, key))).collect()), + Value::String(_) => Value::String(format!( + "{SLOT}: {}>", + match (parent, key) { + (_, "question") => "the one question this page asks its reader".to_string(), + (_, "heading" | "next_heading") => "a heading, at most eight words, saying what the reader gets".to_string(), + (_, "description" | "page_description" | "next_description") => "one or two short sentences".to_string(), + (_, "button" | "back") => "a button, one to three words, saying what pressing it does".to_string(), + // One slot serves every list on the desk, so it cannot + // name any one of them: a model once wrote "Send invite" + // under the cases list. + (_, "confirm") => "the one button under every list on this desk, confirming a move the person has chosen; one word, such as Confirm".to_string(), + (_, "encouragement") => "one reassuring sentence: what happens with what they send, or how little it takes; or empty".to_string(), + (_, "icon") => "an icon name from the list at the end, the one that fits this best; or empty".to_string(), + (_, "for_whom") => "a few words naming who this is for, addressed to them; or empty".to_string(), + (_, "form_title") => "a title for the form, two to four words".to_string(), + (_, "form_description") => "one short line of guidance above the form, or empty".to_string(), + (_, "road_title") => "a short title, two to four words, for the line that says what happens after sending".to_string(), + (_, "road_description") => "one or two sentences walking the reader through the stages this goes through, in order (the stages are listed above), in their words".to_string(), + (_, "road_icon" | "who_icon") => "an icon name from the list at the end that fits this section; or empty".to_string(), + (_, "who_title") => "a short title, two to four words, for the line that says who reads it".to_string(), + (_, "list_description") => "one line over a public list of what people have sent in, or empty".to_string(), + (_, "list_title") => "a short title, two to four words, naming the list itself".to_string(), + (_, "link") => "the words of a link to the whole list, two to four words, naming it".to_string(), + (_, "empty") => "one short sentence shown when the list has nothing in it yet, inviting rather than apologising".to_string(), + (_, "list_note") => "one sentence saying what a single row is, and what the buttons under it do or what the list leaves out".to_string(), + (_, "behind") => "one sentence naming what a reader can do behind this door, in their words".to_string(), + (_, "who_description") => "one sentence: who reads what they send, and how to reach them meanwhile".to_string(), + ("hints", field) => format!("an example answer for the field `{field}`, as a placeholder; or empty"), + (_, other) if other.contains("->") => format!("a button, two to four words, for a case going {other}"), + (_, other) => format!("a form label for the field `{other}`"), + } + )), + other => other.clone(), + } +} + +/// Icons the words may name: a fixed set from Iconify's lucide pack, +/// so a reply can only pick one that exists. Anything else is dropped. +pub const ICONS: &[&str] = &[ + "house", "building-2", "store", "school", "map-pin", "route", "footprints", "trees", "leaf", "snowflake", "sun", + "users", "user", "user-plus", "user-check", "baby", "heart-handshake", "hand-helping", "handshake", "smile", + "mail", "phone", "message-circle", "megaphone", "bell", "send", "inbox", + "alert-triangle", "siren", "shield", "construction", "hammer", "wrench", "ruler", "package", "truck", "car", "bus", "train-front", "bike", + "calendar", "clock", "file-text", "clipboard-list", "receipt", "wallet", "coins", "banknote", "credit-card", + "key", "lock", "search", "star", "gift", "shopping-cart", "lightbulb", "flag", "thumbs-up", "check-circle", + "briefcase", "stethoscope", "activity", "camera", "pencil", "refresh-cw", "list", "layers", "vote", "scale", +]; + +/// `lucide:` for a name on the list, otherwise nothing. +pub fn icon_name(given: &str) -> Option { + // A lift token (see `lift_words`) has to reach the rendered file to + // be matched against the repo's own icon. + if given.starts_with('\u{1}') { + return Some(given.to_string()); + } + let name = given.trim().trim_start_matches("lucide:"); + ICONS.contains(&name).then(|| format!("lucide:{name}")) +} + +/// A slot's words for the machinery's purposes: the string, or the +/// first storyline's when the slot holds several (see `text`). +fn s(v: &Value) -> &str { + match v { + Value::Array(items) => items.first().and_then(|x| x.as_str()).unwrap_or_default(), + other => other.as_str().unwrap_or_default(), + } +} + +/// A slot's words as the content carries them: one string, or one +/// per storyline - portal reads a list as its storylines +/// (`docs/storylines.md`). Trimmed either way. +fn text(v: &Value) -> Value { + match v { + Value::Array(items) => Value::Array(items.iter().map(|x| json!(x.as_str().unwrap_or_default().trim())).collect()), + other => json!(other.as_str().unwrap_or_default().trim()), + } +} + +/// The same slot, each storyline put through `f`. +fn text_map(v: &Value, f: impl Fn(&str) -> String) -> Value { + match v { + Value::Array(items) => Value::Array(items.iter().map(|x| json!(f(x.as_str().unwrap_or_default()))).collect()), + other => json!(f(other.as_str().unwrap_or_default())), + } +} + +/// A one-button `consequence`: `[Send]`, or one list per storyline. +fn button(v: &Value) -> Value { + match v { + Value::Array(items) => Value::Array(items.iter().map(|x| json!([x.as_str().unwrap_or_default().trim()])).collect()), + other => json!([other.as_str().unwrap_or_default().trim()]), + } +} + +fn form_alternative(plan: &Plan, need: &PlanNeed, w: &Value, action: &str) -> Value { + let parent = plan.parent_of(need); + let mut requirements: Vec = need.collects.iter().map(|f| { + let mut r = json!({ "name": f.name, "label": text(&w["labels"][&f.name]), "type": f.kind }); + if f.optional { r["optional"] = json!(true); } + if f.kind == "select" { r["options"] = json!(select_options(plan, f)); } + if f.multiple { r["multiple"] = json!(true); } + let hint = s(&w["hints"][&f.name]).trim(); + if !hint.is_empty() && f.kind != "select" { r["placeholder"] = json!(hint); } + // Someone signed in is not asked for what the site knows: on a + // group's page they always are, and answering a record they + // may be. + if parent.is_some() || need.enters_as.is_some() { + if f.kind == "email" && matches!(f.name.as_str(), "email" | "epost" | "e-post") { r["value"] = json!("{user.email}"); } + if f.kind == "text" && matches!(f.name.as_str(), "name" | "navn") { r["value"] = json!("{user.name}"); } + } + r + }).collect(); + // The record this answer is about, carried in from the page's own + // address: nobody types a record id. + if let Some(parent) = parent { + requirements.push(json!({ "name": parent.lands_in, "type": "record", "of": parent.lands_in, "value": "{key}" })); + } + let mut feature = json!({ + "name": text(&w["form_title"]), + "requirements": requirements, + }); + if !s(&w["form_description"]).trim().is_empty() { + feature["description"] = text(&w["form_description"]); + } + if let Some(icon) = icon_name(s(&w["icon"])) { + feature["icon"] = json!(icon); + } + // Context first, then the form: the reader learns where this goes + // and who is at the other end before being asked for anything. + let strip = |title: &Value, body: &Value, icon: &str| { + let mut f = json!({ "name": text(title), "description": text(body) }); + if let Some(icon) = icon_name(icon) { f["icon"] = json!(icon); } + f + }; + let mut features = Vec::new(); + // What the person must already have or be for this to work at all, + // first of everything: a card that advertises something conditional + // and says so only after the form has taken their time has already + // wasted it. + // Whether the section exists is the plan's to say, never the + // wording's: under a lift every slot holds a token, and a token is + // never empty, so a word-driven condition would conjure a section + // the live page does not have and shift every feature after it. + if !need.requires.trim().is_empty() { + features.push(strip(&w["requires_title"], &w["requires_description"], s(&w["requires_icon"]))); + } + if !s(&w["road_description"]).trim().is_empty() { + features.push(strip(&w["road_title"], &w["road_description"], s(&w["road_icon"]))); + } + if !s(&w["who_description"]).trim().is_empty() { + features.push(strip(&w["who_title"], &w["who_description"], s(&w["who_icon"]))); + } + features.push(feature); + let mut alt = json!({ + "name": text(&w["heading"]), + "description": text(&w["description"]), + "action": action, + "consequence": button(&w["button"]), + "record_as": need.lands_in, + "features": features, + }); + if !s(&w["encouragement"]).trim().is_empty() { + alt["encouragements"] = button(&w["encouragement"]); + } + alt +} + +/// Every file of the content repo, from the plan and a set of words. +pub fn render(plan: &Plan, words: &BTreeMap) -> anyhow::Result> { + let mut files: BTreeMap = BTreeMap::new(); + let mut responsible = json!({ "name": plan.contact.name, "contact": plan.contact.email }); + if let Some(group) = &plan.contact.group { + responsible["group"] = json!(group); + } + + let mut site = json!({ "title": plan.business, "lang": plan.language }); + if let Some(mail) = &plan.mail { + site["mail"] = json!({ "from": mail.from }); + if let Some(reply_to) = &mail.reply_to { + site["mail"]["reply_to"] = json!(reply_to); + } + if let Some(invite) = words.get("site.yaml").map(|w| &w["invite"]).filter(|i| i.is_object()) { + site["mail"]["invite"] = json!({ "subject": text(&invite["subject"]), "body": text(&invite["body"]) }); + } + } + files.insert("site.yaml".into(), site); + + let mail_words = words.get("aggregates.yaml").cloned().unwrap_or(Value::Null); + files.insert( + "aggregates.yaml".into(), + json!({ "aggregates": plan.by_bucket().into_iter().map(|(bucket, needs)| { + let need = needs[0]; + let mut aggregate = json!({ + "bucket": bucket, + "initial": &need.stages[0], + "states": all_stages(need).iter().map(|st| { + let mut state = json!({ "event": event_for(need, st) }); + if let Some(e) = need.escalate.get(st) { + state["after"] = json!({ "days": e.days, "to": escalated_stage(st) }); + } + if let Some(to) = need.mails.get(st) { + let w = &mail_words[bucket][st]; + // `owner` is whoever sent the record this need + // answers: the address that record holds, reached + // through the record field the form carries. + let to = match (to.as_str(), plan.parent_of(need)) { + ("owner", Some(parent)) => format!("{{{}.email}}", parent.lands_in), + _ => to.clone(), + }; + state["mail"] = json!({ "to": to, "subject": text(&w["subject"]), "body": text(&w["body"]) }); + } + if let Some(group) = need.grants.get(st) { + state["grants"] = json!(group); + } + if let Some(d) = need.deadlines.get(st) { + state["after"] = json!({ "days": d.days, "to": d.to }); + } + (st.clone(), state) + }).collect::>(), + "transitions": machine(need), + }); + if let Some(who) = &need.attended_by { + aggregate["attended_by"] = json!(who); + } + aggregate + }).collect::>() }), + ); + + // The front door opens modestly: first what the business does, + // in features and no button; then what a visitor may look at; + // and only then, further down, one alternative per door and one + // per need the public brings straight to it. A stranger decides + // to trust the place before being asked for anything. + let front = &words["questions/index.yaml"]; + let card = |w: &Value| { + let mut f = json!({ "name": text(&w["heading"]), "description": text(&w["description"]) }); + if let Some(icon) = icon_name(s(&w["icon"])) { + f["icon"] = json!(icon); + } + f + }; + let does: Vec = front["opener"]["features"].as_object().into_iter().flatten().map(|(_, w)| card(w)).collect(); + let mut alternatives: Vec = vec![json!({ + "name": text(&front["opener"]["heading"]), + "description": text(&front["opener"]["description"]), + "features": does, + })]; + let mut shopping: Vec = plan + .shows + .iter() + .map(|show| { + let mut f = card(&front["shopping"]["items"][&show.id]); + f["resource"] = show.resource.clone(); + f + }) + .collect(); + // A public list is shown, not pointed at: its newest few records + // inline, and a link to the whole list. + for (bucket, needs) in public_buckets(plan) { + let w = &front["shopping"]["items"][&format!("status-{bucket}")]; + let mut f = card(w); + let link = match s(&w["link"]).trim() { "" => s(&w["heading"]).trim().to_string(), l => l.to_string() }; + f["description"] = text_map(&w["description"], |d| format!("{} [{link}](/status/{bucket})", d.trim()).trim().to_string()); + f["resource"] = public_resource(bucket, &needs, s(&w["empty"]), Some(FRONT_DOOR_LATEST), s(&words[&public_view_path(bucket)]["answer"])); + shopping.push(f); + } + if !shopping.is_empty() { + alternatives.push(json!({ + "name": text(&front["shopping"]["heading"]), + "description": text(&front["shopping"]["description"]), + "features": shopping, + })); + } + alternatives.extend(plan + .doors + .iter() + .map(|d| { + let dw = &front["doors"][&d.id]; + let mut alt = json!({ + "name": text(&dw["heading"]), + "description": text(&dw["description"]), + "action": format!("/{}", d.id), + "consequence": button(&dw["button"]), + }); + // A door carries no form; its icon and "for whom" line + // ride an empty section, which portal renders as a + // labelled strip on the card. + let (icon, for_whom) = (icon_name(s(&dw["icon"])), s(&dw["for_whom"]).trim()); + let behind = s(&dw["behind"]).trim(); + if icon.is_some() || !for_whom.is_empty() || !behind.is_empty() { + let mut f = json!({ "name": for_whom, "description": behind }); + if let Some(icon) = icon { f["icon"] = json!(icon); } + alt["features"] = json!([f]); + } + alt + })); + alternatives.extend( + plan.needs + .iter() + .filter(|n| n.on_front_door()) + .map(|n| form_alternative(plan, n, &front["needs"][&n.id], &public_followup(n))), + ); + if plan.front_door_count() > MAX_FRONT_DOOR { + eprintln!( + "build: the front door offers {} alternatives after the opener; past {MAX_FRONT_DOOR} a visitor stops reading. Fewer offers, or needs that share one.", + plan.front_door_count() + ); + } + files.insert( + "questions/index.yaml".into(), + json!({ "name": text(&front["question"]), "description": text(&front["description"]), "responsible": responsible, "alternatives": alternatives }), + ); + + // A door's page: a nav page (index.yaml), public, holding the + // forms of the needs behind it. + for door in &plan.doors { + let path = door_path(door); + let w = &words[&path]; + files.insert( + path, + json!({ + "name": text(&w["question"]), + "description": text(&w["description"]), + "responsible": responsible, + "alternatives": plan.needs_behind(&door.id).iter().map(|n| match &n.enters_as { + None => form_alternative(plan, n, &w["needs"][&n.id], &public_followup(n)), + // The way to the group's page: portal shows the + // sign-in gate there to whoever is not in the group. + Some(group) => json!({ + "name": text(&w["needs"][&n.id]["heading"]), + "description": text(&w["needs"][&n.id]["description"]), + "action": internal_page(n, group), + "consequence": button(&w["needs"][&n.id]["button"]), + }), + }).collect::>(), + }), + ); + } + + // A page per record of a board, holding the form that answers it. + // Dynamic: portal serves `/status//` from it, and the + // form's record field carries that id. + for parent in &plan.needs { + let answers = plan.answers_to(parent); + if answers.is_empty() { + continue; + } + let path = answer_page_path(&parent.lands_in); + let w = &words[&path]; + files.insert( + path, + json!({ + "name": text(&w["question"]), + "description": text(&w["description"]), + "responsible": responsible, + "alternatives": answers.iter().map(|n| form_alternative(plan, n, &w["forms"][&n.id], &public_followup(n))).collect::>(), + }), + ); + } + + for need in &plan.needs { + // No "back" alternative: portal's way down the tree (the link at + // the foot of every page) takes a visitor one level down from + // any page, here to the front door or, after an internal form, + // to the group's desk. + let path = match &need.enters_as { + None => format!("questions/received/{}.yaml", need.id), + Some(group) => format!("questions/{group}/{}-sent.yaml", need.id), + }; + let w = &words[&path]; + let mut alternatives = vec![ + json!({ "name": text(&w["next_heading"]), "description": text(&w["next_description"]) }), + ]; + if need.watched_by.iter().any(|g| g == PUBLIC) { + alternatives.push(json!({ + "name": text(&w["see_all"]["heading"]), "description": text(&w["see_all"]["description"]), + "action": format!("/status/{}", need.lands_in), "consequence": button(&w["see_all"]["button"]), + })); + } + // Nested and not an index: portal infers a followup page. + files.insert(path, json!({ "name": text(&w["question"]), "description": text(&w["description"]), "alternatives": alternatives })); + if let Some(group) = &need.enters_as { + let path = format!("questions/{group}/{}.yaml", need.id); + let w = &words[&path]; + files.insert( + path, + json!({ + // A way in, not an outcome, though it sits in a directory. + "followup": false, + "name": text(&w["question"]), + "description": text(&w["page_description"]), + "responsible": responsible, + "alternatives": [form_alternative(plan, need, w, &format!("/{group}/{}-sent", need.id))], + }), + ); + } + } + + for group in groups(plan) { + let w = &words[&format!("questions/{group}/index.yaml")]; + let mut alternatives = Vec::new(); + for (bucket, needs) in plan.by_bucket() { + if !on_desk(needs[0], &group) { + continue; + } + let bw = &w["buckets"][bucket]; + let mut resource = json!({ + "source": { "kind": "kv", "bucket": bucket }, + "requires_group": group, + // This group's buttons only. The same bucket sits on + // other groups' desks with theirs; a watcher's list + // is empty, which portal renders read-only. + "transitions": moves_of(needs[0], &group).into_iter().map(|(from, to)| { + json!({ "from": from, "to": to, "label": text(&bw["moves"][&format!("{from}->{to}")]) }) + }).collect::>(), + }); + // A group that has the bucket only because cases escalate + // to it sees those cases and no others. + let escalated = escalated_states_for(needs[0], &group); + if !escalated.is_empty() { + resource["states"] = json!(escalated); + } + alternatives.push(json!({ + "name": text(&bw["heading"]), + "description": text(&bw["description"]), + "action": format!("/{group}"), + "consequence": button(&w["confirm"]), + "features": [{ "name": text(&bw["list_title"]), "description": text(&bw["list_note"]), "resource": resource }], + })); + } + for need in plan.needs.iter().filter(|n| n.enters_as.as_deref() == Some(group.as_str())) { + let lw = &w["links"][&need.id]; + alternatives.push(json!({ + "name": text(&lw["heading"]), + "description": text(&lw["description"]), + "action": internal_page(need, &group), + "consequence": button(&lw["button"]), + })); + } + // Invites this desk may extend: `invite: {group}` is portal's + // onboarding (an account, the group, a one-time credential + // link), gated by this page's `qualifies`; what it made is + // listed from the runtime's portal_invites bucket. + if let Some(inv) = w["invites"].as_object().filter(|m| !m.is_empty()) { + let mut targets: Vec<&String> = inv.keys().collect(); + targets.sort(); + for target in targets { + let iw = &inv[target]; + alternatives.push(json!({ + "name": text(&iw["heading"]), + "description": text(&iw["description"]), + "action": format!("/{group}"), + "consequence": button(&iw["button"]), + "invite": { "group": target }, + "features": [{ "name": text(&iw["form_title"]), "description": text(&iw["form_description"]), "requirements": if plan.memberships() { + json!([ + { "name": "name", "label": text(&iw["labels"]["name"]) }, + { "name": "phone", "label": text(&iw["labels"]["phone"]), "type": "tel" }, + { "name": "email", "label": text(&iw["labels"]["email"]), "type": "email", "optional": true }, + ]) + } else { + json!([ + { "name": "name", "label": text(&iw["labels"]["name"]) }, + { "name": "email", "label": text(&iw["labels"]["email"]), "type": "email" }, + ]) + }}], + })); + } + let lw = &w["invited"]; + // Through Kanidm an invite is its own case with a link that + // runs out; with memberships the membership is the case. + let (bucket, transitions) = if plan.memberships() { + ("portal_memberships", json!([ + { "from": "invited", "to": "removed", "label": text(&lw["withdraw"]) }, + { "from": "active", "to": "removed", "label": text(&lw["done"]) }, + { "from": "removed", "to": "invited", "label": text(&lw["again"]) }, + ])) + } else { + ("portal_invites", json!([ + { "from": "open", "to": "done", "label": text(&lw["done"]) }, + { "from": "expired", "to": "open", "label": text(&lw["again"]) }, + { "from": "expired", "to": "done", "label": text(&lw["done"]) }, + ])) + }; + alternatives.push(json!({ + "name": text(&lw["heading"]), + "description": text(&lw["description"]), + "action": format!("/{group}"), + "consequence": button(&w["confirm"]), + "features": [{ "name": text(&lw["list_title"]), "description": text(&lw["list_note"]), "resource": { + "source": { "kind": "kv", "bucket": bucket }, + "requires_group": group, + "transitions": transitions, + }}], + })); + } + // Suggested changes, from portal's own bucket: open, discussed, + // done or declined. + if let Some(sw) = w["suggestions"].as_object() { + alternatives.push(json!({ + "name": text(&sw["heading"]), + "description": text(&sw["description"]), + "action": format!("/{group}"), + "consequence": button(&w["confirm"]), + "features": [{ "name": text(&sw["list_title"]), "description": text(&sw["list_note"]), "resource": { + "source": { "kind": "kv", "bucket": "portal_edit_suggestions" }, + "requires_group": group, + "transitions": [ + { "from": "open", "to": "discussed", "label": text(&sw["looking"]) }, + { "from": "open", "to": "done", "label": text(&sw["changed"]) }, + { "from": "open", "to": "declined", "label": text(&sw["declined"]) }, + { "from": "discussed", "to": "done", "label": text(&sw["changed"]) }, + { "from": "discussed", "to": "declined", "label": text(&sw["declined"]) }, + ], + }}], + })); + } + files.insert(format!("questions/{group}/_section.yaml"), json!({ "qualifies": group })); + files.insert( + format!("questions/{group}/index.yaml"), + json!({ "name": text(&w["question"]), "description": text(&w["description"]), "responsible": responsible, "alternatives": alternatives }), + ); + } + + // Public views: an ungated nav page per opened bucket, listing it + // read-only, with contact details and private fields projected + // away. Anyone can see what was sent in and where it stands. + for (bucket, needs) in public_buckets(plan) { + let path = public_view_path(bucket); + let w = &words[&path]; + files.insert( + path, + json!({ + "name": text(&w["question"]), + "description": text(&w["description"]), + "responsible": responsible, + "alternatives": [ + { + "name": text(&w["heading"]), + "description": text(&w["list_description"]), + "features": [{ "name": text(&w["list_title"]), "description": text(&w["list_note"]), "resource": public_resource(bucket, &needs, s(&w["empty"]), None, s(&w["answer"])) }], + }, + ], + }), + ); + } + + // A submitter's own moves: a followup page (nested, not an index) + // whose alternatives are self_transitions, authorized at runtime by + // the record's link plus the email they gave. + for need in &plan.needs { + let theirs = moves_of(need, SUBMITTER); + if theirs.is_empty() { + continue; + } + let path = decide_path(need); + let w = &words[&path]; + let targets: BTreeSet<&String> = theirs.iter().map(|(_, to)| to).collect(); + // Where each of their moves may be made from. A person is in + // dialogue with their case for as long as it runs: the + // applicant answers a request for papers from `incomplete`, + // and withdraws from wherever the case has got to by then. + // Portal compiles one policy rule per state named here, and + // without them it assumes the day the case arrived. + let from_for = |to: &String| -> Vec<&String> { + theirs.iter().filter(|(_, t)| t == to).map(|(from, _)| from).collect() + }; + files.insert( + path, + json!({ + "name": text(&w["question"]), + "description": text(&w["description"]), + "alternatives": targets.iter().map(|to| json!({ + "name": text(&w["moves"][to.as_str()]["heading"]), + "description": text(&w["moves"][to.as_str()]["description"]), + "self_transition": { "bucket": need.lands_in, "to": to, "from": from_for(to), "label": text(&w["moves"][to.as_str()]["button"]) }, + })).collect::>(), + }), + ); + } + + // The spec the verifier holds the site to. `via` is the heading the + // words gave that need, filled in mechanically. + files.insert( + "needs.yaml".into(), + json!({ + "needs": plan.needs.iter().map(|n| { + // `via` pins the alternative on the entry page: the + // door's heading for a need behind one, else the form's. + let (enters, heading, steps) = match (&n.enters_as, &n.door, plan.parent_of(n)) { + (Some(group), _, _) => (internal_page(n, group), s(&words[&format!("questions/{group}/{}.yaml", n.id)]["heading"]).to_string(), 1), + (None, Some(door), _) => ("/".to_string(), s(&front["doors"][door]["heading"]).to_string(), 2), + // An answer enters on the record's own page, which + // the board's row links to. + (None, None, Some(parent)) => (answer_page(&parent.lands_in), s(&words[&answer_page_path(&parent.lands_in)]["forms"][&n.id]["heading"]).to_string(), 1), + (None, None, None) => ("/".to_string(), s(&front["needs"][&n.id]["heading"]).to_string(), 1), + }; + let mut need = json!({ + "id": n.id, "who": n.who, "wants": n.wants, "enters": enters, "via": heading, + "lands_in": n.lands_in, "handled_by": n.handled_by, + "stages": all_stages(n), "done_when": n.done_when, "success": n.success, + "max_steps": steps, "max_fields": n.max_fields, + "stage_words": all_stages(n).iter().map(|s| (s.clone(), json!(stage_text(n, s)))).collect::>(), + }); + if let Some(group) = &n.enters_as { need["enters_as"] = json!(group); } + let others: BTreeSet<&String> = n.moves_by.values().flatten().filter(|m| *m != SUBMITTER && **m != n.handled_by).collect(); + if !others.is_empty() { need["also_moved_by"] = json!(others); } + need + }).collect::>(), + "personas": plan.personas, + "tones": plan.tones, + }), + ); + + files.into_iter().map(|(path, value)| Ok((path, serde_yaml::to_string(&value)?))).collect() +} + +fn words_prompt(plan: &Plan, path: &str, fallback: &Value, tone: Option<&str>) -> String { + let voice = match tone { + Some(tone) => format!("\n\nTone of voice for every word on this page: {tone}. The site is told in more than one tone, each written in a pass of its own, so keep to this one throughout and do not hedge between tones."), + None => String::new(), + }; + let shown_only = public_buckets(plan) + .into_iter() + .find(|(bucket, _)| public_view_path(bucket) == path) + .map(|(_, needs)| shown_stages(needs[0])) + .unwrap_or_default(); + let status_purpose = if shown_only.is_empty() { + "a public page anyone can open, listing everything sent in to one bucket and where each one stands, read-only. `question` is what this page asks; `heading` names the list.".to_string() + } else { + format!("a public page anyone can open, read-only, listing ONLY the cases that have reached: {shown_only}. Nothing else is shown - do not promise the reader anything waiting or turned down. `question` is what this page asks; `heading` names the list.") + }; + let purpose = if path == "site.yaml" { + "the invite mail: the first mail a person gets when a member of the business brings them in to help (onto a desk, into a committee), carrying the one-time link that lets them set a password. `subject` and `body` under `invite`. They did not ask for it, so say who brought them in and to what, then the link and when it runs out, then where they sign in afterwards. Plain text, short lines. Keep every `{...}` placeholder exactly as it is: `{name}`, `{username}`, `{group}`, `{link}`, `{expires}`, `{site}`, `{site_name}` are filled in by the site, and `{link}` must stay on a line of its own." + } else if path == "aggregates.yaml" { + "the mails the site sends. Under each bucket, each stage that sends one has a `subject` and a `body`: the mail a person gets the moment their case enters that stage, saying what happened and what, if anything, they can do now. Plain text, at most three short sentences plus the link line, no greeting longer than one word, in the business's voice. Keep every `{...}` placeholder exactly as it is - `{site}`, `{chain}`, `{email}` and any field name are filled in by the site - and keep any line with a link in it. The person reads the mail alone, days later, maybe among others, so what works is a mail that says what it is about in their terms - a field they filled in, as a `{placeholder}`, names their case far better than `this` or `your message` (each bucket's fields are listed under the needs above) - and ends with the business's name so they know who wrote. Short fields (a name, a place, a choice) make good subjects; a field marked `(long)` is a whole answer and a field marked `(optional)` may be blank, so those serve better elsewhere or not at all. A desk's decision carries no written reason, but a reply to the mail reaches the people who decided, so inviting a reply is the honest way to offer an explanation." + } else if path.contains("/decide/") { + "a page the person who sent a form reaches later, through a private link, to make a decision that is theirs: under `moves`, each entry is one thing they can decide." + } else if path == "questions/index.yaml" { + "the front door: the first page any visitor sees. `question` is the page's one question. It opens modestly: `opener` is the first card, what the business does in several features and no button at all - a stranger reads it and decides whether to trust the place, so say what is true and useful, not what to press. `shopping` is the second card: the site's live content, presented as it is - the latest records, a figure that matters, what has been built. It works best presented plainly: a heading that names what it shows ('Latest from the noticeboard', 'What we have built'), each item headed by its own content, `link` naming the full list. The content does the persuading; the card only has to label it, so it needs no invitation to look around and no word about forms. Only then come the offers: under `doors`, each entry is one alternative that leads to a page of its own for one kind of visitor, and its heading must let that visitor recognise themselves from the heading alone; under `needs`, each entry is one alternative with a form behind it." + } else if path.starts_with("questions/status/") { + status_purpose.as_str() + } else if plan.doors.iter().any(|d| door_path(d) == path) { +"a page one kind of visitor reaches from the front door. `question` is what this page asks them; under `needs`, each entry is one alternative they can pick, with the form behind it - or, for a need that is members' only, the card that leads to the members' page, where the form is behind sign-in: say so in its description." + } else if path.ends_with("/index.yaml") { + "a desk: the page one role inside the business works from. Under `buckets`, each entry is a list of cases; `moves` are the buttons that move a case from one stage to the next (key `from->to`), so label each with what the person has just done. Under `links` are pages this role can open. Under `invites`, each entry is a form that invites a person into that group - they get an account and a one-time link - and `invited` is the list of invites sent, where `again` is the button that sends an invite whose link ran out once more. `suggestions`, on the desk that has it, is the list of changes the people responsible for a page have suggested: `looking`, `changed` and `declined` are its buttons." + } else if path.ends_with("/[key].yaml") { + "a page one record of a public list opens to. This is NOT where a notice is posted: the visitor has read somebody else's record on the list (a notice, an application) and is answering it. `question` asks the visitor what they have to say to it; under `forms`, each entry is one kind of answer (an objection, an appeal, an offer to lend), with the form behind it. An answer that mails the record's owner goes straight to them; nobody at a desk passes it on, so never say so." + } else if path.contains("/received/") || path.ends_with("-sent.yaml") { + "a page shown right after someone has sent a form. Say who reads it, when they answer, and what happens next." + } else { + "a page only one role inside the business can open, holding one form." + }; + let mut context: Vec = plan + .doors + .iter() + .map(|d| format!("- door {}: for {}; behind it: {}", d.id, d.who, plan.needs_behind(&d.id).iter().map(|n| n.id.as_str()).collect::>().join(", "))) + .collect(); + context.extend( + plan.needs + .iter() + .map(|n| format!("- {}: {} - wants: {}. Handled by {}. Stages: {}. Lands in `{}`; its form collects: {}.", n.id, n.who, n.wants, n.handled_by, n.stages.join(" -> "), n.lands_in, n.collects.iter().map(|f| format!("{{{}}}{}{}", f.name, if f.kind == "textarea" { " (long)" } else { "" }, if f.optional { " (optional)" } else { "" })).collect::>().join(", "))), + ); + format!( + "You write the words for one page of a website for this business.\n\nBusiness: {}\nAbout: {}\nContact: {} <{}>\nLanguage to write in: {}\n\nEveryone the site serves:\n{}\n\nThe page: {path}\nIt is {purpose}\n\nBelow is the page as JSON. Every value is a slot that says what to write there. Return the same JSON with every slot replaced by your words. Keep every key exactly as it is. A slot left as it is counts as no answer.\n\nRules for the words:\n- `question` is a question the reader could ask themselves.\n- A heading says what the reader gets, in the reader's own words, and must work without its description.\n- Lead with what a thing enables. No negations in headings. No trade jargon the reader would not use.\n- Descriptions are one or two short sentences. Labels and buttons are a few words.\n- The button says what pressing it does; the encouragement says what happens next or how little it takes; the form's guidance line says what to have ready. Placeholders are example answers in the reader's words.\n- An icon is one name from this list, chosen for what the reader is doing, or empty: {}\n- Write the way people in this trade and country actually speak.{voice}\n\n{}\n\nReply with the JSON object only.", + plan.business, + plan.about.trim(), + plan.contact.name, + plan.contact.email, + plan.language, + context.join("\n"), + ICONS.join(", "), + serde_json::to_string_pretty(&slots(fallback, "")).unwrap_or_default(), + ) +} + +pub fn run(argv: &[String]) -> anyhow::Result<()> { + let value = |flag: &str| argv.iter().position(|a| a == flag).and_then(|i| argv.get(i + 1)).cloned(); + let usage = "usage: portal-trainer build --plan --out [--words-from ] [--words ]... [--llm 'cmd'] [--lint question_lint]"; + let plan_path = value("--plan").ok_or_else(|| anyhow!(usage))?; + let out = std::path::PathBuf::from(value("--out").ok_or_else(|| anyhow!(usage))?); + let plan: Plan = serde_yaml::from_str(&std::fs::read_to_string(&plan_path).with_context(|| format!("reading {plan_path}"))?) + .with_context(|| format!("parsing {plan_path}"))?; + plan.validate()?; + for warning in plan.spectators() { + eprintln!("build: {warning}"); + } + + // Words, in layers: the fallbacks; what an existing repo already + // says (hand edits survive a rebuild); explicit overrides for new + // slots; and only then a model, for whatever is still fallback. + let mut words = match value("--words-from") { + Some(repo) => lift_words(&plan, Path::new(&repo))?, + None => fallback_words(&plan), + }; + // `--words` may repeat: the words a previous build used, then a + // round of hand overrides on top, in the order given. Each is + // merged key by key over what is there, so a file names only the + // slots it changes. + let words_files: Vec<&String> = argv.iter().enumerate().filter(|(_, a)| *a == "--words").filter_map(|(i, _)| argv.get(i + 1)).collect(); + for path in words_files { + let given: BTreeMap = serde_json::from_str(&std::fs::read_to_string(path).with_context(|| format!("reading {path}"))?) + .with_context(|| format!("parsing {path}"))?; + for (page, v) in given { + match words.get(&page) { + Some(base) => { + let merged = merge_words(base, &v); + words.insert(page, merged); + } + None => eprintln!("words: {page} is not a page of this plan, ignored"), + } + } + } + if let Some(cmd) = value("--llm") { + std::fs::create_dir_all(out.join("llm"))?; + // --llm-for : the model writes only the pages whose + // path starts with it (`aggregates.yaml` for the mails alone, + // `questions/received/` for the followups), and everything + // else keeps the words it has. + let only = value("--llm-for"); + for (n, (path, fallback)) in words.clone().iter().enumerate() { + if only.as_ref().is_some_and(|p| !path.starts_with(p.as_str())) { + continue; + } + // Once per tone, or once: each tone's page is written whole, + // so a storyline is consistent within itself, and the tones + // are folded into lists where they differ. + let tones: Vec> = if plan.tones.is_empty() { vec![None] } else { plan.tones.iter().map(|t| Some(t.as_str())).collect() }; + let mut told: Vec = Vec::new(); + for (t, tone) in tones.iter().enumerate() { + let prompt = words_prompt(&plan, path, fallback, *tone); + let reply = crate::trainer::llm_with(&cmd, &prompt, &[("FORMAT", "json"), ("TEMPERATURE", "0.4")]).unwrap_or_default(); + let suffix = if tones.len() > 1 { format!("-tone{}", t + 1) } else { String::new() }; + std::fs::write(out.join(format!("llm/{:02}-{}{suffix}.md", n + 1, path.replace('/', "_"))), format!("{prompt}\n\n=== REPLY ===\n{reply}"))?; + match first_object(&reply) { + Some(given) => match wrong_language(&plan.language, &given) { + Some(found) => { + eprintln!("words: {path} - the reply reads as {found} for a {} plan, keeping the words it had", plan.language); + told.clear(); + break; + } + None => told.push(merge_words(fallback, &given)), + }, + None => { + eprintln!("words: {path} - no usable reply, keeping the fallback"); + told.clear(); + break; + } + } + } + if !told.is_empty() { + words.insert(path.clone(), fold_tones(&told)); + eprintln!("words: {path}"); + } + } + } + // Last, whatever the words came from - a model, an overrides file, + // a lift from a live repo: a mail keeps the link to the sender's + // own move, and a placeholder left loose in its prose goes. + if let Some(mails) = words.get("aggregates.yaml").cloned() { + let mut mails = mails; + keep_links(&fallback_words(&plan)["aggregates.yaml"], &mut mails); + words.insert("aggregates.yaml".to_string(), mails); + } + if let Some(site_words) = words.get_mut("site.yaml") { + keep_invite_link(&plan.language, site_words); + } + write_repo(&out, render(&plan, &words)?)?; + // The words as used, so the next rebuild can start from them. + std::fs::write(out.join("words.json"), serde_json::to_string_pretty(&words)?)?; + println!("content repo written to {}", out.display()); + + if let Some(lint) = value("--lint") { + let output = std::process::Command::new(&lint).arg("--path").arg(out.join("questions")).output().with_context(|| format!("running {lint}"))?; + let stderr = String::from_utf8_lossy(&output.stderr); + print!("{}{}", String::from_utf8_lossy(&output.stdout), stderr); + if !output.status.success() { + // Personas are attached from a file and name needs by id; a + // planned site whose model chose other ids fails only on + // that mapping, which is the reader's to fix, not the + // builder's. + let unmapped = stderr.lines().filter(|l| l.starts_with("FAIL")).all(|l| l.contains("points at a need that is not declared") || l.contains("unmet business need finding")); + if unmapped && stderr.contains("points at a need that is not declared") { + bail!("the site is sound, but the personas name needs this plan does not declare: map each persona's `need:` to one of the plan's ids, or rename the plan's needs, then build again"); + } + bail!("the verifier rejected a deterministically built site - that is a bug in the builder, not in the words"); + } + } + Ok(()) +} + +fn write_repo(out: &Path, files: BTreeMap) -> anyhow::Result<()> { + // The questions tree is rebuilt whole, so a page from an earlier + // plan never lingers. + let _ = std::fs::remove_dir_all(out.join("questions")); + for (path, body) in files { + let target = out.join(&path); + if let Some(parent) = target.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(target, body)?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn need(stages: &[&str], done: &[&str], success: &[&str], skippable: &[&str]) -> PlanNeed { + let v = |xs: &[&str]| xs.iter().map(|x| x.to_string()).collect::>(); + PlanNeed { + id: "n".into(), who: "w".into(), wants: "x".into(), enters_as: None, door: None, about: None, + lands_in: "b".into(), handled_by: "g".into(), + stages: v(stages), done_when: v(done), success: v(success), skippable: v(skippable), + moves_by: BTreeMap::new(), watched_by: vec![], + events: BTreeMap::new(), attended_by: None, mails: BTreeMap::new(), stages_text: BTreeMap::new(), turned_down_from: vec![], reached_from: BTreeMap::new(), requires: String::new(), + grants: BTreeMap::new(), deadlines: BTreeMap::new(), escalate: BTreeMap::new(), public_states: vec![], + collects: vec![], max_fields: 3, + } + } + + fn map(pairs: &[(&str, &[&str])]) -> BTreeMap> { + pairs.iter().map(|(k, vs)| (k.to_string(), vs.iter().map(|v| v.to_string()).collect())).collect() + } + + fn m_of(need: &PlanNeed, stage: &str) -> Vec { + machine(need).remove(stage).unwrap_or_default() + } + + fn parse(yaml: &str) -> anyhow::Result { + let plan: Plan = serde_yaml::from_str(yaml)?; + plan.validate()?; + Ok(plan) + } + + #[test] + fn a_stage_may_mail_the_submitter_or_an_address_and_the_words_fill_it() { + let yaml = "business: B\ncontact: { name: I, email: i@b.example }\nmail: { from: \"B \", reply_to: i@b.example }\nneeds:\n - id: leak\n who: owners\n wants: a dry house\n lands_in: leaks\n handled_by: roofers\n stages: [open, fixed, dropped]\n done_when: [fixed, dropped]\n success: [fixed]\n moves_by: { dropped: [submitter, roofers] }\n mails: { open: submitter, fixed: submitter, dropped: board@b.example }\n max_fields: 2\n collects:\n - { name: name }\n - { name: email, type: email }\n"; + let plan: Plan = serde_yaml::from_str(yaml).unwrap(); + plan.validate().unwrap(); + let words = fallback_words(&plan); + let open_body = words["aggregates.yaml"]["leaks"]["open"]["body"].as_str().unwrap(); + // The submitter has a move from open, so the fallback says where. + assert!(open_body.starts_with("Hello {name},")); + assert!(open_body.contains("{site}/decide/leak?chain={chain}&email={email}")); + assert!(!words["aggregates.yaml"]["leaks"]["fixed"]["body"].as_str().unwrap().contains("/decide/")); + let files = render(&plan, &words).unwrap(); + let site: Value = serde_yaml::from_str(&files["site.yaml"]).unwrap(); + assert_eq!(site["mail"]["from"], "B "); + let agg: Value = serde_yaml::from_str(&files["aggregates.yaml"]).unwrap(); + let states = &agg["aggregates"][0]["states"]; + assert_eq!(states["open"]["mail"]["to"], "submitter"); + assert_eq!(states["dropped"]["mail"]["to"], "board@b.example"); + assert!(states["fixed"]["mail"]["subject"].as_str().unwrap().contains("Fixed")); + // A submitter mail needs the email; mails need a sender. + let no_email = yaml.replace(" - { name: email, type: email }\n", "").replace(" moves_by: { dropped: [submitter, roofers] }\n", ""); + assert!(serde_yaml::from_str::(&no_email).unwrap().validate().unwrap_err().to_string().contains("required `email`")); + let no_from = yaml.replace("mail: { from: \"B \", reply_to: i@b.example }\n", ""); + assert!(serde_yaml::from_str::(&no_from).unwrap().validate().unwrap_err().to_string().contains("mail: { from")); + } + + #[test] + fn a_reply_in_the_wrong_language_is_named() { + let english = json!({ "a": { "body": "We have received what you sent and we will read it in full before we reply to you." } }); + let norwegian = json!({ "a": { "body": "Vi har mottatt det du sendte og leser det i sin helhet før vi svarer deg på det." } }); + assert_eq!(wrong_language("no", &english), Some("English")); + assert_eq!(wrong_language("no", &norwegian), None); + assert_eq!(wrong_language("en", &norwegian), Some("Norwegian")); + assert_eq!(wrong_language("en", &english), None); + // Too short to tell, or a language nobody checks: left alone. + assert_eq!(wrong_language("no", &json!({ "a": "Send" })), None); + assert_eq!(wrong_language("de", &english), None); + } + + #[test] + fn a_models_mail_keeps_the_submitters_link() { + let fallback = json!({ "leaks": { "open": { "subject": "s", "body": "Hello,\n\nGot it.\n\nWhat you can do next: {site}/decide/leak?chain={chain}&email={email}\n\nI at i@b.example" } } }); + let mut merged = json!({ "leaks": { "open": { "subject": "Got it", "body": "Hi,\n\nWe have it. {chain}\n\n{chain}" } } }); + keep_links(&fallback, &mut merged); + let body = merged["leaks"]["open"]["body"].as_str().unwrap(); + assert!(body.ends_with("What you can do next: {site}/decide/leak?chain={chain}&email={email}")); + assert!(!body.contains("it. {chain}") && !body.contains("\n{chain}")); + assert!(body.starts_with("Hi,\n\nWe have it.")); + // A body that kept its link is left alone. + let mut fine = json!({ "leaks": { "open": { "subject": "s", "body": "Withdraw here: {site}/decide/leak?chain={chain}&email={email}" } } }); + keep_links(&fallback, &mut fine); + assert_eq!(fine["leaks"]["open"]["body"], "Withdraw here: {site}/decide/leak?chain={chain}&email={email}"); + } + + #[test] + fn a_live_sites_event_names_survive_a_rebuild() { + let mut n = need(&["open", "invited", "active", "departed", "declined"], &["departed", "declined"], &["departed"], &[]); + assert_eq!(event_for(&n, "open"), "received"); + assert_eq!(event_for(&n, "active"), "active"); + n.events = [("open", "applied"), ("active", "onboarded")].iter().map(|(s, e)| (s.to_string(), e.to_string())).collect(); + assert_eq!(event_for(&n, "open"), "applied"); + assert_eq!(event_for(&n, "active"), "onboarded"); + assert_eq!(event_for(&n, "invited"), "invited"); + } + + #[test] + fn the_machine_walks_the_line_and_can_be_turned_down_anywhere() { + let m = machine(&need(&["open", "surveyed", "quoted", "won", "lost"], &["won", "lost"], &["won"], &[])); + assert_eq!(m["open"], ["surveyed", "lost"]); + assert_eq!(m["surveyed"], ["quoted", "lost"]); + assert_eq!(m["quoted"], ["won", "lost"]); + assert!(!m.contains_key("won")); + } + + // A building case is sent back for the drawing it was missing and + // walks the same stretch again. The line alone only goes forward, + // so the plan says where `incomplete` is entered from. + #[test] + fn a_case_can_be_sent_back_for_what_it_was_missing() { + let mut n = need( + &["open", "incomplete", "complete", "on_hearing", "approved", "refused"], + &["approved", "refused"], + &["approved"], + &["incomplete"], + ); + assert_eq!(m_of(&n, "on_hearing"), ["approved", "refused"]); + assert!(!m_of(&n, "on_hearing").contains(&"incomplete".to_string())); + n.reached_from = map(&[("incomplete", &["complete", "on_hearing"][..])]); + let m = machine(&n); + assert_eq!(m["on_hearing"], ["approved", "refused", "incomplete"]); + assert_eq!(m["complete"], ["on_hearing", "refused", "incomplete"]); + // And nowhere else: `open` no longer reaches it, because saying + // where a stage is entered from says where it is not. + assert_eq!(m["open"], ["complete", "refused"]); + } + + // The authority that changes its own mind ends the case at + // `changed_by_us`, which is a good ending in the middle of the + // line, not at the end of it. + #[test] + fn a_good_ending_can_sit_partway_along() { + let mut n = need( + &["open", "reconsidered", "before_the_board", "changed_by_us", "upheld", "overturned"], + &["changed_by_us", "upheld", "overturned"], + &["changed_by_us", "upheld"], + &[], + ); + // By the line alone, we change our own mind only once the board + // already has the case - which is backwards. + assert!(m_of(&n, "before_the_board").contains(&"changed_by_us".to_string())); + assert!(!m_of(&n, "reconsidered").contains(&"changed_by_us".to_string())); + n.reached_from = map(&[ + ("changed_by_us", &["reconsidered"][..]), + ("overturned", &["before_the_board"][..]), + ]); + let m = machine(&n); + assert_eq!(m["reconsidered"], ["before_the_board", "changed_by_us"]); + assert_eq!(m["before_the_board"], ["upheld", "overturned"]); + assert!(!m["open"].contains(&"overturned".to_string())); + } + + #[test] + fn a_stage_reached_from_nowhere_real_is_refused() { + let base = "business: B\ncontact: { name: I, email: i@b.example }\nneeds:\n - id: apply\n who: owners\n wants: permission\n lands_in: cases\n handled_by: office\n stages: [open, checked, approved, refused]\n done_when: [approved, refused]\n success: [approved]\n max_fields: 2\n collects:\n - { name: what }\n"; + let with = |line: &str| format!("{base} reached_from: {line}\n"); + assert!(parse(&with("{ approved: [checked] }")).is_ok()); + assert!(parse(&with("{ signed: [checked] }")).unwrap_err().to_string().contains("not one of its stages")); + assert!(parse(&with("{ approved: [approved] }")).unwrap_err().to_string().contains("not one of its unfinished stages")); + assert!(parse(&with("{ open: [checked] }")).unwrap_err().to_string().contains("after the first")); + assert!(parse(&with("{ approved: [] }")).unwrap_err().to_string().contains("never be entered")); + } + + #[test] + fn a_stated_prerequisite_comes_before_the_form() { + let yaml = "business: B\ncontact: { name: I, email: i@b.example }\nneeds:\n - id: renew\n who: members\n wants: another year\n requires: You need your membership number from last year's receipt.\n lands_in: renewals\n handled_by: office\n stages: [open, done]\n done_when: [done]\n max_fields: 2\n collects:\n - { name: number }\n"; + let plan: Plan = serde_yaml::from_str(yaml).unwrap(); + plan.validate().unwrap(); + let files = render(&plan, &fallback_words(&plan)).unwrap(); + let front: Value = serde_yaml::from_str(&files["questions/index.yaml"]).unwrap(); + let features = front["alternatives"][1]["features"].as_array().unwrap(); + assert_eq!(features[0]["name"], "What you need"); + assert_eq!(features[0]["description"], "You need your membership number from last year's receipt."); + assert_eq!(features[1]["name"], "What happens next"); + // No prerequisite, no section. + let none: Plan = serde_yaml::from_str(&yaml.replace(" requires: You need your membership number from last year's receipt.\n", "")).unwrap(); + let files = render(&none, &fallback_words(&none)).unwrap(); + let front: Value = serde_yaml::from_str(&files["questions/index.yaml"]).unwrap(); + assert_eq!(front["alternatives"][1]["features"][0]["name"], "What happens next"); + } + + #[test] + fn a_case_is_turned_down_only_where_the_plan_says() { + let mut n = need(&["open", "invited", "active", "departed", "declined"], &["departed", "declined"], &["departed"], &[]); + n.turned_down_from = vec!["open".into(), "invited".into()]; + let m = machine(&n); + assert_eq!(m["open"], ["invited", "declined"]); + assert_eq!(m["invited"], ["active", "declined"]); + assert_eq!(m["active"], ["departed"]); + } + + #[test] + fn a_skippable_stage_can_be_passed_over() { + let m = machine(&need(&["open", "awaiting_papers", "approved", "declined"], &["approved", "declined"], &["approved"], &["awaiting_papers"])); + assert_eq!(m["open"], ["awaiting_papers", "approved", "declined"]); + assert_eq!(m["awaiting_papers"], ["approved", "declined"]); + } + + #[test] + fn a_line_with_one_ending_has_no_way_to_be_turned_down() { + let m = machine(&need(&["open", "in_hand", "closed"], &["closed"], &["closed"], &[])); + assert_eq!(m["open"], ["in_hand"]); + assert_eq!(m["in_hand"], ["closed"]); + } + + #[test] + fn the_card_carries_button_encouragement_icon_guidance_and_placeholders() { + let plan: Plan = serde_yaml::from_str(PLAN).unwrap(); + let mut words = fallback_words(&plan); + let front = words.get_mut("questions/index.yaml").unwrap(); + front["needs"]["survey"]["button"] = json!("Book a look"); + front["needs"]["survey"]["encouragement"] = json!("We call within two days."); + front["needs"]["survey"]["icon"] = json!("house"); + front["needs"]["survey"]["form_description"] = json!("The address is enough to start."); + front["needs"]["survey"]["hints"]["email"] = json!("you@example.no"); + let files = render(&plan, &words).unwrap(); + let page: Value = serde_yaml::from_str(&files["questions/index.yaml"]).unwrap(); + let alt = &page["alternatives"][1]; + assert_eq!(alt["consequence"][0], "Book a look"); + assert_eq!(alt["encouragements"][0], "We call within two days."); + // Two context sections from the plan come before the form. + let f = alt["features"].as_array().unwrap(); + assert_eq!(f.len(), 3); + assert_eq!(f[0]["name"], "What happens next"); + assert_eq!(f[0]["description"], "It is open, then surveyed, then quoted, and ends won or lost."); + assert_eq!(f[0]["icon"], "lucide:route"); + assert_eq!(f[1]["name"], "Who reads it"); + assert_eq!(f[1]["description"], "Project managers. Questions go to Ingrid at i@n.example."); + assert_eq!(f[2]["icon"], "lucide:house"); + assert_eq!(f[2]["description"], "The address is enough to start."); + assert_eq!(f[2]["requirements"][0]["placeholder"], "you@example.no"); + // An icon off the list is dropped, not passed through. + assert_eq!(icon_name("skull"), None); + assert_eq!(icon_name("lucide:house").as_deref(), Some("lucide:house")); + // Empty slots leave nothing behind: no encouragement key, no form icon. + let plain = render(&plan, &fallback_words(&plan)).unwrap(); + let page: Value = serde_yaml::from_str(&plain["questions/index.yaml"]).unwrap(); + assert!(page["alternatives"][0].get("encouragements").is_none()); + assert!(page["alternatives"][0]["features"][2].get("icon").is_none()); + } + + #[test] + fn words_take_the_models_strings_and_never_its_structure() { + let fallback = json!({"question": "What?", "needs": {"a": {"heading": "A", "labels": {"email": "Email"}}}}); + let given = json!({"question": " What do you need? ", "needs": {"a": {"heading": "", "labels": {"email": 7, "extra": "x"}}, "b": {"heading": "B"}}, "injected": "y"}); + let merged = merge_words(&fallback, &given); + assert_eq!(merged, json!({"question": "What do you need?", "needs": {"a": {"heading": "A", "labels": {"email": "Email"}}}})); + } + + const PLAN: &str = "business: Nordvik Bygg\ncontact: { name: Ingrid, email: i@n.example }\nabout: We fix old buildings.\nneeds:\n - id: survey\n who: owners\n wants: Someone to look at the building\n lands_in: enquiries\n handled_by: project_managers\n stages: [open, surveyed, quoted, won, lost]\n done_when: [won, lost]\n success: [won]\n max_fields: 4\n collects:\n - { name: email, type: email }\n - { name: problem, type: textarea }\n - { name: address, optional: true }\n - id: report\n who: crew\n wants: Report a hazard\n enters_as: crew\n lands_in: site_issues\n handled_by: hse\n stages: [open, in_hand, closed]\n done_when: [closed]\n success: [closed]\n max_fields: 3\n collects:\n - { name: kind, type: select, options: [Hazard, Near miss] }\n"; + + #[test] + fn a_plan_renders_every_file_the_site_needs() { + let plan: Plan = serde_yaml::from_str(PLAN).unwrap(); + plan.validate().unwrap(); + let files = render(&plan, &fallback_words(&plan)).unwrap(); + let paths: Vec<&str> = files.keys().map(String::as_str).collect(); + assert_eq!(paths, [ + "aggregates.yaml", "needs.yaml", + "questions/crew/_section.yaml", "questions/crew/index.yaml", "questions/crew/report-sent.yaml", "questions/crew/report.yaml", + "questions/hse/_section.yaml", "questions/hse/index.yaml", + "questions/index.yaml", + "questions/project_managers/_section.yaml", "questions/project_managers/index.yaml", + "questions/received/survey.yaml", "site.yaml", + ]); + // The crew's form is gated, a way in, and recorded for the safety officer's desk. + assert!(files["questions/crew/_section.yaml"].contains("qualifies: crew")); + assert!(files["questions/crew/report.yaml"].contains("followup: false")); + assert!(files["questions/hse/index.yaml"].contains("bucket: site_issues")); + assert!(files["questions/hse/index.yaml"].contains("to: closed")); + // The spec names the heading the words gave, and the budget the form implies. + assert!(files["needs.yaml"].contains("via: Someone to look at the building")); + assert!(files["needs.yaml"].contains("max_fields: 4")); + assert!(files["needs.yaml"].contains("enters: /crew/report")); + } + + #[test] + fn roles_overlap_on_one_aggregate_each_with_their_own_buttons() { + let yaml = "business: B\ncontact: { name: I, email: i@b.example }\nneeds:\n - id: change\n who: a client\n wants: a change priced and done\n lands_in: change_orders\n handled_by: project_managers\n stages: [open, priced, approved, completed, declined]\n done_when: [completed, declined]\n success: [completed]\n moves_by:\n approved: [submitter]\n declined: [submitter, project_managers]\n completed: [site_managers]\n watched_by: [accounts]\n max_fields: 3\n collects:\n - { name: email, type: email }\n - { name: change, type: textarea }\n"; + let plan: Plan = serde_yaml::from_str(yaml).unwrap(); + plan.validate().unwrap(); + let files = render(&plan, &fallback_words(&plan)).unwrap(); + + // The project manager prices and may decline; approving and completing are not theirs. + let pm = &files["questions/project_managers/index.yaml"]; + assert!(pm.contains("to: priced") && pm.contains("to: declined")); + assert!(!pm.contains("to: approved") && !pm.contains("to: completed")); + // The site manager completes, and nothing else. + let site = &files["questions/site_managers/index.yaml"]; + assert!(site.contains("to: completed") && !site.contains("to: priced")); + // Accounts see the bucket and move nothing. + let accounts = &files["questions/accounts/index.yaml"]; + assert!(accounts.contains("bucket: change_orders") && accounts.contains("transitions: []")); + // The client decides through their own link. + let decide = &files["questions/decide/change.yaml"]; + assert!(decide.contains("self_transition") && decide.contains("to: approved") && decide.contains("to: declined")); + // The spec tells the verifier whose desks count. + assert!(files["needs.yaml"].contains("also_moved_by:\n - site_managers")); + + // A submitter's move without an email to authorize it is refused. + let no_email = yaml.replace(" - { name: email, type: email }\n", ""); + let plan: Plan = serde_yaml::from_str(&no_email).unwrap(); + assert!(plan.validate().unwrap_err().to_string().contains("required `email`")); + } + + #[test] + fn the_front_door_opens_with_what_we_do_and_a_look_around_before_any_button() { + let yaml = "business: B\ncontact: { name: I, email: i@b.example }\ndoes:\n - { id: fix, text: We fix roofs., for: leak }\n - { id: keep, text: We keep them fixed. }\nshows:\n - id: built\n text: Roofs we have done.\n resource: { public: true, source: { kind: url, url: https://b.example/roofs.json } }\nneeds:\n - id: leak\n who: owners\n wants: a dry house\n lands_in: leaks\n handled_by: roofers\n watched_by: [public]\n stages: [open, fixed]\n done_when: [fixed]\n max_fields: 1\n collects:\n - { name: where }\n - id: apply\n who: workers\n wants: a job\n lands_in: applicants\n handled_by: roofers\n stages: [open, hired]\n done_when: [hired]\n max_fields: 1\n collects:\n - { name: name }\n"; + let plan: Plan = serde_yaml::from_str(yaml).unwrap(); + plan.validate().unwrap(); + let files = render(&plan, &fallback_words(&plan)).unwrap(); + let front: Value = serde_yaml::from_str(&files["questions/index.yaml"]).unwrap(); + let alts = front["alternatives"].as_array().unwrap(); + // The opener: two features, no action, no form. + assert_eq!(alts[0]["name"], "What we do"); + assert!(alts[0].get("action").is_none()); + assert_eq!(alts[0]["features"].as_array().unwrap().len(), 2); + assert_eq!(alts[0]["features"][0]["description"], "We fix roofs."); + // The live content, named for what it is: the resource verbatim, + // and the public list shown inline - its newest three - with a + // link to the whole of it. + assert!(alts[1]["name"].as_str().unwrap().starts_with("Latest from")); + assert!(alts[1].get("action").is_none()); + assert_eq!(alts[1]["features"][0]["resource"]["source"]["kind"], "url"); + let listed = &alts[1]["features"][1]; + assert!(listed["description"].as_str().unwrap().contains("](/status/leaks)")); + assert_eq!(listed["resource"]["source"]["bucket"], "leaks"); + assert_eq!(listed["resource"]["public"], true); + assert!(listed["resource"]["jq"].as_str().unwrap().starts_with("sort_by(.submitted_ms) | reverse | .[:3] | .[] |")); + // Only then the offers. + assert_eq!(alts[2]["record_as"], "leaks"); + assert_eq!(alts[3]["record_as"], "applicants"); + // The role the opener invites in is headed by its win, and + // must have something to play: the owner watches the public + // list, so no warning; take that away and the build says so. + assert_eq!(alts[0]["features"][0]["name"], "A dry house"); + assert!(plan.spectators().is_empty()); + let spectator: Plan = serde_yaml::from_str(&yaml.replace(" watched_by: [public]\n", "")).unwrap(); + assert!(spectator.spectators()[0].contains("play nothing")); + // Three features is a menu; a `for` must name a need. + let three = yaml.replace(" - { id: keep, text: We keep them fixed. }\n", " - { id: keep, text: x }\n - { id: more, text: y }\n"); + assert!(serde_yaml::from_str::(&three).unwrap().validate().unwrap_err().to_string().contains("one or two")); + let bad = yaml.replace("for: leak", "for: nothing"); + assert!(serde_yaml::from_str::(&bad).unwrap().validate().unwrap_err().to_string().contains("nothing")); + } + + #[test] + fn a_door_is_a_first_question_with_its_needs_on_a_page_of_their_own() { + let yaml = "business: B\ncontact: { name: I, email: i@b.example }\ndoors:\n - { id: your-building, who: an owner with a building }\nneeds:\n - id: survey\n who: owners\n wants: a look\n door: your-building\n lands_in: enquiries\n handled_by: pms\n stages: [open, won, lost]\n done_when: [won, lost]\n max_fields: 2\n collects:\n - { name: email, type: email }\n - id: build\n who: owners\n wants: a price\n door: your-building\n lands_in: enquiries\n handled_by: pms\n stages: [open, won, lost]\n done_when: [won, lost]\n max_fields: 2\n collects:\n - { name: email, type: email }\n - id: apply\n who: workers\n wants: a job\n lands_in: applicants\n handled_by: pms\n stages: [open, hired]\n done_when: [hired]\n max_fields: 1\n collects:\n - { name: name }\n"; + let plan: Plan = serde_yaml::from_str(yaml).unwrap(); + plan.validate().unwrap(); + let files = render(&plan, &fallback_words(&plan)).unwrap(); + // The front door: the opener, then the door, then the direct need. + let front: Value = serde_yaml::from_str(&files["questions/index.yaml"]).unwrap(); + let names: Vec<&str> = front["alternatives"].as_array().unwrap().iter().map(|a| a["name"].as_str().unwrap()).collect(); + assert_eq!(names, ["What we do", "Your building", "a job"]); + assert_eq!(front["alternatives"][1]["action"], "/your-building"); + assert!(front["alternatives"][1].get("record_as").is_none()); + // The door's page holds both forms and is not gated. + let door: Value = serde_yaml::from_str(&files["questions/your-building/index.yaml"]).unwrap(); + assert_eq!(door["alternatives"].as_array().unwrap().len(), 2); + assert_eq!(door["alternatives"][0]["record_as"], "enquiries"); + assert!(!files.contains_key("questions/your-building/_section.yaml")); + // The spec pins the door as the way in, two submissions deep. + let needs: Value = serde_yaml::from_str(&files["needs.yaml"]).unwrap(); + assert_eq!(needs["needs"][0]["via"], "Your building"); + assert_eq!(needs["needs"][0]["max_steps"], 2); + assert_eq!(needs["needs"][2]["max_steps"], 1); + assert_eq!(plan.front_door_count(), 2); + + // A door nobody is behind, and a door that is also a group, are + // refused. A door on a need that enters as a group is the way + // to that group's page: a card on the door, the form behind + // sign-in, and the spec entering on the group's page. + let lonely: Plan = serde_yaml::from_str(&yaml.replace(" door: your-building\n", "")).unwrap(); + assert!(lonely.validate().unwrap_err().to_string().contains("leads nowhere")); + let members: Plan = serde_yaml::from_str(&yaml.replacen(" door: your-building\n", " door: your-building\n enters_as: pms\n", 1)).unwrap(); + members.validate().unwrap(); + let files = render(&members, &fallback_words(&members)).unwrap(); + let door: Value = serde_yaml::from_str(&files["questions/your-building/index.yaml"]).unwrap(); + assert_eq!(door["alternatives"][0]["action"], "/pms/survey"); + assert!(door["alternatives"][0].get("record_as").is_none()); + let page: Value = serde_yaml::from_str(&files["questions/pms/survey.yaml"]).unwrap(); + assert_eq!(page["alternatives"][0]["record_as"], "enquiries"); + let needs: Value = serde_yaml::from_str(&files["needs.yaml"]).unwrap(); + assert_eq!(needs["needs"][0]["enters"], "/pms/survey"); + assert_eq!(needs["needs"][0]["enters_as"], "pms"); + let clash: Plan = serde_yaml::from_str(&yaml.replace("your-building", "pms")).unwrap(); + assert!(clash.validate().unwrap_err().to_string().contains("also a group")); + } + + #[test] + fn selects_can_take_many_and_draw_their_options_from_the_labelled_groups() { + let yaml = "business: B\ncontact: { name: I, email: i@b.example }\ngroups:\n - { id: traffic, label: Traffic }\n - { id: elvesti, label: Riverside }\nneeds:\n - id: committee\n who: someone who wants a seat on a committee\n wants: a seat\n lands_in: applications\n handled_by: board\n stages: [open, placed, declined]\n done_when: [placed, declined]\n max_fields: 3\n collects:\n - { name: name }\n - { name: committee, type: select, options_from: groups, multiple: true }\n"; + let plan: Plan = serde_yaml::from_str(yaml).unwrap(); + plan.validate().unwrap(); + let files = render(&plan, &fallback_words(&plan)).unwrap(); + let page: Value = serde_yaml::from_str(&files["questions/index.yaml"]).unwrap(); + let field = &page["alternatives"][1]["features"][2]["requirements"][1]; + assert_eq!(field["options"], json!(["Traffic", "Riverside"])); + assert_eq!(field["multiple"], true); + // Without labelled groups there is nothing to draw from. + let no_groups: Plan = serde_yaml::from_str(&yaml.replace("groups:\n - { id: traffic, label: Traffic }\n - { id: elvesti, label: Riverside }\n", "")).unwrap(); + assert!(no_groups.validate().unwrap_err().to_string().contains("no labelled")); + // `multiple` on a text field is a mistake, not a no-op. + let bad: Plan = serde_yaml::from_str(&yaml.replace("{ name: name }", "{ name: name, multiple: true }")).unwrap(); + assert!(bad.validate().unwrap_err().to_string().contains("multiple is for select")); + } + + #[test] + fn a_public_watcher_gets_a_read_only_page_without_contact_details() { + let yaml = "business: B\ncontact: { name: I, email: i@b.example }\nneeds:\n - id: traffic\n who: someone who saw a hazard\n wants: at det tas opp\n lands_in: meldinger\n handled_by: traffic\n stages: [open, fixed]\n done_when: [fixed]\n watched_by: [board, public]\n max_fields: 2\n collects:\n - { name: where }\n - { name: what, type: textarea }\n - { name: epost, type: email, optional: true }\n - { name: name, optional: true, private: true }\n"; + let plan: Plan = serde_yaml::from_str(yaml).unwrap(); + plan.validate().unwrap(); + let files = render(&plan, &fallback_words(&plan)).unwrap(); + // `public` is not a group: no desk directory for it. + assert!(!files.contains_key("questions/public/index.yaml")); + assert!(files.contains_key("questions/board/index.yaml")); + let view: Value = serde_yaml::from_str(&files["questions/status/meldinger/index.yaml"]).unwrap(); + let resource = &view["alternatives"][0]["features"][0]["resource"]; + assert_eq!(resource["public"], true); + assert!(resource.get("requires_group").is_none()); + assert_eq!(resource["jq"], "sort_by(.submitted_ms) | reverse | .[] | .responses |= {what: .what, where: .where}"); + assert!(view.get("qualifies").is_none()); + // The followup page points people at the list. + let followup: Value = serde_yaml::from_str(&files["questions/received/traffic.yaml"]).unwrap(); + assert_eq!(followup["alternatives"][1]["action"], "/status/meldinger"); + // An internal need cannot be public. + let internal: Plan = serde_yaml::from_str(&yaml.replace(" lands_in:", " enters_as: board\n lands_in:")).unwrap(); + assert!(internal.validate().unwrap_err().to_string().contains("public view")); + } + + #[test] + fn a_stage_can_grant_a_group_a_clock_can_move_a_case_and_a_public_list_can_show_some_stages() { + let yaml = "business: B\ncontact: { name: I, email: i@b.example }\nmail: { from: post@b.example }\nneeds:\n - id: komite\n who: en som vil sitte i en komité\n wants: en plass\n lands_in: soknader\n handled_by: styret\n stages: [open, plassert, avslatt]\n done_when: [plassert, avslatt]\n grants: { plassert: komiteer }\n max_fields: 3\n collects:\n - { name: name }\n - { name: email, type: email }\n - id: oppslag\n who: en nabo\n wants: et oppslag\n lands_in: oppslag\n handled_by: styret\n stages: [open, lagt_ut, tatt_ned]\n done_when: [tatt_ned]\n watched_by: [public]\n public_states: [lagt_ut]\n deadlines: { lagt_ut: { days: 30, to: tatt_ned } }\n max_fields: 2\n collects:\n - { name: hva, type: textarea }\n"; + let plan: Plan = serde_yaml::from_str(yaml).unwrap(); + plan.validate().unwrap(); + let files = render(&plan, &fallback_words(&plan)).unwrap(); + let agg: Value = serde_yaml::from_str(&files["aggregates.yaml"]).unwrap(); + let bucket = |name: &str| agg["aggregates"].as_array().unwrap().iter().find(|a| a["bucket"] == name).unwrap().clone(); + assert_eq!(bucket("soknader")["states"]["plassert"]["grants"], "komiteer"); + assert_eq!(bucket("oppslag")["states"]["lagt_ut"]["after"], json!({"days": 30, "to": "tatt_ned"})); + let view: Value = serde_yaml::from_str(&files["questions/status/oppslag/index.yaml"]).unwrap(); + let resource = &view["alternatives"][0]["features"][0]["resource"]; + assert_eq!(resource["states"], json!(["lagt_ut"])); + assert_eq!(resource["empty"], "Nothing here yet."); + + // A new member's sign-in link goes by mail: the site writes its + // invite mail, and a reword that loses the link gets it back. + let site: Value = serde_yaml::from_str(&files["site.yaml"]).unwrap(); + assert!(s(&site["mail"]["invite"]["body"]).contains("{link}")); + let mut words = fallback_words(&plan); + words.insert("site.yaml".into(), json!({ "invite": { "subject": "Velkommen", "body": "Hei {name}, du er med i {group}." } })); + keep_invite_link("no", words.get_mut("site.yaml").unwrap()); + let body = s(&words["site.yaml"]["invite"]["body"]).to_string(); + assert!(body.starts_with("Hei {name}, du er med i {group}.") && body.contains("{link}") && body.contains("{expires}"), "{body}"); + // Without a sender, a grant is refused: the link would reach nobody. + let no_sender: Plan = serde_yaml::from_str(&yaml.replace("mail: { from: post@b.example }\n", "")).unwrap(); + assert!(no_sender.validate().unwrap_err().to_string().contains("reaches nobody")); + + // A grant needs the person's name and address on the form. + let no_name: Plan = serde_yaml::from_str(&yaml.replace(" - { name: name }\n", "")).unwrap(); + assert!(no_name.validate().unwrap_err().to_string().contains("required `name`")); + // A clock may only make a move the machine has. + let off_graph: Plan = serde_yaml::from_str(&yaml.replace("{ days: 30, to: tatt_ned }", "{ days: 30, to: open }")).unwrap(); + assert!(off_graph.validate().unwrap_err().to_string().contains("not a move")); + // public_states without a public list says nothing. + let not_public: Plan = serde_yaml::from_str(&yaml.replace(" watched_by: [public]\n", "")).unwrap(); + assert!(not_public.validate().unwrap_err().to_string().contains("no `public`")); + } + + #[test] + fn a_form_that_asks_more_than_the_person_will_tolerate_is_refused() { + // Two required fields under a budget of one: the plan is refused + // before any page exists, and the message says what to do. + let plan: Plan = serde_yaml::from_str(&PLAN.replace("max_fields: 4", "max_fields: 1")).unwrap(); + let err = plan.validate().unwrap_err().to_string(); + assert!(err.contains("2 required field(s)") && err.contains("tolerate 1"), "{err}"); + // A budget of zero is not a budget. + let plan: Plan = serde_yaml::from_str(&PLAN.replace("max_fields: 3", "max_fields: 0")).unwrap(); + assert!(plan.validate().unwrap_err().to_string().contains("at least 1")); + // The budget is what the spec hands the verifier, not the form's size. + let plan: Plan = serde_yaml::from_str(PLAN).unwrap(); + let files = render(&plan, &fallback_words(&plan)).unwrap(); + assert!(files["needs.yaml"].contains("max_fields: 4")); + assert!(files["needs.yaml"].contains("max_fields: 3")); + // A plan that says nothing about tolerance is not a plan. + assert!(serde_yaml::from_str::(&PLAN.replace(" max_fields: 4\n", "")).is_err()); + } + + #[test] + fn a_plan_with_a_bad_identifier_or_a_disagreeing_bucket_is_refused() { + let bad: Plan = serde_yaml::from_str(&PLAN.replace("lost]", "avslått]")).unwrap(); + assert!(bad.validate().unwrap_err().to_string().contains("ASCII")); + let clash = format!("{PLAN} - id: other\n who: w\n wants: x\n lands_in: enquiries\n handled_by: project_managers\n stages: [open, won]\n done_when: [won]\n max_fields: 1\n collects: []\n"); + let plan: Plan = serde_yaml::from_str(&clash).unwrap(); + assert!(plan.validate().unwrap_err().to_string().contains("must agree")); + } +} + +/// The words a repo already carries, lifted back into the words map by +/// structure: the plan is rendered with a token in every word slot, +/// each rendered file is walked alongside the repo's file of the same +/// path, and wherever a token meets a string, that string is the word. +/// Pages the repo does not have, and slots it never had (a new invite +/// form), keep their fallback. Hand-edited wording survives a rebuild. +pub fn lift_words(plan: &Plan, repo: &Path) -> anyhow::Result> { + let fallback = fallback_words(plan); + let tokened: BTreeMap = + fallback.iter().map(|(path, v)| (path.clone(), tokenize(v, path, ""))).collect(); + let rendered = render(plan, &tokened)?; + let mut found: std::collections::HashMap = std::collections::HashMap::new(); + for (path, text) in &rendered { + // The spec is derived from the words (a need's `via` is its + // heading), never a source of them: behind a door it named the + // door, and a positional match would hand that to the form. + if path == "needs.yaml" { + continue; + } + let Ok(live) = std::fs::read_to_string(repo.join(path)) else { continue }; + let (Ok(a), Ok(b)) = ( + serde_yaml::from_str::(text), + serde_yaml::from_str::(&live), + ) else { + continue; + }; + collect_words(&a, &b, &mut found); + } + // A second pass across the whole repo: a card that moved page (a + // door taken away, a need brought onto the front door) is still + // the same card, and keeps its words. Every alternative the repo + // has goes into one pool; a rendered card none of whose slots + // were found on its own page takes the pool's card with the same + // strong identity - the bucket it records into, the page it leads + // to, the group it invites into - never a bare `action` like `/`. + let mut pool: Vec = Vec::new(); + for live in yaml_files(&repo.join("questions")) { + let Ok(page) = serde_yaml::from_str::(&live) else { continue }; + if let Some(alts) = page.get("alternatives").and_then(|a| a.as_sequence()) { + pool.extend(alts.iter().cloned()); + } + } + let mut used = vec![false; pool.len()]; + for text in rendered.values() { + let Ok(page) = serde_yaml::from_str::(text) else { continue }; + let Some(alts) = page.get("alternatives").and_then(|a| a.as_sequence()) else { continue }; + for alt in alts { + let ida: Vec<(&str, String)> = identity(alt).into_iter().filter(|(k, v)| strong_identity(k, v)).collect(); + if ida.is_empty() || tokens_in(alt).iter().any(|t| found.contains_key(t)) { + continue; + } + if let Some(j) = (0..pool.len()).find(|&j| !used[j] && identity(&pool[j]).iter().any(|k| ida.contains(k))) { + used[j] = true; + collect_words(alt, &pool[j], &mut found); + } + } + } + Ok(fallback.iter().map(|(path, v)| (path.clone(), untokenize(v, path, "", &found))).collect()) +} + +/// An identity that survives a move between pages: what a card does, +/// not where a nav link points. `/received/` and `/status/…` +/// are one need's own pages; `/`, `/owners` and the like are shared +/// by every "back" and every desk card. +fn strong_identity(key: &str, value: &str) -> bool { + match key { + "action" => value.starts_with("/received/") || value.starts_with("/status/") || value.starts_with("/decide/"), + "name" => false, + _ => true, + } +} + +/// Every token slot inside a rendered item. +fn tokens_in(v: &serde_yaml::Value) -> Vec { + let mut out = Vec::new(); + match v { + serde_yaml::Value::String(s) if s.starts_with('\u{1}') => out.push(s.clone()), + serde_yaml::Value::Mapping(m) => out.extend(m.values().flat_map(tokens_in)), + serde_yaml::Value::Sequence(s) => out.extend(s.iter().flat_map(tokens_in)), + _ => {} + } + out +} + +/// The text of every `.yaml` under `dir`, any depth. +fn yaml_files(dir: &Path) -> Vec { + let mut out = Vec::new(); + let Ok(entries) = std::fs::read_dir(dir) else { return out }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + out.extend(yaml_files(&path)); + } else if path.extension().is_some_and(|e| e == "yaml") { + if let Ok(text) = std::fs::read_to_string(&path) { + out.push(text); + } + } + } + out +} + +/// What identifies an item of a page regardless of its position: the +/// structural strings portal itself keys on. Word slots (tokens on +/// the rendered side) are never an identity. +fn identity(v: &serde_yaml::Value) -> Vec<(&'static str, String)> { + const KEYS: &[&[&str]] = &[ + &["bucket"], + &["record_as"], + &["action"], + &["name"], + &["invite", "group"], + &["resource", "source", "bucket"], + &["self_transition", "bucket"], + ]; + let mut id = Vec::new(); + for path in KEYS { + let mut at = v; + for key in path.iter() { + match at.get(*key) { + Some(next) => at = next, + None => { + at = &serde_yaml::Value::Null; + break; + } + } + } + if let Some(s) = at.as_str() { + if !s.starts_with('\u{1}') && !s.is_empty() { + id.push((path[0], s.to_string())); + } + } + } + // A desk card is its list: the bucket its feature reads is what + // the card is about, and the only thing that tells two cards on + // one desk apart, since they all lead back to the desk. + if let Some(features) = v.get("features").and_then(|f| f.as_sequence()) { + for feature in features { + if let Some(bucket) = feature.get("resource").and_then(|r| r.get("source")).and_then(|s| s.get("bucket")).and_then(|b| b.as_str()) { + if !bucket.starts_with('\u{1}') && !bucket.is_empty() { + id.push(("bucket", bucket.to_string())); + } + } + } + } + id +} + +fn token(path: &str, pointer: &str) -> String { + format!("\u{1}{path}\u{2}{pointer}\u{3}") +} + +fn tokenize(v: &Value, path: &str, pointer: &str) -> Value { + match v { + Value::Object(m) => Value::Object(m.iter().map(|(k, x)| (k.clone(), tokenize(x, path, &format!("{pointer}/{k}")))).collect()), + Value::String(_) => Value::String(token(path, pointer)), + other => other.clone(), + } +} + +fn untokenize(v: &Value, path: &str, pointer: &str, found: &std::collections::HashMap) -> Value { + match v { + Value::Object(m) => Value::Object(m.iter().map(|(k, x)| (k.clone(), untokenize(x, path, &format!("{pointer}/{k}"), found))).collect()), + Value::String(_) => match found.get(&token(path, pointer)) { + Some(word) => word.clone(), + None => v.clone(), + }, + other => other.clone(), + } +} + +fn collect_words(a: &serde_yaml::Value, b: &serde_yaml::Value, found: &mut std::collections::HashMap) { + match (a, b) { + (serde_yaml::Value::Mapping(x), serde_yaml::Value::Mapping(y)) => { + for (k, va) in x { + if let Some(vb) = y.get(k) { + collect_words(va, vb, found); + } + } + } + // Items are matched by what they are, not where they sit: the + // bucket an alternative records into, the page it leads to, a + // field's name, the group it invites into. A rebuild that + // reorders or reshapes a page still hands each card its own + // words; on uhhm.no a positional match once put the old front + // door's three headings on three unrelated new cards. Items + // with no identity (an information feature) fall back to + // position. + // A live list of lists under a rendered list of tokens is the + // storylines of a button list (`consequence: [[Send], [Meld meg]]`): + // each token gets one word per storyline. + (serde_yaml::Value::Sequence(x), serde_yaml::Value::Sequence(y)) + if x.iter().all(|t| t.as_str().is_some_and(|t| t.starts_with('\u{1}'))) && !y.is_empty() && y.iter().all(|v| v.as_sequence().is_some_and(|l| l.iter().all(|w| w.as_str().is_some()))) => + { + for (k, t) in x.iter().enumerate() { + let words: Vec<&str> = y.iter().filter_map(|l| l.as_sequence()?.get(k)?.as_str()).collect(); + if words.len() == y.len() && words.iter().all(|w| !w.trim().is_empty()) { + found.insert(t.as_str().unwrap_or_default().to_string(), Value::Array(words.iter().map(|w| json!(w)).collect())); + } + } + } + (serde_yaml::Value::Sequence(x), serde_yaml::Value::Sequence(y)) => { + let mut used = vec![false; y.len()]; + for (i, va) in x.iter().enumerate() { + let ida = identity(va); + // What a card does is matched before where it points: + // on a desk every card's action is the desk itself, so + // a card with a bucket or a group of its own must not + // take the words of whichever card shares that action + // - when the vel's notices left the board's desk, the + // invite after them was handed their heading that way. + let strong: Vec<(&str, String)> = ida.iter().filter(|(k, v)| strong_identity(k, v)).cloned().collect(); + let matching = |keys: &[(&str, String)]| (0..y.len()).find(|&j| !used[j] && identity(&y[j]).iter().any(|k| keys.contains(k))); + let hit = if ida.is_empty() { + // A name is a word, never an identity that blocks a + // positional match: a feature always carries one. + (i < y.len() && !used[i] && identity(&y[i]).iter().all(|(k, _)| *k == "name")).then_some(i) + } else if !strong.is_empty() { + matching(&strong) + } else { + matching(&ida) + }; + if let Some(j) = hit { + used[j] = true; + collect_words(va, &y[j], found); + } + } + } + (serde_yaml::Value::String(t), serde_yaml::Value::String(live)) if t.starts_with('\u{1}') => { + // An icon slot holds the bare name; the file carries `lucide:`. + let word = if t.ends_with("/icon\u{3}") { live.trim_start_matches("lucide:") } else { live.as_str() }; + // A lift carries words across, not absences: where the live + // page said nothing, the fallback stands. Otherwise a slot + // that has since been given something to say would be + // emptied again by every rebuild. + if !word.trim().is_empty() { + found.insert(t.clone(), json!(word)); + } + } + // A live list of strings under one token: the slot's storylines. + (serde_yaml::Value::String(t), serde_yaml::Value::Sequence(live)) if t.starts_with('\u{1}') && !live.is_empty() && live.iter().all(|w| w.as_str().is_some_and(|w| !w.trim().is_empty())) => { + found.insert(t.clone(), Value::Array(live.iter().map(|w| json!(w.as_str().unwrap_or_default())).collect())); + } + _ => {} + } +} + +#[cfg(test)] +mod invite_tests { + use super::*; + + const PLAN: &str = "business: Vel\ncontact: { name: S, email: s@v.example }\ngroups:\n - { id: traffic, label: Trafikk }\ninvites_by: [board]\nneeds:\n - id: report\n who: a neighbour\n wants: a hazard seen to\n lands_in: hazards\n handled_by: traffic\n stages: [open, fixed]\n done_when: [fixed]\n max_fields: 3\n collects:\n - { name: where }\n - id: expenses\n who: a committee member\n wants: money back\n enters_as: traffic\n lands_in: expenses\n handled_by: board\n stages: [open, betalt, avvist]\n done_when: [betalt, avvist]\n max_fields: 3\n collects:\n - { name: amount }\n"; + + #[test] + fn words_follow_a_card_that_moves_to_another_page() { + // Built once behind a door and hand-worded; rebuilt flat, the + // form's words come along even though its page is gone. + let doored = "business: Vel\ncontact: { name: S, email: s@v.example }\ndoors:\n - { id: report-something, who: someone who saw something }\nneeds:\n - id: report\n who: a neighbour\n wants: a hazard seen to\n door: report-something\n lands_in: hazards\n handled_by: traffic\n stages: [open, fixed]\n done_when: [fixed]\n max_fields: 3\n collects:\n - { name: where }\n"; + let plan: Plan = serde_yaml::from_str(doored).unwrap(); + let mut words = fallback_words(&plan); + let door_page = words.get_mut("questions/report-something/index.yaml").unwrap(); + door_page["needs"]["report"]["heading"] = json!("Report a traffic hazard"); + door_page["needs"]["report"]["hints"]["where"] = json!("The bend by the school"); + let repo = std::env::temp_dir().join(format!("trainer-lift-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&repo); + write_repo(&repo, render(&plan, &words).unwrap()).unwrap(); + + let flat: Plan = serde_yaml::from_str(&doored.replace("doors:\n - { id: report-something, who: someone who saw something }\n", "").replace(" door: report-something\n", "")).unwrap(); + let lifted = lift_words(&flat, &repo).unwrap(); + assert_eq!(lifted["questions/index.yaml"]["needs"]["report"]["heading"], "Report a traffic hazard"); + assert_eq!(lifted["questions/index.yaml"]["needs"]["report"]["hints"]["where"], "The bend by the school"); + let _ = std::fs::remove_dir_all(&repo); + } + + const BOARD: &str = "business: Vel\ncontact: { name: S, email: s@v.example }\nmail: { from: v@v.example }\nlanguage: no\nneeds:\n - id: notice\n who: en nabo som har noe å låne ut\n wants: et oppslag naboene ser\n lands_in: oppslag\n handled_by: redaktor\n stages: [open, tatt_ned]\n done_when: [tatt_ned]\n moves_by: { tatt_ned: [submitter, redaktor] }\n mails: { open: s@v.example }\n watched_by: [public]\n public_states: [open]\n max_fields: 3\n collects:\n - { name: email, type: email }\n - { name: hva, type: textarea }\n - id: answer\n who: en nabo som kan låne ut det\n wants: at den som la ut oppslaget får vite det\n about: notice\n lands_in: svar\n handled_by: redaktor\n stages: [open, lest]\n done_when: [lest]\n mails: { open: owner }\n max_fields: 1\n collects:\n - { name: email, type: email }\n"; + + #[test] + fn a_contact_that_is_a_group_answers_for_every_page_as_that_group() { + let plan: Plan = serde_yaml::from_str(&BOARD.replace("contact: { name: S, email: s@v.example }", "contact: { name: S, email: s@v.example, group: redaktor }")).unwrap(); + plan.validate().unwrap(); + let files = render(&plan, &fallback_words(&plan)).unwrap(); + let front: Value = serde_yaml::from_str(&files["questions/index.yaml"]).unwrap(); + assert_eq!(front["responsible"]["group"], "redaktor"); + assert_eq!(front["responsible"]["contact"], "s@v.example"); + let nobody: Plan = serde_yaml::from_str(&BOARD.replace("contact: { name: S, email: s@v.example }", "contact: { name: S, email: s@v.example, group: nobody }")).unwrap(); + assert!(nobody.validate().unwrap_err().to_string().contains("no desk")); + } + + #[test] + fn two_tones_become_two_storylines_that_render_lift_and_render_again() { + let plan: Plan = serde_yaml::from_str(&PLAN.replace("business: Vel", "business: Vel\ntones:\n - plain, like a notice on the door\n - warm, as a neighbour would put it")).unwrap(); + plan.validate().unwrap(); + // The words as two tones told them, folded: agreed slots stay + // one string, others become the list. + let mut words = fallback_words(&plan); + let page = words["questions/index.yaml"].clone(); + let mut warm = page.clone(); + warm["needs"]["report"]["heading"] = json!("Say where it is dangerous"); + warm["needs"]["report"]["button"] = json!("Tell us"); + let folded = fold_tones(&[page.clone(), warm]); + assert_eq!(folded["needs"]["report"]["heading"], json!([page["needs"]["report"]["heading"], "Say where it is dangerous"])); + assert_eq!(folded["question"], page["question"], "a slot the tones agree on stays one string"); + words.insert("questions/index.yaml".into(), folded); + // Rendered, a list is a list, and a button list a list of lists. + let files = render(&plan, &words).unwrap(); + let front: Value = serde_yaml::from_str(&files["questions/index.yaml"]).unwrap(); + let card = front["alternatives"].as_array().unwrap().iter().find(|a| a["record_as"] == "hazards").unwrap(); + assert_eq!(card["name"], json!([page["needs"]["report"]["heading"], "Say where it is dangerous"])); + assert_eq!(card["consequence"], json!([[page["needs"]["report"]["button"]], ["Tell us"]])); + assert!(files["needs.yaml"].contains("tones:")); + // Lifted back out of the rendered site, the storylines survive. + let repo = std::env::temp_dir().join(format!("trainer-tones-{}", std::process::id())); + for (path, text) in &files { + let full = repo.join(path); + std::fs::create_dir_all(full.parent().unwrap()).unwrap(); + std::fs::write(full, text).unwrap(); + } + let lifted = lift_words(&plan, &repo).unwrap(); + assert_eq!(lifted["questions/index.yaml"]["needs"]["report"]["heading"], json!([page["needs"]["report"]["heading"], "Say where it is dangerous"])); + assert_eq!(lifted["questions/index.yaml"]["needs"]["report"]["button"], json!([page["needs"]["report"]["button"], "Tell us"])); + assert_eq!(render(&plan, &lifted).unwrap()["questions/index.yaml"], files["questions/index.yaml"]); + let _ = std::fs::remove_dir_all(&repo); + // An empty tone says nothing. + let blank: Plan = serde_yaml::from_str(&PLAN.replace("business: Vel", "business: Vel\ntones: [\"\"]")).unwrap(); + assert!(blank.validate().unwrap_err().to_string().contains("tones")); + } + + #[test] + fn with_memberships_a_desk_invites_by_phone_and_lists_who_is_in() { + let plan: Plan = serde_yaml::from_str(&PLAN.replace("invites_by: [board]\n", "invites_by: [board]\npeople: memberships\n")).unwrap(); + plan.validate().unwrap(); + let files = render(&plan, &fallback_words(&plan)).unwrap(); + let board: Value = serde_yaml::from_str(&files["questions/board/index.yaml"]).unwrap(); + let alts = board["alternatives"].as_array().unwrap(); + let invite = alts.iter().find(|a| a["invite"]["group"] == "traffic").unwrap(); + let fields: Vec<(&str, &str)> = invite["features"][0]["requirements"].as_array().unwrap().iter().map(|r| (r["name"].as_str().unwrap(), r["type"].as_str().unwrap_or("text"))).collect(); + assert_eq!(fields, [("name", "text"), ("phone", "tel"), ("email", "email")]); + assert_eq!(invite["features"][0]["requirements"][2]["optional"], true); + let list = alts.iter().find(|a| a["features"][0]["resource"]["source"]["bucket"] == "portal_memberships").unwrap(); + let moves: Vec<(&str, &str)> = list["features"][0]["resource"]["transitions"].as_array().unwrap().iter().map(|t| (t["from"].as_str().unwrap(), t["to"].as_str().unwrap())).collect(); + assert_eq!(moves, [("invited", "removed"), ("active", "removed"), ("removed", "invited")]); + assert!(!files["questions/board/index.yaml"].contains("portal_invites")); + // A grant may know the person by phone number instead of address. + let by_phone: Plan = serde_yaml::from_str(&PLAN.replace("invites_by: [board]\n", "invites_by: [board]\npeople: memberships\nmail: { from: v@v.example }\n").replace(" stages: [open, betalt, avvist]\n", " grants: { betalt: traffic }\n stages: [open, betalt, avvist]\n").replace(" - { name: amount }\n", " - { name: amount }\n - { name: name }\n - { name: phone, type: tel }\n")).unwrap(); + by_phone.validate().unwrap(); + let nobody: Plan = serde_yaml::from_str(&PLAN.replace("business: Vel", "business: Vel\npeople: ldap")).unwrap(); + assert!(nobody.validate().unwrap_err().to_string().contains("people must be")); + } + + #[test] + fn a_case_left_too_long_goes_up_to_another_desk_with_the_same_buttons() { + let plan: Plan = serde_yaml::from_str(&PLAN.replace(" handled_by: traffic\n stages: [open, fixed]\n", " handled_by: traffic\n escalate: { open: { days: 7, to: board } }\n stages: [open, fixed]\n")).unwrap(); + plan.validate().unwrap(); + // The machine: the clock's move in, the same moves out. + let need = plan.needs.iter().find(|n| n.id == "report").unwrap(); + let m = machine(need); + assert_eq!(m["open"], ["fixed", "open_escalated"]); + assert_eq!(m["open_escalated"], ["fixed"]); + assert_eq!(all_stages(need), ["open", "open_escalated", "fixed"]); + let files = render(&plan, &fallback_words(&plan)).unwrap(); + // The clock moves it up after seven days. + let agg: Value = serde_yaml::from_str(&files["aggregates.yaml"]).unwrap(); + let hazards = agg["aggregates"].as_array().unwrap().iter().find(|a| a["bucket"] == "hazards").unwrap(); + assert_eq!(hazards["states"]["open"]["after"], json!({ "days": 7, "to": "open_escalated" })); + assert!(hazards["states"]["open_escalated"].is_object()); + // The board's desk lists the escalated cases only, and may finish them. + let board: Value = serde_yaml::from_str(&files["questions/board/index.yaml"]).unwrap(); + let card = board["alternatives"].as_array().unwrap().iter().find(|a| a["features"][0]["resource"]["source"]["bucket"] == "hazards").unwrap(); + let resource = &card["features"][0]["resource"]; + assert_eq!(resource["states"], json!(["open_escalated"])); + assert_eq!(resource["transitions"][0]["from"], "open_escalated"); + assert_eq!(resource["transitions"][0]["to"], "fixed"); + // Traffic's own desk still has the case, with everything, and may + // send it up itself. + let traffic: Value = serde_yaml::from_str(&files["questions/traffic/index.yaml"]).unwrap(); + let card = traffic["alternatives"].as_array().unwrap().iter().find(|a| a["features"][0]["resource"]["source"]["bucket"] == "hazards").unwrap(); + let tos: Vec<&str> = card["features"][0]["resource"]["transitions"].as_array().unwrap().iter().map(|t| t["to"].as_str().unwrap()).collect(); + assert!(tos.contains(&"open_escalated") && tos.contains(&"fixed"), "{tos:?}"); + assert!(card["features"][0]["resource"].get("states").is_none()); + // The spec knows the stage, in words. + assert!(files["needs.yaml"].contains("open_escalated: escalated to board")); + // A stage cannot both end and go up on the clock. + let both: Plan = serde_yaml::from_str(&PLAN.replace(" handled_by: traffic\n stages: [open, fixed]\n", " handled_by: traffic\n escalate: { open: { days: 7, to: board } }\n deadlines: { open: { days: 30, to: fixed } }\n stages: [open, fixed]\n")).unwrap(); + assert!(both.validate().unwrap_err().to_string().contains("not both")); + } + + #[test] + fn a_need_about_another_is_answered_from_the_boards_own_rows() { + let plan: Plan = serde_yaml::from_str(BOARD).unwrap(); + plan.validate().unwrap(); + let files = render(&plan, &fallback_words(&plan)).unwrap(); + // The board links every row to the record's own page, on the + // front door and on the full list alike. + let board: Value = serde_yaml::from_str(&files["questions/status/oppslag/index.yaml"]).unwrap(); + let link = &board["alternatives"][0]["features"][0]["resource"]["link"]; + assert_eq!(link["to"], "/status/oppslag/{id}"); + assert_eq!(link["label"], "At den som la ut oppslaget får vite det"); + assert!(files["questions/index.yaml"].contains("to: /status/oppslag/{id}")); + // The page per record: a dynamic page holding the form, which + // carries the record's id and takes a signed-in person's address. + let page: Value = serde_yaml::from_str(&files["questions/status/oppslag/[key].yaml"]).unwrap(); + let alt = &page["alternatives"][0]; + assert_eq!(alt["record_as"], "svar"); + assert_eq!(alt["action"], "/received/answer"); + let reqs = alt["features"].as_array().unwrap().last().unwrap()["requirements"].as_array().unwrap(); + assert_eq!(reqs[0]["value"], "{user.email}"); + // Two needs about the same record share its page, one form each. + let two: Plan = serde_yaml::from_str(&format!("{BOARD} - id: borrow\n who: en nabo som vil låne det\n wants: å låne det\n about: notice\n lands_in: laan\n handled_by: redaktor\n stages: [open, lest]\n done_when: [lest]\n max_fields: 1\n collects:\n - {{ name: email, type: email }}\n")).unwrap(); + two.validate().unwrap(); + let files = render(&two, &fallback_words(&two)).unwrap(); + let page: Value = serde_yaml::from_str(&files["questions/status/oppslag/[key].yaml"]).unwrap(); + let buckets: Vec<&str> = page["alternatives"].as_array().unwrap().iter().map(|a| a["record_as"].as_str().unwrap()).collect(); + assert_eq!(buckets, ["svar", "laan"]); + assert_eq!(reqs[1]["type"], "record"); + assert_eq!(reqs[1]["of"], "oppslag"); + assert_eq!(reqs[1]["value"], "{key}"); + // The answer is not on the front door, and its mail reaches + // the poster through the record field. + assert!(!files["questions/index.yaml"].contains("record_as: svar")); + assert!(files["aggregates.yaml"].contains("to: '{oppslag.email}'"), "{}", files["aggregates.yaml"]); + // The verifier is told where an answer enters. + let needs: Value = serde_yaml::from_str(&files["needs.yaml"]).unwrap(); + assert_eq!(needs["needs"][1]["enters"], "/status/oppslag/[key]"); + // A need that answers one nobody can see is refused, and so is + // owner mail with nobody to send it to. + let hidden: Plan = serde_yaml::from_str(&BOARD.replace(" watched_by: [public]\n", "")).unwrap(); + assert!(hidden.validate().unwrap_err().to_string().contains("watched_by")); + let orphan: Plan = serde_yaml::from_str(&BOARD.replace(" about: notice\n", "")).unwrap(); + assert!(orphan.validate().unwrap_err().to_string().contains("owner")); + } + + #[test] + fn a_card_that_left_a_desk_does_not_leave_its_words_on_the_next_card() { + // Live: the board handles expenses, and its card has words of + // its own. Then the plan moves expenses to traffic's desk: the + // invite that now sits where the card sat must not take them. + let live: Plan = serde_yaml::from_str(PLAN).unwrap(); + let mut words = fallback_words(&live); + words.get_mut("questions/board/index.yaml").unwrap()["buckets"]["expenses"]["heading"] = json!("Money people want back"); + let repo = std::env::temp_dir().join(format!("trainer-moved-{}", std::process::id())); + for (path, text) in render(&live, &words).unwrap() { + let full = repo.join(&path); + std::fs::create_dir_all(full.parent().unwrap()).unwrap(); + std::fs::write(full, text).unwrap(); + } + // Expenses go to traffic's desk; the board watches hazards + // instead, so its desk stays, with another bucket's card where + // the expenses card was. + let moved: Plan = serde_yaml::from_str(&PLAN.replace(" handled_by: board\n", " handled_by: traffic\n").replace(" handled_by: traffic\n stages: [open, fixed]", " handled_by: traffic\n watched_by: [board]\n stages: [open, fixed]")).unwrap(); + moved.validate().unwrap(); + let lifted = lift_words(&moved, &repo).unwrap(); + let board = &lifted["questions/board/index.yaml"]; + assert!(board["buckets"].get("expenses").is_none()); + assert_ne!(board["buckets"]["hazards"]["heading"], "Money people want back", "the hazards card took the moved card's heading"); + for (group, invite) in board["invites"].as_object().unwrap() { + assert_ne!(invite["heading"], "Money people want back", "the invite into {group} took the moved card's heading"); + } + let _ = std::fs::remove_dir_all(&repo); + } + + #[test] + fn inviting_desks_get_a_form_per_group_and_a_list_of_invites_sent() { + let plan: Plan = serde_yaml::from_str(PLAN).unwrap(); + plan.validate().unwrap(); + let files = render(&plan, &fallback_words(&plan)).unwrap(); + // The board invites into both groups; traffic only into itself. + let board = &files["questions/board/index.yaml"]; + assert_eq!(board.matches("invite:").count(), 2, "{board}"); + assert!(board.contains("bucket: portal_invites")); + // An invite whose link ran out can be sent again, from any desk + // that lists them. + assert!(board.contains("from: expired") && board.contains("label: Send again"), "{board}"); + let traffic = &files["questions/traffic/index.yaml"]; + assert_eq!(traffic.matches("invite:").count(), 1); + assert!(traffic.contains("label: Send again")); + // Suggested changes are settled at one desk: the one that + // invites into the most groups. + assert!(board.contains("bucket: portal_edit_suggestions") && board.contains("to: discussed"), "{board}"); + assert!(!traffic.contains("portal_edit_suggestions")); + assert!(traffic.contains("group: traffic") && !traffic.contains("group: board")); + // The plan is refused when an inviter has no desk. + let bad: Plan = serde_yaml::from_str(&PLAN.replace("invites_by: [board]", "invites_by: [nobody]")).unwrap(); + assert!(bad.validate().unwrap_err().to_string().contains("no desk")); + } + + #[test] + fn words_lift_back_out_of_a_rendered_repo_and_new_slots_keep_their_fallback() { + let plan: Plan = serde_yaml::from_str(PLAN).unwrap(); + let mut words = fallback_words(&plan); + // A hand-edited desk: Norwegian words in the live repo. + let desk = words.get_mut("questions/traffic/index.yaml").unwrap(); + desk["question"] = json!("What is waiting?"); + desk["buckets"]["hazards"]["heading"] = json!("Reported hazards"); + desk["buckets"]["hazards"]["moves"]["open->fixed"] = json!("Fixed"); + let dir = std::env::temp_dir().join(format!("lift-{}", std::process::id())); + write_repo(&dir, render(&plan, &words).unwrap()).unwrap(); + + let lifted = lift_words(&plan, &dir).unwrap(); + let desk = &lifted["questions/traffic/index.yaml"]; + assert_eq!(desk["question"], "What is waiting?"); + assert_eq!(desk["buckets"]["hazards"]["heading"], "Reported hazards"); + assert_eq!(desk["buckets"]["hazards"]["moves"]["open->fixed"], "Fixed"); + // A slot the repo never had keeps the fallback. + let plan2: Plan = serde_yaml::from_str(&PLAN.replace("invites_by: [board]", "invites_by: [board, traffic]")).unwrap(); + let lifted2 = lift_words(&plan2, &dir).unwrap(); + assert_eq!(lifted2["questions/traffic/index.yaml"]["question"], "What is waiting?"); + assert!(lifted2["questions/traffic/index.yaml"]["invites"]["board"]["heading"].as_str().unwrap().starts_with("Invite someone")); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn words_follow_their_card_when_the_live_page_is_ordered_differently() { + let plan: Plan = serde_yaml::from_str(PLAN).unwrap(); + let mut words = fallback_words(&plan); + words.get_mut("questions/index.yaml").unwrap()["needs"]["report"]["heading"] = json!("Report a hazard"); + let dir = std::env::temp_dir().join(format!("lift-order-{}", std::process::id())); + write_repo(&dir, render(&plan, &words).unwrap()).unwrap(); + // The live front door has an extra first card the plan knows + // nothing about, so the hazard form is no longer first. + let front = dir.join("questions/index.yaml"); + let mut page: serde_yaml::Value = serde_yaml::from_str(&std::fs::read_to_string(&front).unwrap()).unwrap(); + let alternatives = page["alternatives"].as_sequence_mut().unwrap(); + let stranger: serde_yaml::Value = serde_yaml::from_str("name: About us\ndescription: Founded in 1947.\nrecord_as: newsletter\nfeatures: []\n").unwrap(); + alternatives.insert(0, stranger); + std::fs::write(&front, serde_yaml::to_string(&page).unwrap()).unwrap(); + + let lifted = lift_words(&plan, &dir).unwrap(); + assert_eq!(lifted["questions/index.yaml"]["needs"]["report"]["heading"], "Report a hazard"); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/src/trainer/card.rs b/src/trainer/card.rs new file mode 100644 index 0000000..aa8bcc4 --- /dev/null +++ b/src/trainer/card.rs @@ -0,0 +1,101 @@ +//! An alternative as the visitor meets it, in text. +//! +//! Portal renders a card: the heading, the description, a button, one +//! or two encouragements, and for each section an icon, a title, a +//! line of guidance and the fields with their labels and placeholders. +//! A visitor is oriented by all of it, not the heading alone. The +//! answering visitor and `rate` both read this rendering, so the +//! scored page is the served page; the gutted twin keeps the card's +//! shape and none of its words. + +use serde_json::Value; + +pub fn s(v: &Value) -> &str { + v.as_str().unwrap_or_default() +} + +/// One option on a page. `full` is the card as rendered; otherwise the +/// heading alone, which is what a visitor who reads nothing else sees. +pub fn alternative(alt: &Value, full: bool) -> String { + let mut out = format!("- {}", s(&alt["name"])); + if !full { + out.push('\n'); + return out; + } + let d = s(&alt["description"]).trim(); + if !d.is_empty() { + out.push_str(&format!(": {d}")); + } + out.push('\n'); + if let Some(button) = alt["consequence"].as_array().and_then(|c| c.first()).and_then(Value::as_str) { + out.push_str(&format!(" Button: {button}\n")); + } + for e in alt["encouragements"].as_array().into_iter().flatten().filter_map(Value::as_str) { + out.push_str(&format!(" {e}\n")); + } + for f in alt["features"].as_array().into_iter().flatten() { + let (name, desc, icon) = (s(&f["name"]).trim(), s(&f["description"]).trim(), s(&f["icon"]).trim()); + let mut line = String::from(" "); + if !icon.is_empty() { + line.push_str(&format!("[icon {}] ", icon.rsplit(':').next().unwrap_or(icon))); + } + if !name.is_empty() { + line.push_str(name); + } + if !desc.is_empty() { + line.push_str(&format!("{}{desc}", if name.is_empty() { "" } else { ": " })); + } + if !line.trim().is_empty() { + out.push_str(line.trim_end()); + out.push('\n'); + } + let fields: Vec = f["requirements"] + .as_array() + .into_iter() + .flatten() + .map(|r| { + let label = r["label"].as_str().unwrap_or_else(|| s(&r["name"])); + let hint = s(&r["placeholder"]).trim(); + let opt = if r["optional"] == true { " (optional)" } else { "" }; + if hint.is_empty() { format!("{label}{opt}") } else { format!("{label}{opt} e.g. {hint}") } + }) + .collect(); + if !fields.is_empty() { + out.push_str(&format!(" Asks for: {}\n", fields.join("; "))); + } + } + out +} + +/// The page around its options: what it asks and says of itself. +pub fn page_head(page: &Value) -> String { + let mut out = format!("{}\n", s(&page["name"])); + let d = s(&page["description"]).trim(); + if !d.is_empty() { + out.push_str(&format!("{d}\n")); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn a_card_reads_as_the_visitor_sees_it_and_a_heading_alone_is_a_heading_alone() { + let alt = json!({ + "name": "Get a price", "description": "For owners.", + "consequence": ["Send it"], "encouragements": ["We answer within two days."], + "features": [{"name": "Your brief", "icon": "lucide:file-text", "description": "A few lines are enough.", + "requirements": [{"name": "email", "label": "Email", "placeholder": "you@example.no"}, + {"name": "address", "label": "Address", "optional": true}]}] + }); + let full = alternative(&alt, true); + assert!(full.starts_with("- Get a price: For owners.\n")); + assert!(full.contains("Button: Send it") && full.contains("We answer within two days.")); + assert!(full.contains("[icon file-text] Your brief: A few lines are enough.")); + assert!(full.contains("Asks for: Email e.g. you@example.no; Address (optional)")); + assert_eq!(alternative(&alt, false), "- Get a price\n"); + } +} diff --git a/src/trainer/mod.rs b/src/trainer/mod.rs new file mode 100644 index 0000000..0182eb4 --- /dev/null +++ b/src/trainer/mod.rs @@ -0,0 +1,593 @@ +//! Generates a portal content repo from a business intake. +//! +//! Three phases, each held to portal's `question_lint`: +//! +//! 1. **Needs.** The intake (what the business does, who comes to it, +//! what it keeps track of and through which stages, who handles +//! what) becomes `needs.yaml`. After this phase the file is frozen: +//! it is the spec, and the generator may not weaken it to pass. +//! 2. **Structure.** Pages, `aggregates.yaml` and desks are generated +//! and revised against the lint's findings until it exits zero. +//! 3. **Wording.** The personas are answered blind (headings only, +//! then with descriptions) and misroutes are fed back as wording +//! defects. Only pages may change. +//! +//! The LLM is an external command: prompt on stdin, text on stdout. +//! `claude -p` works; so does anything else with that shape. + +use std::collections::BTreeMap; +use std::io::Write as _; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; + +use anyhow::{anyhow, bail, Context}; + +pub mod build; +mod card; +pub mod plan; +mod rate; +mod report; +mod score; +mod simulate; + +pub const REFERENCE: &str = include_str!("reference.md"); + +struct Opts { + intake: PathBuf, + out: PathBuf, + lint: String, + llm: String, + rounds: usize, + wording_rounds: usize, + /// The model that plays the visitor. A generator grading its own + /// wording agrees with itself, so this should be a different one. + visitor: Option, +} + +/// The trainer, as `portal ...`: `argv` is everything after +/// `portal`. `answer --tasks [--llm ]` is the blind +/// answerer alone, for grading an existing site with any backend; +/// answers go to stdout, ready for `portal lint --needs-score`. +pub fn run(argv: Vec) -> anyhow::Result<()> { + match argv.first().map(String::as_str) { + Some("simulate") => return simulate::run(&argv), + Some("compare") => return simulate::compare(&argv), + Some("plan") => return plan::run(&argv), + Some("build") => return build::run(&argv), + Some("rate") => return rate::rate(&argv), + Some("report") => return report::run(&argv), + Some("prefer") => return rate::prefer(&argv), + Some("score") => return score::run(&argv), + _ => {} + } + if argv.first().map(String::as_str) == Some("answer") { + let value = |flag: &str| { + argv.iter() + .position(|a| a == flag) + .and_then(|i| argv.get(i + 1)) + .cloned() + }; + let tasks = value("--tasks").ok_or_else(|| anyhow!("usage: portal-trainer answer --tasks [--repo ] [--llm 'cmd'] [--seed ]"))?; + let cmd = value("--llm").unwrap_or_else(|| "claude -p".to_string()); + let repo = value("--repo").map(PathBuf::from); + // --seed: the same tasks answered again with other shuffles and + // sampling, so a score comes with its spread. 0 is the run + // every earlier score used. + let seed: u64 = value("--seed").and_then(|s| s.parse().ok()).unwrap_or(0); + print!("{}", answer_blind(&cmd, &std::fs::read_to_string(&tasks).with_context(|| format!("reading {tasks}"))?, repo.as_deref(), seed)?); + return Ok(()); + } + let opts = parse_args()?; + let intake = std::fs::read_to_string(&opts.intake) + .with_context(|| format!("reading {}", opts.intake.display()))?; + std::fs::create_dir_all(opts.out.join("questions"))?; + std::fs::create_dir_all(opts.out.join("llm"))?; + LLM_LOG.set(opts.out.join("llm")).ok(); + let mut log = Vec::new(); + + // Phase 1: the spec. + let mut feedback = String::new(); + for round in 1..=opts.rounds { + let prompt = format!( + "{REFERENCE}\n\n# Task\n\nA business filled in this intake. Write ONLY `needs.yaml` for it: every need the intake implies, for the public and for each role inside the business, with states taken from how the business says things progress, and personas.\n\n## Intake\n\n{intake}\n{feedback}\n{FORMAT}" + ); + let Ok(files) = parse_files(&llm(&opts.llm, &prompt)?) else { + eprintln!("needs round {round}: the reply had no file blocks, asking again"); + feedback = "\n## Your last attempt\n\nIt contained no <<>> blocks at all. Return needs.yaml in exactly that format.\n".to_string(); + continue; + }; + let Some(needs) = files.get("needs.yaml") else { + feedback = "\n## Your last attempt\n\nIt contained no needs.yaml.\n".to_string(); + continue; + }; + std::fs::write(opts.out.join("needs.yaml"), needs)?; + // A placeholder front door, so the lint gets as far as parsing + // the spec; every need fails, a parse error is what we look for. + std::fs::write(opts.out.join("questions/index.yaml"), "name: Placeholder?\n")?; + let (_, report) = lint(&opts, &[])?; + if let Some(line) = report.lines().find(|l| l.contains("parsing needs.yaml")) { + eprintln!("needs round {round}: {line}"); + feedback = format!("\n## Your last attempt failed to parse\n\n{line}\n\n```yaml\n{needs}\n```\n"); + continue; + } + eprintln!("needs round {round}: needs.yaml parses, frozen"); + log.push(format!("needs: parsed in round {round}")); + feedback.clear(); + break; + } + if !feedback.is_empty() { + bail!("needs.yaml never parsed in {} round(s); the replies are in {}/llm/", opts.rounds, opts.out.display()); + } + let needs = std::fs::read_to_string(opts.out.join("needs.yaml")).context("no needs.yaml was produced")?; + + // Phase 2: structure. The page-writing model sees the needs and + // never the personas: pages written with the test in view route + // their own personas perfectly and strangers badly (measured: + // 10/10 on its own personas, 8/10 headings-only on held-out ones). + let needs_full = needs; + let needs = needs_full + .split("\npersonas:") + .next() + .unwrap_or(&needs_full) + .to_string(); + let mut green = false; + let mut report = String::new(); + for round in 1..=opts.rounds { + let prompt = if round == 1 { + format!( + "{REFERENCE}\n\n# Task\n\nWrite the content repo that meets this frozen `needs.yaml`: `aggregates.yaml`, `site.yaml`, and every page under `questions/` - the front door, followup pages, role-gated pages, one desk per handling group. Do not return needs.yaml.\n\n## Intake\n\n{intake}\n\n## needs.yaml (frozen)\n\n```yaml\n{needs}\n```\n\n{FORMAT}" + ) + } else { + format!( + "{REFERENCE}\n\n# Task\n\nThe verifier rejected the repo below. Fix it. Return only the files you change, each complete. To remove a file, return it with the single line DELETE. needs.yaml is frozen; do not return it.\n\n## Verifier output\n\n```\n{report}\n```\n\n## needs.yaml (frozen)\n\n```yaml\n{needs}\n```\n\n## Current files\n\n{}\n\n{FORMAT}", + dump(&opts.out)? + ) + }; + match parse_files(&llm(&opts.llm, &prompt)?) { + Ok(files) => apply(&opts.out, files)?, + Err(_) => eprintln!("structure round {round}: the reply had no file blocks"), + } + let (ok, r) = lint(&opts, &[])?; + report = r; + let fails = report.lines().filter(|l| l.starts_with("FAIL")).count(); + eprintln!("structure round {round}: {}", if ok { "green".to_string() } else { format!("{fails} FAIL line(s)") }); + log.push(format!("structure round {round}: ok={ok} fails={fails}")); + if ok { + green = true; + break; + } + } + if !green { + std::fs::write(opts.out.join("run.log"), format!("{}\n\n{report}", log.join("\n")))?; + bail!("structure never went green in {} round(s); last verifier output is in run.log", opts.rounds); + } + + // Phase 3: wording. + for round in 0..=opts.wording_rounds { + let mut defects = String::new(); + let mut summary = Vec::new(); + for skim in [true, false] { + let tasks_path = opts.out.join(if skim { "tasks-skim.jsonl" } else { "tasks.jsonl" }); + let mut args = vec!["--needs-tasks".to_string(), tasks_path.display().to_string()]; + if skim { + args.push("--skim".to_string()); + } + lint(&opts, &args)?; + let answers = answer_blind(opts.visitor.as_deref().unwrap_or(&opts.llm), &std::fs::read_to_string(&tasks_path)?, Some(&opts.out), 0)?; + let answers_path = opts.out.join(if skim { "answers-skim.jsonl" } else { "answers.jsonl" }); + std::fs::write(&answers_path, answers)?; + let mut args = vec!["--needs-score".to_string(), answers_path.display().to_string()]; + if skim { + args.push("--skim".to_string()); + } + let (_, scored) = lint(&opts, &args)?; + let mode = if skim { "headings only" } else { "headings and descriptions" }; + for line in scored.lines().filter(|l| l.starts_with("MISROUTE") || l.starts_with("HESITANT")) { + defects.push_str(&format!("[{mode}] {line}\n")); + } + if let Some(line) = scored.lines().find(|l| l.starts_with("wording:")) { + summary.push(format!("{mode}: {line}")); + } + } + eprintln!("wording round {round}: {}", summary.join(" | ")); + log.push(format!("wording round {round}: {}", summary.join(" | "))); + if defects.is_empty() || round == opts.wording_rounds { + break; + } + let personas = needs_full.split("\npersonas:").nth(1).unwrap_or_default(); + let prompt = format!( + "{REFERENCE}\n\n# Task\n\nThe structure is correct. Visitors are picking the wrong alternative. Each MISROUTE below is a persona who, reading the page, chose an alternative that does not meet their need; HESITANT means they chose right and nearly did not. Rewrite alternative headings first (a heading that needs its description has failed), descriptions second. Keep every `record_as`, `action`, field and state as it is. If you rename an alternative that needs.yaml names in `via`, you may not: keep that one. Return only the pages you change, each complete.\n\n## Defects\n\n```\n{defects}```\n\n## The personas\n\n```yaml\npersonas:{personas}\n```\n\n## Current files\n\n{}\n\n{FORMAT}", + dump(&opts.out)? + ); + let before = snapshot(&opts.out)?; + apply(&opts.out, parse_files(&llm(&opts.llm, &prompt)?)?)?; + let (ok, r) = lint(&opts, &[])?; + if !ok { + eprintln!("wording round {round}: the rewrite broke structure, reverted"); + log.push(format!("wording round {round}: rewrite reverted\n{r}")); + restore(&opts.out, before)?; + break; + } + } + std::fs::write(opts.out.join("run.log"), log.join("\n") + "\n")?; + println!("content repo written to {}", opts.out.display()); + Ok(()) +} + +const FORMAT: &str = "# Output format\n\nReturn files and nothing else, each as:\n\n<<>>\n...complete file content...\n<<>>\n\nNo prose, no code fences."; + +fn parse_args() -> anyhow::Result { + let mut args = std::env::args().skip(1); + if args.next().as_deref() != Some("generate") { + bail!("usage: portal-trainer generate --intake --out [--lint question_lint] [--llm 'claude -p'] [--rounds 4] [--wording-rounds 2]"); + } + let mut opts = Opts { + intake: PathBuf::new(), + out: PathBuf::new(), + lint: "question_lint".to_string(), + llm: "claude -p".to_string(), + rounds: 4, + wording_rounds: 2, + visitor: None, + }; + while let Some(arg) = args.next() { + let mut value = || args.next().ok_or_else(|| anyhow!("{arg} needs a value")); + match arg.as_str() { + "--intake" => opts.intake = value()?.into(), + "--out" => opts.out = value()?.into(), + "--lint" => opts.lint = value()?, + "--llm" => opts.llm = value()?, + "--rounds" => opts.rounds = value()?.parse()?, + "--wording-rounds" => opts.wording_rounds = value()?.parse()?, + "--visitor-llm" => opts.visitor = Some(value()?), + other => bail!("unknown argument {other}"), + } + } + if opts.intake.as_os_str().is_empty() || opts.out.as_os_str().is_empty() { + bail!("--intake and --out are required"); + } + Ok(opts) +} + +/// Prompt on stdin, text on stdout. +/// Where `generate` keeps every prompt and reply (`/llm/`). A +/// model that returns nothing usable has to be read, not guessed at. +static LLM_LOG: std::sync::OnceLock = std::sync::OnceLock::new(); +static LLM_CALLS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + +fn llm(cmd: &str, prompt: &str) -> anyhow::Result { + llm_env(cmd, prompt, &[]) +} + +/// As `llm`, with hints for the backend in its environment. +fn llm_env(cmd: &str, prompt: &str, env: &[(&str, &str)]) -> anyhow::Result { + let reply = llm_with(cmd, prompt, env); + if let Some(dir) = LLM_LOG.get() { + let n = LLM_CALLS.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1; + std::fs::write(dir.join(format!("{n:02}-prompt.md")), prompt).ok(); + let text = match &reply { + Ok(text) => text.clone(), + Err(e) => format!("ERROR: {e}"), + }; + std::fs::write(dir.join(format!("{n:02}-reply.md")), text).ok(); + } + reply +} + +/// As `llm`, with hints for the backend in its environment (`SEED`, +/// `FORMAT=json`, `TEMPERATURE`). A backend is free to ignore them. +pub(crate) fn llm_with(cmd: &str, prompt: &str, env: &[(&str, &str)]) -> anyhow::Result { + let mut child = Command::new("sh") + .arg("-c") + .arg(cmd) + .envs(env.iter().copied()) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .spawn() + .with_context(|| format!("starting {cmd:?}"))?; + let mut stdin = child.stdin.take().expect("piped"); + let prompt = prompt.to_string(); + // Written from a thread: a large prompt would otherwise deadlock + // against a child that starts writing before it finishes reading. + let writer = std::thread::spawn(move || stdin.write_all(prompt.as_bytes())); + let output = child.wait_with_output()?; + writer.join().ok(); + if !output.status.success() { + bail!("{cmd:?} exited with {}", output.status); + } + Ok(String::from_utf8_lossy(&output.stdout).into_owned()) +} + +fn lint(opts: &Opts, extra: &[String]) -> anyhow::Result<(bool, String)> { + let output = Command::new(&opts.lint) + .arg("--path") + .arg(opts.out.join("questions")) + .args(extra) + .output() + .with_context(|| format!("running {}", opts.lint))?; + let mut report = String::from_utf8_lossy(&output.stdout).into_owned(); + report.push_str(&String::from_utf8_lossy(&output.stderr)); + Ok((output.status.success(), report)) +} + +/// `<<>> ... <<>>` blocks. Anything outside a block is +/// ignored, so a model that adds a sentence does no harm. +fn parse_files(text: &str) -> anyhow::Result> { + // Small models mangle the markers: `<<>` with a bracket short, + // a stray ``, the next `<< = None; + let close = |current: &mut Option<(String, String)>, files: &mut BTreeMap| { + if let Some((path, body)) = current.take() { + files.insert(path, body); + } + }; + for line in text.lines() { + let mut rest = line; + loop { + let (file_at, end_at) = (rest.find("<< { + close(&mut current, &mut files); + let after = &rest[f + "<<>>").unwrap_or(after.len()); + current = Some((after[..stop].trim().to_string(), String::new())); + rest = after.get(stop + 3..).unwrap_or(""); + } + (_, Some(e)) => { + close(&mut current, &mut files); + rest = &rest[e + "<< break, + } + } + // A line that carried a marker carries no content. + if std::ptr::eq(rest, line) { + if let Some((_, body)) = current.as_mut() { + body.push_str(line); + body.push('\n'); + } + } + } + close(&mut current, &mut files); + if files.is_empty() { + bail!("the model returned no <<>> blocks"); + } + Ok(files) +} + +/// Model output is written to disk, so the path is checked, not +/// trusted: repo-relative, yaml, and only where content lives. +fn allowed(path: &str) -> bool { + let p = Path::new(path); + let plain = p.components().all(|c| matches!(c, std::path::Component::Normal(_))); + plain + && p.extension().and_then(|e| e.to_str()) == Some("yaml") + && (matches!(path, "aggregates.yaml" | "site.yaml") || path.starts_with("questions/")) +} + +fn apply(out: &Path, files: BTreeMap) -> anyhow::Result<()> { + for (path, body) in files { + if !allowed(&path) { + eprintln!("ignored {path:?}: outside what the generator may write"); + continue; + } + let target = out.join(&path); + if body.trim() == "DELETE" { + std::fs::remove_file(&target).ok(); + continue; + } + if let Some(parent) = target.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(target, body)?; + } + Ok(()) +} + +fn content_files(out: &Path) -> anyhow::Result> { + fn walk(dir: &Path, found: &mut Vec) -> anyhow::Result<()> { + for entry in std::fs::read_dir(dir)? { + let path = entry?.path(); + if path.is_dir() { + walk(&path, found)?; + } else if path.extension().and_then(|e| e.to_str()) == Some("yaml") { + found.push(path); + } + } + Ok(()) + } + let mut found = Vec::new(); + for name in ["aggregates.yaml", "site.yaml"] { + if out.join(name).exists() { + found.push(out.join(name)); + } + } + walk(&out.join("questions"), &mut found)?; + found.sort(); + Ok(found) +} + +fn snapshot(out: &Path) -> anyhow::Result> { + content_files(out)? + .into_iter() + .map(|p| Ok((p.clone(), std::fs::read_to_string(p)?))) + .collect() +} + +fn restore(out: &Path, before: Vec<(PathBuf, String)>) -> anyhow::Result<()> { + for path in content_files(out)? { + std::fs::remove_file(path)?; + } + for (path, body) in before { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(path, body)?; + } + Ok(()) +} + +fn dump(out: &Path) -> anyhow::Result { + let mut text = String::new(); + for path in content_files(out)? { + let rel = path.strip_prefix(out).unwrap_or(&path).display().to_string(); + text.push_str(&format!("<<>>\n{}<<>>\n\n", std::fs::read_to_string(&path)?)); + } + Ok(text) +} + +/// The answering side never sees `expect` or which need a persona has. +/// +/// One call per task, so no visitor sees another visitor's situation +/// (a batch of eight personas over four headings is nearly a matching +/// puzzle, and a model solves it as one). The options are shuffled per +/// task, so a heading's place on the page cannot carry the answer, and +/// the call is seeded from the task id, so a rerun on the same site is +/// the same experiment. +/// +/// With a content repo to read, the visitor sees each option as the +/// card portal renders: button, encouragement, sections with icon and +/// guidance, fields with placeholders. Headings-only tasks (`--skim`, +/// where the task's text is the heading itself) stay headings only. +pub(crate) fn answer_blind(cmd: &str, tasks: &str, repo: Option<&Path>, seed_offset: u64) -> anyhow::Result { + let mut answers = String::new(); + for line in tasks.lines().filter(|l| !l.trim().is_empty()) { + let task: serde_json::Value = serde_json::from_str(line)?; + let id = task["id"].as_str().unwrap_or_default(); + let body = task["state"]["body"].as_str().unwrap_or_default(); + let pick = &task["questions"]["pick"]; + let instructions = pick["instructions"].as_str().unwrap_or_default(); + let mut options: Vec<(&str, &str)> = pick["criteria"] + .as_object() + .into_iter() + .flatten() + .map(|(k, v)| (k.as_str(), v.as_str().unwrap_or_default())) + .collect(); + let skim = options.iter().all(|(name, text)| text.is_empty() || text == name); + let page = if skim { None } else { repo.and_then(|r| rate::load_page(r, task["page"].as_str().unwrap_or("/"))) }; + let seed = fnv(id).wrapping_add(seed_offset.wrapping_mul(0x9E37_79B9_7F4A_7C15) as _); + shuffle(&mut options, seed); + // Options are numbered and the answer is the number: with a + // whole card in view, a small model asked to copy a heading + // copied the page's description instead (measured: six of + // fourteen misroutes on one run were that), and no heading + // matched. A number cannot be a description. + let listed: Vec = options + .iter() + .enumerate() + .map(|(i, (name, text))| { + let card = page + .as_ref() + .and_then(|p| p["alternatives"].as_array()) + .and_then(|alts| alts.iter().find(|a| a["name"] == *name)) + .map(|a| card::alternative(a, true)); + let body = match card { + Some(card) => card.trim_end().trim_start_matches("- ").to_string(), + None if skim => name.to_string(), + None => format!("{name}: {text}"), + }; + format!("{}. {body}", i + 1) + }) + .collect(); + let mut head = page.as_ref().map(card::page_head).unwrap_or_default(); + // What the page says before it offers anything (the opener, the + // look around): read, not picked. + if let Some(context) = task["state"]["page"].as_str().filter(|c| !c.trim().is_empty()) { + head.push_str(&format!("The page first says:\n{context}\n\n")); + } + let prompt = format!( + "You are this person: {body}\n\n{instructions}\n{head}The page offers these options:\n\n{}\n\nWhich one do you pick? Judge only from your own situation and the wording in front of you. Give your honest confidence between 0 and 1; if two options fit you about equally, confidence is low.\nReply as JSON: {{\"choice\": , \"confidence\": 0.00}}", + listed.join("\n\n") + ); + let reply = llm_env(cmd, &prompt, &[("FORMAT", "json"), ("SEED", &seed.to_string()), ("TEMPERATURE", "0.2")])?; + let parsed = simulate::first_object(&reply).unwrap_or(serde_json::Value::Null); + let names: Vec<&str> = options.iter().map(|(n, _)| *n).collect(); + let raw = match &parsed["choice"] { + serde_json::Value::Number(n) => n.to_string(), + other => other.as_str().unwrap_or_default().to_string(), + }; + // A number, or a heading for a model that wrote one anyway. + let choice = match raw.trim().trim_end_matches('.').parse::() { + Ok(n) if (1..=names.len()).contains(&n) => names[n - 1].to_string(), + _ => simulate::resolve_choice(&names, &raw), + }; + let confidence = parsed["confidence"].as_f64().unwrap_or(0.0); + answers.push_str(&serde_json::to_string(&serde_json::json!({ "id": id, "choice": choice, "confidence": confidence }))?); + answers.push('\n'); + } + Ok(answers) +} + +/// FNV-1a over the task id: a seed that is the same on every run. +fn fnv(text: &str) -> u32 { + text.bytes().fold(0x811c_9dc5u32, |h, b| (h ^ u32::from(b)).wrapping_mul(0x0100_0193)) +} + +/// Fisher-Yates on a small xorshift, so the order is fixed by the seed. +fn shuffle(items: &mut [T], seed: u32) { + let mut x = seed.max(1); + for i in (1..items.len()).rev() { + x ^= x << 13; + x ^= x >> 17; + x ^= x << 5; + items.swap(i, (x as usize) % (i + 1)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn files_are_parsed_from_blocks_and_prose_is_ignored() { + let files = parse_files("Sure!\n<<>>\ntitle: A\n<<>>\nnoise\n<<>>\nname: Q?\n<<>>\n").unwrap(); + assert_eq!(files["site.yaml"], "title: A\n"); + assert_eq!(files.len(), 2); + assert!(parse_files("no blocks here").is_err()); + } + + #[test] + fn mangled_markers_from_a_small_model_still_separate_the_files() { + // Verbatim shape of a nemotron reply: a short END, a stray + // think tag, the next FILE glued on, and no END at the very end. + let files = parse_files( + "<<>>\nname: Desk?\n<<><<>>\nname: Hva trenger du?\n<<>>\ntitle: A\n", + ) + .unwrap(); + assert_eq!(files["questions/desk/index.yaml"], "name: Desk?\n"); + assert_eq!(files["questions/index.yaml"], "name: Hva trenger du?\n"); + assert_eq!(files["site.yaml"], "title: A\n"); + } + + #[test] + fn a_task_is_shuffled_the_same_way_every_time_and_differently_from_the_next() { + let mut a = ["w", "x", "y", "z"]; + let mut b = a; + shuffle(&mut a, fnv("scaffolder@/")); + shuffle(&mut b, fnv("scaffolder@/")); + assert_eq!(a, b); + let mut c = ["w", "x", "y", "z"]; + shuffle(&mut c, fnv("apprentice@/")); + assert_ne!(a, c); + let mut sorted = a; + sorted.sort(); + assert_eq!(sorted, ["w", "x", "y", "z"]); + } + + #[test] + fn only_content_paths_may_be_written() { + assert!(allowed("site.yaml")); + assert!(allowed("aggregates.yaml")); + assert!(allowed("questions/desk/_section.yaml")); + // The spec is frozen after phase one. + assert!(!allowed("needs.yaml")); + assert!(!allowed("../outside.yaml")); + assert!(!allowed("/etc/passwd.yaml")); + assert!(!allowed("questions/run.sh")); + assert!(!allowed(".gitea/workflows/deploy.yaml")); + } +} diff --git a/src/trainer/plan.rs b/src/trainer/plan.rs new file mode 100644 index 0000000..ec90e14 --- /dev/null +++ b/src/trainer/plan.rs @@ -0,0 +1,495 @@ +//! From an intake, in plain words, to a `plan.yaml` a person can check. +//! +//! This is the one step where a model decides structure: which needs +//! the intake implies, the stages each goes through, who handles it, +//! what the form collects and how much that person will put up with. +//! Everything after it is deterministic (`build`), so this is where a +//! misreading of the business has to be caught, and it is caught by a +//! person reading the plan, not by a verifier. The model's YAML is +//! parsed and validated exactly as `build` would, and the errors go +//! back to it for another round; what passes is written verbatim, so +//! what the person reads is what the model wrote. +//! +//! Personas are never asked of this model: a persona written by the +//! same model that phrased the needs tests nothing. They come from a +//! person or a sector file, attached with `--personas`. + +use anyhow::{anyhow, bail, Context}; + +use crate::trainer::build::Plan; +use crate::trainer::simulate::first_object; + +const REFERENCE: &str = r#"# plan.yaml + +A plan is a needs spec that also says what each form collects. A +builder turns it into a working site with no further decisions, so +every structural fact about the business has to be in it. + +```yaml +business: +language: en # the intake's language code +contact: { name: , email: } +about: > + What the business does, in its own words. +mail: { from: " ", reply_to: } + # who every mail the site sends comes from; required as soon + # as any need has `mails` + +does: # the front door's first card: what the business does, one + - id: kebab-case # or two features, one role each, no button. Say it for the + for: # role the business exists for and the next one it serves. + text: what it does for them, in the intake's own words + +shows: # what the site shows as it is: a live figure, the latest + - id: kebab-case # releases, what has been built; `resource` is portal's block + text: one line on what it is + resource: { public: true, source: { kind: url, url: https://... } } + +doors: # a first question before the forms, only when the offers + - id: kebab-case # are more than one page holds (measured: four doors beat + who: who comes through it # ten offers on one page; six offers on one page beat doors) + +groups: # groups with a label the public may see: the committees + - { id: traffic, label: Traffic, invited_by: [board] } # a person can ask to join (optional; label defaults to the id) +invites_by: [board] # groups whose desk may invite people into every desk + +needs: + - id: kebab-case # ASCII only + who: the kind of person, in plain words, in the intake's language + wants: what they want from the business + door: # the door this public need sits behind; omit to put it on the front door + enters_as: # ONLY for a role inside the business; omit for the public + lands_in: # snake_case, ASCII + handled_by: # the role that must see and move the record + stages: [open, , ..., , ] + # EVERY stage the business said a case goes through, first to + # last, read off their own sentences one verb at a time: "they + # come in, we go and look, we send a quote, we win or lose it" + # is [open, surveyed, quoted, won, lost]. A pipeline is not an + # inbox: "answered" is rarely the real outcome. + done_when: [, ] # every stage that counts as finished, good or bad + success: [] # the finished stages that are a good outcome for the business + skippable: [] # stages a case may pass over (papers already complete) + requires: # what the person must ALREADY have or be for this to work: + # a membership, a reference number, the link from a mail, an + # account. Stated on the card above the form. Omit when there + # is none; never invent one + turned_down_from: [open, ] + # the unfinished stages a case can still be turned down from; + # omit for every one. An applicant is declined before they + # start, not after - after, they depart + reached_from: { : [, ...] } + # where a stage is entered from, when the line does not say it. + # Two things need it and nothing else does: a case SENT BACK to + # a stage it was already past ("papers missing" again, after the + # hearing turned something up), and a GOOD ENDING partway along, + # where a case finishes well without walking to the end ("we + # changed our own decision", instead of passing it upwards). + # A stage named here is entered from exactly those stages and + # nowhere else, so it also says where a refusal may NOT come + # from. Omit unless the business described one of those two + moves_by: { : [, ...] } + # only where someone other than handled_by moves a case INTO a + # stage: another group, or `submitter` (the person who sent the + # form; the form must then collect a required `email`) + watched_by: [] # groups that see these cases and move nothing; `public` opens a + # read-only page of the bucket to everyone (contact details hidden) + mails: { open: submitter, : submitter, :
} + # who hears when a case enters a stage: `submitter` (the form + # must then collect a required `email`) or one address. Tell the + # person on arrival and on every decision that is about them. + grants: { : } + # a stage that makes the person a member of a group when their + # case arrives (an approval that IS membership); the form must + # then collect a required `name` and a required `email` + deadlines: { : { days: 30, to: } } + # a stage a clock moves on from when a case sits in it that long + # (a notice that expires, a hearing with no answer); only along + # a move the stages already allow + public_states: [] # with `public` in watched_by: the stages the public list shows + # (a noticeboard shows what is up, not what was taken down) + stages_text: { open: , : } + # each stage in the intake's language, where the ASCII id + # would read wrong to a person: "ikke_lost" is "ikke løst" + events: { open: applied } # ONLY when rebuilding a live site: the event names its + # aggregates.yaml already has, so its records keep replaying + attended_by: > # ONLY for a stage no desk button moves: the automation that does + what moves it, in a sentence + max_fields: 4 # decide this BEFORE writing collects, from what the intake + # says about the person; a budget that equals the form's size + # is counted, not decided, and is reported as such + # how many required fields THIS person will tolerate, from what + # the intake says about them: someone on a phone with gloves + # on gets 3, a first enquiry 4 to 6, a firm's paperwork 5 to 6 + collects: # the form; name is snake_case ASCII, type one of + # text (default), email, tel, textarea, date, select, location + # (a pin on a map, stored as lat/lon/zoom; optional: true, + # since a map needs a browser that can draw one) + - { name: name } + - { name: phone, type: tel } + - { name: problem, type: textarea } + - { name: urgency, type: select, options: [This week, This month, No rush] } + - { name: can_do, type: select, multiple: true, options: [Drive, Cook, Carry] } # any number + - { name: committee, type: select, options_from: groups } # the labelled groups above + - { name: address, optional: true, private: true } # never on a public page +``` + +What a portal site can do - use what serves this business, leave the +rest. Each is one line in the plan, and the builder does the wiring: + +- **Show, not only ask.** A bucket with `watched_by: [public]` becomes + a public list: its newest few records sit inline on the front door, + linked to the whole list, contact details pruned. What neighbours + reported and where it stands, which changes were approved, what is + on the noticeboard: a visitor who sees the place working trusts it, + and a person who reported something sees it being dealt with. + `public_states` shows only the stages worth showing. +- **Stay in dialogue.** `mails` tells the person at the stages that + are about them, and a reply to that mail comes back to the case as + a note. `moves_by: { : [submitter] }` gives them a move of + their own through their link: confirm, withdraw, say it is solved. +- **Let a decision do the work.** `grants` makes an approval into + membership: the person gets an account and a place in the group the + moment the case gets there; `deadlines` lets a stage end on its own + (a notice that expires, a request nobody took up); `invites_by` lets + a desk bring a person in without anyone touching an admin tool. +- **Let nothing wait unseen.** `escalate: { open: { days: 7, to: styret } }` + sends a case nobody has moved in a week up to another group's desk, + where it sits with the same buttons until someone finishes it; the + desk that had it keeps it too, and may send it up itself. A desk job, + not a mail: the group sees it when they sit down. +- **Let one record answer another.** A need `about` another need is + one answer per record of it, from that record's own row on the + public list: a neighbour saying "I can lend you that" to a notice, a + bid to a request. `mails: { open: owner }` sends the answer straight + to whoever posted the record, and the site keeps no queue - the + poster's inbox is the queue. The site never becomes a go-between. +- **Keep some things for members.** A need with `door` and `enters_as` + is offered to everyone on the door and taken on the group's page + behind sign-in, with name and address filled from the account: a + seat on a committee for members, a discount for customers. The + group is one a decision `grants`, so nobody has to be waved in. +- **Tell it two ways and find out.** `tones` lists tones of voice, one + storyline each: every word on the site is written once per tone, a + visitor reads one storyline throughout, and the site counts what + each brought in. Two is plenty; name them as a person would ("plain, + like a good notice on the door"; "warm, as a neighbour would put + it"). Leave it out to tell one story. +- **Keep the people in portal.** `people: memberships` keeps every + group in portal itself: a desk invite is a place held until the + person signs in, a person is their phone number first, and any + sign-in provider that says who someone is will do (Vipps, for a + Norwegian association). Invite forms then ask for a phone number. + Without it, Kanidm makes an account per invite. +- **Answer for the pages as a group.** `contact: { name, email, group }` + makes the contact a group's inbox rather than a person: no account + is made for it, and every member of the group may suggest changes + to the pages from the page itself. +- **Make the choices real.** `groups` with labels, and a select with + `options_from: groups`, so a form offers exactly the committees or + teams that exist; `multiple: true` where a person can pick several. +- **Place things.** A `location` field puts a pin on a map (where the + hazard is, where the job is), when the site has maps. +- **Open with the business itself.** `does` says what it does for the + roles it exists for; `shows` puts its live content beside that. + +Rules: +- One need per (kind of person, thing they want). Work inside the + business counts: a role recording or posting something is a need + with `enters_as`. The intake's "what slows us down" usually names one. +- Groups are the roles the intake names, as snake_case identifiers. +- The front door opens with `does` (at most two features, one role + each) and `shows`, then holds at most seven offers: doors plus needs + with no door. A door with no need behind it is refused; a door on a + need with `enters_as` is the way to the members' page. A role `does` + speaks for must have a move + of its own after sending (`moves_by` with `submitter`) or a public + view, or it is a spectator. +- Every need whose form collects an email should tell the person on + arrival and on the decisions about them (`mails`); the plan then + needs `mail: { from }`. +- Every feature on a card explains itself: a list says what one row is + and what its buttons do, a form says what to have ready. If an + alternative only works for someone who already has something, say so + in `requires` - a condition the reader meets only after filling the + form in has already cost them the filling in. +- Needs that land in the same bucket must have the same stages, + done_when, skippable, moves_by, watched_by and handled_by. +- Every required field is one the business will use to act on the + record. If the intake gives no reason to need it, make it optional or + leave it out. The required fields must not exceed max_fields. +- Only `select` lists `options`, and it must list them. +- Everything a person reads (who, wants, about, select options) is in + the intake's language. Everything a machine reads (ids, buckets, + groups, stages, field names) is plain ASCII: no æ, ø, å. +- Write no personas. +"#; + +pub fn run(argv: &[String]) -> anyhow::Result<()> { + let value = |flag: &str| { + argv.iter() + .position(|a| a == flag) + .and_then(|i| argv.get(i + 1)) + .cloned() + }; + let usage = "usage: portal-trainer plan --intake --out [--llm 'cmd'] [--personas ] [--rounds 4]"; + let intake_path = value("--intake").ok_or_else(|| anyhow!(usage))?; + let out = std::path::PathBuf::from(value("--out").ok_or_else(|| anyhow!(usage))?); + let cmd = value("--llm").unwrap_or_else(|| "claude -p".to_string()); + let rounds: usize = value("--rounds") + .map(|v| v.parse()) + .transpose()? + .unwrap_or(4); + let intake = + std::fs::read_to_string(&intake_path).with_context(|| format!("reading {intake_path}"))?; + let mut personas: Option = match value("--personas") { + Some(path) => { + let text = std::fs::read_to_string(&path).with_context(|| format!("reading {path}"))?; + let doc: serde_yaml::Value = + serde_yaml::from_str(&text).with_context(|| format!("parsing {path}"))?; + Some(doc.get("personas").cloned().ok_or_else(|| anyhow!("{path} has no `personas:`"))?) + } + None => None, + }; + std::fs::create_dir_all(out.join("llm"))?; + + let mut feedback = String::new(); + // A structural finding the builder would only warn about (a role + // the opener speaks for with nothing to play) goes back to the + // model once; a second time it is the reader's, not another round. + let mut spectators_sent = false; + for round in 1..=rounds { + let prompt = format!( + "{REFERENCE}\n\n# Task\n\nA business filled in this intake. Write its plan.yaml: every need the intake implies, for the public and for each role inside the business, with the stages read off how the business says things progress, the form each need collects, and how much each person will tolerate.\n\n## Intake\n\n{intake}\n{feedback}\nReply with the YAML only, no prose, no code fences." + ); + let reply = crate::trainer::llm_with(&cmd, &prompt, &[("TEMPERATURE", "0.2")])?; + std::fs::write(out.join(format!("llm/{round:02}-prompt.md")), &prompt)?; + std::fs::write(out.join(format!("llm/{round:02}-reply.md")), &reply)?; + let yaml = extract_yaml(&reply); + match check(&yaml) { + Ok(plan) if !plan.spectators().is_empty() && !spectators_sent && round < rounds => { + spectators_sent = true; + let findings = plan.spectators().join("\n- "); + eprintln!("plan round {round}: the plan parses, and a role the opener speaks for has no move after sending; asking once more"); + feedback = format!("\n## Your last attempt parsed, with one thing to fix\n\n- {findings}\n\nGive each such need a move the person makes themselves (`moves_by: {{: [submitter, ]}}`, with a required `email` in its form) or `watched_by: [public]`, and return the whole plan again.\n\n```yaml\n{yaml}\n```\n"); + continue; + } + Ok(plan) => { + let mut text = yaml.trim_end().to_string(); + text.push('\n'); + // The attached personas were written against someone + // else's need ids. The plan is final by now, so a model + // may map them to this plan's ids without its words + // reaching any page: it returns ids and nothing else, + // and an id not in the plan is ignored. What it leaves + // unmapped is listed for the reader. + if let Some(list) = personas.as_mut() { + let mapped = map_personas(&cmd, &plan, list)?; + if mapped > 0 { + eprintln!("plan round {round}: {mapped} persona(s) mapped to this plan's needs"); + } + text.push_str("\n# Held out from every model that writes words.\n"); + text.push_str(&serde_yaml::to_string(&serde_yaml::Mapping::from_iter([( + serde_yaml::Value::from("personas"), + list.clone(), + )]))?); + } + let whole: Plan = + serde_yaml::from_str(&text).context("the plan with its personas attached")?; + let strangers: Vec = whole + .personas + .iter() + .filter(|p| { + !whole + .needs + .iter() + .any(|n| n.id == p["need"].as_str().unwrap_or_default()) + }) + .map(|p| { + format!( + "{} (need: {})", + p["id"].as_str().unwrap_or_default(), + p["need"].as_str().unwrap_or_default() + ) + }) + .collect(); + std::fs::write(out.join("plan.yaml"), &text)?; + let groups: std::collections::BTreeSet<&str> = plan + .needs + .iter() + .flat_map(|n| [Some(n.handled_by.as_str()), n.enters_as.as_deref()]) + .flatten() + .collect(); + let buckets: std::collections::BTreeSet<&str> = + plan.needs.iter().map(|n| n.lands_in.as_str()).collect(); + eprintln!("plan round {round}: {} need(s), {} group(s), {} bucket(s), {} persona(s) attached", plan.needs.len(), groups.len(), buckets.len(), whole.personas.len()); + for n in &plan.needs { + eprintln!( + " {}: {} -> {} ({}), {} of {} field(s) required", + n.id, + n.stages.join(" -> "), + n.lands_in, + n.handled_by, + n.collects.iter().filter(|f| !f.optional).count(), + n.max_fields + ); + } + if !strangers.is_empty() { + eprintln!(" {} persona(s) name a need this plan does not have; set their `need:` to one of the ids above before building:\n {}", strangers.len(), strangers.join("\n ")); + } + // A budget that equals the form is the tell of a model + // that counted the form and called it the person's + // tolerance. One such need is a form that uses its + // room; most of them is the number being invented. + let tight: Vec<&str> = plan + .needs + .iter() + .filter(|n| n.collects.iter().filter(|f| !f.optional).count() == n.max_fields) + .map(|n| n.id.as_str()) + .collect(); + if tight.len() * 2 > plan.needs.len() && tight.len() > 1 { + eprintln!(" {} of {} need(s) have max_fields equal to their required fields ({}): check each against what the intake says about that person, not the form", tight.len(), plan.needs.len(), tight.join(", ")); + } + println!( + "plan written to {}; read it before building from it", + out.join("plan.yaml").display() + ); + return Ok(()); + } + Err(e) => { + eprintln!("plan round {round}: {e}"); + feedback = format!("\n## Your last attempt was refused\n\n{e}\n\nFix that and return the whole plan again.\n\n```yaml\n{yaml}\n```\n"); + } + } + } + bail!( + "no plan passed in {rounds} round(s); the replies are in {}/llm/", + out.display() + ) +} + +/// Personas whose `need:` is not one of the plan's ids get one mapped +/// by a model that sees the plan's needs and the persona texts and +/// returns ids only. Returns how many were mapped; an id the plan does +/// not have, or a persona the model leaves out, stays as it was. +fn map_personas(cmd: &str, plan: &Plan, personas: &mut serde_yaml::Value) -> anyhow::Result { + let ids: Vec<&str> = plan.needs.iter().map(|n| n.id.as_str()).collect(); + let Some(list) = personas.as_sequence_mut() else { return Ok(0) }; + let strangers: Vec<(String, String)> = list + .iter() + .filter(|p| !ids.contains(&p["need"].as_str().unwrap_or_default())) + .map(|p| (p["id"].as_str().unwrap_or_default().to_string(), p["text"].as_str().unwrap_or_default().to_string())) + .filter(|(id, _)| !id.is_empty()) + .collect(); + if strangers.is_empty() { + return Ok(0); + } + let needs: Vec = plan.needs.iter().map(|n| format!("- {}: {} - wants {}", n.id, n.who, n.wants)).collect(); + let people: Vec = strangers.iter().map(|(id, text)| format!("- {id}: {text}")).collect(); + let prompt = format!( + "A site has these needs, one per kind of visitor:\n{}\n\nThese people arrive at it:\n{}\n\nFor each person, which need is theirs? Reply with a JSON object mapping each person's id to one need id from the list, and nothing else. Leave a person out if no need fits.", + needs.join("\n"), + people.join("\n") + ); + let reply = crate::trainer::llm_with(cmd, &prompt, &[("FORMAT", "json"), ("TEMPERATURE", "0.0")])?; + let Some(mapping) = first_object(&reply) else { return Ok(0) }; + let mut mapped = 0; + for persona in list.iter_mut() { + let id = persona["id"].as_str().unwrap_or_default().to_string(); + if let Some(need) = mapping.get(&id).and_then(|v| v.as_str()) { + if ids.contains(&need) && !ids.contains(&persona["need"].as_str().unwrap_or_default()) { + persona["need"] = serde_yaml::Value::from(need); + mapped += 1; + } + } + } + Ok(mapped) +} + +/// Parsed and validated exactly as `build` will. +fn check(yaml: &str) -> anyhow::Result { + let plan: Plan = + serde_yaml::from_str(yaml).map_err(|e| anyhow!("does not parse as a plan: {e}"))?; + if !plan.personas.is_empty() { + bail!("contains personas; write none"); + } + if plan.needs.is_empty() { + bail!("has no needs"); + } + plan.validate()?; + Ok(plan) +} + +/// The YAML in a reply: inside code fences if the model used them, +/// otherwise from the first top-level key on. A stray JSON object +/// (some models answer `{"business": ...}`) is turned into YAML. +fn extract_yaml(reply: &str) -> String { + let text = reply.trim(); + if let Some(start) = text.find("```") { + let after = &text[start + 3..]; + let after = after.find('\n').map_or(after, |i| &after[i + 1..]); + let body = after.find("```").map_or(after, |i| &after[..i]); + return body.to_string(); + } + if text.starts_with('{') { + if let Some(v) = first_object(text) { + return serde_yaml::to_string(&v).unwrap_or_default(); + } + } + let start = text + .lines() + .position(|l| { + l.starts_with("business:") || l.starts_with("needs:") || l.starts_with("contact:") + }) + .unwrap_or(0); + text.lines().skip(start).collect::>().join("\n") +} + +#[cfg(test)] +mod tests { + use super::*; + + const GOOD: &str = "business: B\ncontact: { name: I, email: i@b.example }\nneeds:\n - id: enquiry\n who: an owner\n wants: a look\n lands_in: enquiries\n handled_by: project_managers\n stages: [open, quoted, won, lost]\n done_when: [won, lost]\n success: [won]\n max_fields: 3\n collects:\n - { name: name }\n - { name: phone, type: tel }\n"; + + #[test] + fn yaml_is_taken_from_fences_prose_or_json() { + assert_eq!( + extract_yaml(&format!("Here you go:\n```yaml\n{GOOD}```\nDone.")), + GOOD + ); + assert_eq!( + extract_yaml(&format!("Sure, the plan:\n\n{GOOD}")), + GOOD.trim_end() + ); + let from_json = extract_yaml("{\"business\": \"B\", \"needs\": []}"); + assert!(from_json.contains("business: B")); + } + + #[test] + fn a_plan_is_held_to_the_same_checks_as_build() { + assert!(check(GOOD).is_ok()); + assert!( + check("business: B\ncontact: { name: I, email: i@b.example }\nneeds: []\n") + .unwrap_err() + .to_string() + .contains("no needs") + ); + assert!(check(&GOOD.replace("max_fields: 3", "max_fields: 1")) + .unwrap_err() + .to_string() + .contains("tolerate 1")); + assert!(check(&format!( + "{GOOD}personas:\n - {{ id: p, need: enquiry, text: hi }}\n" + )) + .unwrap_err() + .to_string() + .contains("personas")); + assert!(check(&GOOD.replace( + "lands_in: enquiries", + "lands_in: henvendelser\n extra: 1" + )) + .unwrap_err() + .to_string() + .contains("does not parse")); + } +} diff --git a/src/trainer/rate.rs b/src/trainer/rate.rs new file mode 100644 index 0000000..9828a53 --- /dev/null +++ b/src/trainer/rate.rs @@ -0,0 +1,635 @@ +//! Understanding, readability, friendliness: three scores to iterate +//! wording against, each measured the way that keeps it from sticking +//! at its maximum. +//! +//! - **Readability** is counted, not judged: LIX, sentence length plus +//! the share of words over six letters, over the page's prose only - +//! the question, the descriptions and the followup - never headings +//! or field labels, which are not sentences and would drag the score +//! down to "very easy" whatever the prose says. Below 30 reads very +//! easily, 30-40 easily, 40-50 takes effort, above 50 is official +//! prose. +//! - **Understanding** is three yes-or-no questions put to each persona +//! about the page in front of them: which option is theirs, what the +//! form will ask, and what happens after sending. The last needs the +//! followup page, which `rate` reads from the content repo (`--repo`) +//! because the sim export stops at the form. `rate` also puts all +//! three to a deliberately gutted twin of the page (headings replaced +//! by "Option 1", descriptions, the form line and the followup +//! removed). An instrument that still says yes to the twin is not +//! measuring the page, and `rate` says so, per question. Every page +//! on a need's path is rated: behind a door, "which" is decided on +//! the door page and the form's page carries "asked" and "next". +//! - **Friendliness** is a preference between two versions, never an +//! absolute mark: `prefer` shows each persona both versions in both +//! orders; a pick that flips with the order counts as no preference. + +use std::collections::BTreeMap; +use std::path::Path; + +use anyhow::{anyhow, Context}; +use serde_json::{json, Value}; + +use crate::trainer::card; +use crate::trainer::simulate::ask; + +fn flag(argv: &[String], name: &str) -> Option { + argv.iter().position(|a| a == name).and_then(|i| argv.get(i + 1)).cloned() +} + +fn load(path: &str) -> anyhow::Result { + Ok(serde_json::from_str(&std::fs::read_to_string(path).with_context(|| format!("reading {path}"))?)?) +} + +/// LIX = words per sentence + 100 * long words / words. +pub fn lix(text: &str) -> f64 { + let words: Vec<&str> = text + .split(|c: char| !(c.is_alphanumeric() || c == '\'' || c == '-')) + .filter(|w| w.chars().any(char::is_alphabetic)) + .collect(); + if words.is_empty() { + return 0.0; + } + let sentences = text.split(['.', '!', '?', ':', '\n']).filter(|s| s.chars().any(char::is_alphabetic)).count().max(1); + let long = words.iter().filter(|w| w.chars().count() > 6).count(); + words.len() as f64 / sentences as f64 + 100.0 * long as f64 / words.len() as f64 +} + +pub fn lix_band(score: f64) -> &'static str { + match score { + s if s < 30.0 => "very easy", + s if s < 40.0 => "easy", + s if s < 50.0 => "takes effort", + _ => "official prose", + } +} + +/// The page as the visitor meets it: the question, every alternative, +/// the form behind the one that serves them, and the page they land on +/// after sending, when the repo is there to read it from. +fn page_text(step: &Value, page: Option<&Value>, followup: Option<&Value>, gutted: bool) -> String { + let mut text = match (gutted, page) { + (false, Some(p)) => card::page_head(p), + _ => format!("{}\n", step["question"].as_str().unwrap_or_default()), + }; + // The cards that lead nowhere (the opener, the look around) are + // read before the options; the gutted twin loses them too. + if let Some(context) = step["context"].as_str().filter(|c| !c.trim().is_empty() && !gutted) { + text.push_str(&format!("\n{context}\n")); + } + for (i, option) in step["options"].as_array().into_iter().flatten().enumerate() { + if gutted { + text.push_str(&format!("\n- Option {}\n", i + 1)); + continue; + } + // The whole card when the repo is there to read it from; + // heading and description otherwise. + let card = page + .and_then(|p| p["alternatives"].as_array()) + .and_then(|alts| alts.iter().find(|a| a["name"] == option["name"])) + .map(|a| card::alternative(a, true)); + match card { + Some(c) => text.push_str(&format!("\n{c}")), + None => text.push_str(&format!( + "\n- {}\n {}\n", + option["name"].as_str().unwrap_or_default(), + option["description"].as_str().unwrap_or_default() + )), + } + } + if page.is_some() && !gutted { + // The cards carry their own "Asks for" lines. + return finish_page_text(text, followup); + } + // The twin has no form line at all: "Field 1, Field 2" still + // tells a reader how many things they will be asked, and a model + // answers "can you tell what you will be asked" with yes to that + // (measured: 2 of 2 on every need). + let fields: Vec<&str> = step["fields"].as_array().into_iter().flatten().filter_map(|f| f["label"].as_str()).collect(); + if !gutted && !fields.is_empty() { + text.push_str(&format!( + "\nThe form under \"{}\" asks for: {}.\n", + step["alternative"].as_str().unwrap_or_default(), + fields.join(", ") + )); + } + if gutted { + return text; + } + finish_page_text(text, followup) +} + +fn finish_page_text(mut text: String, followup: Option<&Value>) -> String { + if let Some(next) = followup { + text.push_str(&format!("\nAfter you send it, the next page says:\n{}\n", next["name"].as_str().unwrap_or_default())); + if let Some(d) = next["description"].as_str().filter(|d| !d.trim().is_empty()) { + text.push_str(&format!("{}\n", d.trim())); + } + for alt in next["alternatives"].as_array().into_iter().flatten() { + let d = alt["description"].as_str().unwrap_or_default().trim(); + text.push_str(&format!("- {}{}{}\n", alt["name"].as_str().unwrap_or_default(), if d.is_empty() { "" } else { ": " }, d)); + } + } + text +} + +/// The running text a reader has to parse: the question, each +/// description, and the followup. Headings and labels are left out. +fn prose_text(step: &Value, followup: Option<&Value>) -> String { + let mut prose = vec![step["question"].as_str().unwrap_or_default().to_string()]; + prose.extend(step["options"].as_array().into_iter().flatten().filter_map(|o| o["description"].as_str()).map(str::to_string)); + if let Some(next) = followup { + prose.extend(next["description"].as_str().map(str::to_string)); + prose.extend(next["alternatives"].as_array().into_iter().flatten().filter_map(|a| a["description"].as_str()).map(str::to_string)); + } + prose.into_iter().filter(|p| !p.trim().is_empty()).collect::>().join("\n") +} + +/// The page a visitor lands on after the step's alternative: read from +/// the content repo, since the sim export stops at the form. `None` +/// when there is no repo, no `action`, or the page is not there. +fn followup_page(repo: Option<&Path>, step: &Value) -> Option { + let repo = repo?; + let page = load_page(repo, step["page"].as_str()?)?; + let alternative = step["alternative"].as_str()?; + let action = page["alternatives"].as_array()?.iter().find(|a| a["name"] == alternative)?["action"].as_str()?; + load_page(repo, action) +} + +/// Every desk's buttons per bucket, from the simulation model: +/// (page, group, bucket, [(from, to, label)]). A desk with one button +/// for a bucket has nothing to confuse and is left out. +fn desk_buttons(model: &Value) -> Vec<(String, String, String, Vec<(String, String, String)>)> { + let mut out = Vec::new(); + for (bucket, spec) in model["buckets"].as_object().into_iter().flatten() { + for desk in spec["desks"].as_array().into_iter().flatten() { + let moves: Vec<(String, String, String)> = desk["moves"] + .as_array() + .into_iter() + .flatten() + .map(|m| (s(&m["from"]), s(&m["to"]), s(&m["label"]))) + .filter(|(_, _, l)| !l.is_empty()) + .collect(); + if moves.len() >= 2 { + out.push((s(&desk["page"]), s(&desk["group"]), bucket.clone(), moves)); + } + } + } + out.sort(); + out +} + +fn s(v: &Value) -> String { + v.as_str().unwrap_or_default().to_string() +} + +/// A worker on the desk, told what just happened to a case, picks +/// the button for it. The outcome is put in plain words from the +/// state's own name, never from the label, and the buttons are listed +/// in a seeded shuffle. +fn button_pick(cmd: &str, group: &str, bucket: &str, from: &str, to: &str, labels: &[&str], seed: usize, words: &Value) -> anyhow::Result { + // The state in the plan's own words when the spec carries them, + // else the id made readable. + let plain = |id: &str| words[id].as_str().map(str::to_string).unwrap_or_else(|| id.replace(['_', '-'], " ")); + let mut order: Vec<&str> = labels.to_vec(); + // A fixed rotation per move: the right answer is not always first. + let by = seed % order.len().max(1); + order.rotate_left(by); + let listed: Vec = order.iter().enumerate().map(|(i, l)| format!("{}. {l}", i + 1)).collect(); + let prompt = format!( + "You work on the {} desk of a small organisation. A case in \"{}\" was \"{}\" and has just become \"{}\". The desk shows these buttons for it:\n\n{}\n\nWhich button records what just happened? Reply as JSON: {{\"choice\": }}", + plain(group), + plain(bucket), + plain(from), + plain(to), + listed.join("\n") + ); + let reply = ask(cmd, &prompt, &seed.to_string())?; + let n = reply["choice"].as_u64().unwrap_or(0) as usize; + Ok(order.get(n.wrapping_sub(1)).map(|l| l.to_string()).unwrap_or_default()) +} + +/// Every `mail:` on a state in the repo's aggregates.yaml, as +/// (`bucket:state`, subject, body) with the `{placeholders}` taken +/// out, so they read as the person will read them. +pub(crate) fn mail_texts(repo: &Path) -> Option> { + let raw = std::fs::read_to_string(repo.join("aggregates.yaml")).ok()?; + let doc: Value = serde_yaml::from_str(&raw).ok()?; + let strip = |s: &str| { + let mut out = String::new(); + let mut skip = false; + for c in s.chars() { + match c { + '{' => skip = true, + '}' => skip = false, + _ if !skip => out.push(c), + _ => {} + } + } + out.split_whitespace().collect::>().join(" ") + }; + Some(raw_mails(repo, &doc).into_iter().map(|(name, subject, body)| (name, strip(&subject), strip(&body))).collect()) +} + +/// Every mail the site sends, as written: the state mails in +/// aggregates.yaml, then the invite mail in site.yaml - onboarding is +/// a mail too, and the first one a new member reads. +fn raw_mails(repo: &Path, aggregates: &Value) -> Vec<(String, String, String)> { + let mut out = Vec::new(); + for aggregate in aggregates["aggregates"].as_array().into_iter().flatten() { + let bucket = aggregate["bucket"].as_str().unwrap_or_default(); + for (state, spec) in aggregate["states"].as_object().into_iter().flatten() { + if let Some(mail) = spec.get("mail") { + out.push(( + format!("{bucket}:{state}"), + mail["subject"].as_str().unwrap_or_default().to_string(), + mail["body"].as_str().unwrap_or_default().to_string(), + )); + } + } + } + let site: Value = std::fs::read_to_string(repo.join("site.yaml")).ok().and_then(|t| serde_yaml::from_str(&t).ok()).unwrap_or(Value::Null); + let invite = &site["mail"]["invite"]; + if invite.is_object() { + out.push(("invite".to_string(), invite["subject"].as_str().unwrap_or_default().to_string(), invite["body"].as_str().unwrap_or_default().to_string())); + } + out +} + +/// A mail as it arrives: the placeholders filled with plausible values, +/// so a reader meets a link where the link will be, not a blank. +fn as_received(template: &str, site_name: &str) -> String { + let mut out = String::new(); + let mut rest = template; + while let Some(i) = rest.find('{') { + out.push_str(&rest[..i]); + let after = &rest[i + 1..]; + match after.find('}') { + Some(j) if after[..j].chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.') && j > 0 => { + out.push_str(&match &after[..j] { + "site" => "https://example.site".to_string(), + "chain" => "8f3a1c".to_string(), + "link" => "https://id.example.site/reset/8f3a1c".to_string(), + "site_name" => site_name.to_string(), + "expires" => "2026-10-05 12:00 UTC".to_string(), + key => format!("[{}]", key.replace('_', " ")), + }); + rest = &after[j + 1..]; + } + _ => { + out.push('{'); + rest = after; + } + } + } + out.push_str(rest); + out +} + +const MAIL_QUESTIONS: [(&str, &str); 3] = [ + ("happened", "Do you know, from this mail alone, what has happened and to what?"), + ("now", "Do you know what, if anything, you can or must do now, and how?"), + ("who", "Do you know who sent it, and how you would answer them?"), +]; + +/// Three yes-or-no questions on one mail, from the person it is for. +fn mail_understanding(cmd: &str, reader: &str, subject: &str, body: &str, seed: usize) -> anyhow::Result<[bool; 3]> { + let prompt = format!( + "You are {reader}. This mail has just arrived:\n---\nSubject: {subject}\n\n{body}\n---\n\nAnswer three questions as yourself. Say yes only when the mail itself tells you; if you would be guessing, say no.\n{}\nReply as JSON: {{\"happened\": true or false, \"now\": true or false, \"who\": true or false}}", + MAIL_QUESTIONS.iter().map(|(k, q)| format!("- {k}: {q}")).collect::>().join("\n") + ); + let reply = ask(cmd, &prompt, &seed.to_string())?; + Ok([reply["happened"] == true, reply["now"] == true, reply["who"] == true]) +} + +/// `/` is `questions/index.yaml`; `/a/b` is `questions/a/b.yaml` or +/// `questions/a/b/index.yaml`. +pub(crate) fn load_page(repo: &Path, route: &str) -> Option { + let rel = route.trim_matches('/'); + let candidates = if rel.is_empty() { + vec![repo.join("questions/index.yaml")] + } else { + vec![repo.join(format!("questions/{rel}.yaml")), repo.join(format!("questions/{rel}/index.yaml"))] + }; + let text = candidates.iter().find_map(|p| std::fs::read_to_string(p).ok())?; + serde_yaml::from_str(&text).ok() +} + +/// What one page may ask of one person before it should be split into +/// a further question, or the fields moved to a later stage of the +/// case where they are actually needed. Complexity belongs in the +/// number of steps and states, not in the weight of any one page. +const MAX_OPTIONS: usize = 5; +const MAX_REQUIRED: usize = 5; + +/// (alternatives offered, required fields in the form that serves them) +fn page_load(step: &Value) -> (usize, usize) { + ( + step["options"].as_array().map_or(0, Vec::len), + step["fields"].as_array().into_iter().flatten().filter(|f| f["optional"] != true).count(), + ) +} + +const QUESTIONS: [(&str, &str); 4] = [ + ("which", "Is it clear from the page which option is meant for someone in your situation?"), + ("where", "Do you know where you are on this site, who this option is for, and what pressing its button will do?"), + ("asked", "Can you tell from the page what you will be asked to provide?"), + ("next", "Does the page tell you what happens after you send it, or who will read it?"), +]; +const N: usize = QUESTIONS.len(); + +fn understanding(cmd: &str, who: &str, page: &str, seed: usize) -> anyhow::Result<[bool; N]> { + let prompt = format!( + "You are this person: {who}\n\nThis is the whole of a web page you have just opened:\n---\n{page}---\n\nAnswer four questions about it as yourself. Say yes only when the page text itself gives you that; if you would be guessing, say no.\n{}\nReply as JSON: {{\"which\": true or false, \"where\": true or false, \"asked\": true or false, \"next\": true or false}}", + QUESTIONS.iter().map(|(k, q)| format!("- {k}: {q}")).collect::>().join("\n") + ); + let reply = ask(cmd, &prompt, &seed.to_string())?; + Ok([reply["which"] == true, reply["where"] == true, reply["asked"] == true, reply["next"] == true]) +} + +pub fn rate(argv: &[String]) -> anyhow::Result<()> { + let usage = "usage: portal-trainer rate --model --out [--repo ] [--llm 'cmd']"; + let model = load(&flag(argv, "--model").ok_or_else(|| anyhow!(usage))?)?; + let out = std::path::PathBuf::from(flag(argv, "--out").ok_or_else(|| anyhow!(usage))?); + let repo = flag(argv, "--repo").map(std::path::PathBuf::from); + let cmd = flag(argv, "--llm").unwrap_or_else(|| "claude -p".to_string()); + std::fs::create_dir_all(&out)?; + if repo.is_none() { + eprintln!("rate: no --repo, so no followup page is shown and \"next\" is not measured"); + } + + let mut report = BTreeMap::new(); + // The desks: the inside of the business reads button labels and + // nothing else, so each desk is scored on whether a worker told + // what has just happened to a case picks the right button among + // that desk's buttons for the bucket. + let desks = desk_buttons(&model); + if !desks.is_empty() { + println!("{:<20} {:<24} {:>8} {}", "desk", "bucket", "buttons", "mistaken for"); + for (page, group, bucket, moves) in &desks { + // A desk row shows only the buttons that start from the + // row's state, so those are the ones the worker chooses + // among; a state with one button has nothing to confuse. + let mut right = 0; + let mut asked = 0; + let mut mistaken = Vec::new(); + for (i, (from, to, label)) in moves.iter().enumerate() { + let labels: Vec<&str> = moves.iter().filter(|(f, _, _)| f == from).map(|(_, _, l)| l.as_str()).collect(); + if labels.len() < 2 { + continue; + } + asked += 1; + let picked = button_pick(&cmd, group, bucket, from, to, &labels, i, &model["buckets"][bucket]["words"])?; + if picked == *label { + right += 1; + } else { + mistaken.push(format!("{from}>{to} as {picked:?}")); + } + } + if asked == 0 { + continue; + } + println!("{:<20} {:<24} {:>3} of {:<2} {}", page, bucket, right, asked, mistaken.join("; ")); + report.insert(format!("desk {page} {bucket}"), json!({ "right": right, "of": asked, "mistaken": mistaken })); + } + println!(); + } + // The mails first, when the repo carries any: a person reads a + // mail alone, with no card around it, so its prose is rated on + // its own - LIX and length. + // Then the three questions a person asks of any mail - what + // happened, what now, who is this - each also put to a gutted twin + // that says only that there is news. A yes the twin also gets is + // the reader agreeing with anything. + if let Some(mails) = repo.as_deref().and_then(mail_texts) { + if !mails.is_empty() { + let dir = repo.as_deref().unwrap_or(Path::new(".")); + let site: Value = std::fs::read_to_string(dir.join("site.yaml")).ok().and_then(|t| serde_yaml::from_str(&t).ok()).unwrap_or(Value::Null); + let site_name = site["title"].as_str().unwrap_or("the site").to_string(); + let aggregates: Value = std::fs::read_to_string(dir.join("aggregates.yaml")).ok().and_then(|t| serde_yaml::from_str(&t).ok()).unwrap_or(Value::Null); + let raw = raw_mails(dir, &aggregates); + println!("{:<34} {:>5} {:<14} {:>5} {:>8} {:>5} {:>5} {:<16}", "mail", "LIX", "", "words", "happened", "now", "who", "gutted twin says"); + for (i, ((name, subject, body), (_, raw_subject, raw_body))) in mails.iter().zip(&raw).enumerate() { + let text = format!("{subject}. {body}"); + let score = lix(&text); + let reader = if name == "invite" { + format!("someone a member of {site_name} has just brought in to help run it; you did not ask for this mail") + } else { + format!("someone who sent a form to {site_name} some time ago") + }; + let (subject_in, body_in) = (as_received(raw_subject, &site_name), as_received(raw_body, &site_name)); + let got = mail_understanding(&cmd, &reader, &subject_in, &body_in, i)?; + let twin = mail_understanding(&cmd, &reader, "News", "There is news.", i)?; + let yn = |b: bool| if b { "yes" } else { "no" }; + let failed: Vec<&str> = MAIL_QUESTIONS.iter().zip(twin).filter(|(_, t)| *t).map(|((k, _), _)| *k).collect(); + println!( + "{:<34} {:>5.0} {:<14} {:>5} {:>8} {:>5} {:>5} {:<16}", + name, score, lix_band(score), text.split_whitespace().count(), yn(got[0]), yn(got[1]), yn(got[2]), + if failed.is_empty() { "ok".to_string() } else { format!("FAILED: {}", failed.join(", ")) } + ); + report.insert(format!("mail {name}"), json!({ + "lix": score, "words": text.split_whitespace().count(), + "understanding": { "happened": got[0], "now": got[1], "who": got[2] }, + "control_ok": failed.is_empty(), + })); + } + println!(); + } + } + println!("{:<28} {:>6} {:<14} {:>8} {:>8} {:>8} {:>8} {:<22} {:<22}", "need", "LIX", "", "which", "where", "asked", "next", "gutted twin says", "page load"); + for need in model["needs"].as_array().into_iter().flatten() { + let id = need["id"].as_str().unwrap_or_default(); + let steps = need["steps"].as_array().cloned().unwrap_or_default(); + let personas: Vec<&Value> = model["personas"].as_array().into_iter().flatten().filter(|p| p["need"] == id).collect(); + if personas.is_empty() { + continue; + } + // Every page on the path is rated. The followup follows only + // the last page's alternative: on a door page, what the + // alternative leads to is the next page, not what happens + // after sending. + let mut pages = Vec::new(); + for (i, step) in steps.iter().enumerate() { + let followup = if i + 1 == steps.len() { followup_page(repo.as_deref(), step) } else { None }; + let page = repo.as_deref().and_then(|r| load_page(r, step["page"].as_str().unwrap_or("/"))); + let row = if steps.len() > 1 { format!("{id} @{}", step["page"].as_str().unwrap_or_default()) } else { id.to_string() }; + let readability = lix(&prose_text(step, followup.as_ref())); + let (mut yes, mut control) = ([0usize; N], [0usize; N]); + for (i, persona) in personas.iter().enumerate() { + let who = persona["text"].as_str().unwrap_or_default(); + for (tally, gutted) in [(&mut yes, false), (&mut control, true)] { + let answers = understanding(&cmd, who, &page_text(step, page.as_ref(), followup.as_ref(), gutted), i)?; + for (slot, answer) in tally.iter_mut().zip(answers) { + *slot += answer as usize; + } + } + } + let n = personas.len(); + let measured = |k: usize| k != 3 || followup.is_some(); + let share = |k: usize| if measured(k) { format!("{}/{n}", yes[k]) } else { "-".to_string() }; + // The twin has no headings, no descriptions, no cards and no + // followup: every question must collapse on it, or that + // question is agreeing with anything. + let control_ok: Vec = (0..N).map(|k| !measured(k) || control[k] * 2 < n.max(1)).collect(); + let failed: Vec<&str> = QUESTIONS.iter().zip(&control_ok).filter(|(_, ok)| !**ok).map(|((k, _), _)| *k).collect(); + let (options, required) = page_load(step); + println!( + "{:<28} {:>6.0} {:<14} {:>8} {:>8} {:>8} {:>8} {:<22} {:<22}", + row, + readability, + lix_band(readability), + share(0), + share(1), + share(2), + share(3), + format!( + "{}/{}/{}/{} {}", + control[0], + control[1], + control[2], + if measured(3) { control[3].to_string() } else { "-".to_string() }, + if failed.is_empty() { "ok".to_string() } else { format!("FAILED: {}", failed.join(", ")) } + ), + format!( + "{options} options, {required} required{}", + if options > MAX_OPTIONS || required > MAX_REQUIRED { " - SPLIT" } else { "" } + ), + ); + pages.push(json!({ + "page": step["page"], "lix": readability, "personas": n, + "options": options, "required_fields": required, + "overloaded": options > MAX_OPTIONS || required > MAX_REQUIRED, + "followup_shown": followup.is_some(), + "understanding": {"which": yes[0], "where": yes[1], "asked": yes[2], "next": if measured(3) { json!(yes[3]) } else { Value::Null }}, + "control": {"which": control[0], "where": control[1], "asked": control[2], "next": if measured(3) { json!(control[3]) } else { Value::Null }}, + "control_ok": failed.is_empty(), + })); + } + report.insert(id.to_string(), json!({ "steps": pages.len(), "pages": pages })); + } + std::fs::write(out.join("rate.json"), serde_json::to_string_pretty(&report)?)?; + Ok(()) +} + +pub fn prefer(argv: &[String]) -> anyhow::Result<()> { + let usage = "usage: portal-trainer prefer --a --b [--llm 'cmd']"; + let a = load(&flag(argv, "--a").ok_or_else(|| anyhow!(usage))?)?; + let b = load(&flag(argv, "--b").ok_or_else(|| anyhow!(usage))?)?; + let cmd = flag(argv, "--llm").unwrap_or_else(|| "claude -p".to_string()); + + println!("{:<28} {:>5} {:>5} {:>14} {:>5} {:>5} {:>14} {:>9}", "need", "A", "B", "no preference", "A", "B", "no preference", "LIX A->B"); + println!("{:<28} {:^26} {:^26}", "", "friendlier", "clearer"); + for need_a in a["needs"].as_array().into_iter().flatten() { + let id = need_a["id"].as_str().unwrap_or_default(); + let Some(need_b) = b["needs"].as_array().into_iter().flatten().find(|n| n["id"] == id) else { continue }; + let (Some(step_a), Some(step_b)) = (need_a["steps"].as_array().and_then(|s| s.first()), need_b["steps"].as_array().and_then(|s| s.first())) else { continue }; + let (text_a, text_b) = (page_text(step_a, None, None, false), page_text(step_b, None, None, false)); + if text_a == text_b { + continue; + } + // [friendlier, clearer] x [A, B, none] + let mut wins = [[0usize; 3]; 2]; + for (i, persona) in a["personas"].as_array().into_iter().flatten().filter(|p| p["need"] == id).enumerate() { + let who = persona["text"].as_str().unwrap_or_default(); + let mut picks: [[&str; 2]; 2] = [["", ""], ["", ""]]; + for (order, (first, second)) in [(&text_a, &text_b), (&text_b, &text_a)].into_iter().enumerate() { + let prompt = format!( + "You are this person: {who}\n\nTwo versions of the same web page. They lead to the same thing; only the wording differs.\n\n=== VERSION 1 ===\n{first}\n=== VERSION 2 ===\n{second}\n\nAs yourself: which version makes you feel more welcome and at ease, and which one makes it clearer what to do?\nReply as JSON: {{\"friendlier\": 1 or 2, \"clearer\": 1 or 2}}" + ); + let reply = ask(&cmd, &prompt, &(i * 2 + order).to_string())?; + for (k, key) in ["friendlier", "clearer"].iter().enumerate() { + let first_won = reply[key] == 1; + // In the second order, version 1 is B. + picks[k][order] = if first_won == (order == 0) { "A" } else { "B" }; + } + } + for k in 0..2 { + let slot = match picks[k] { + ["A", "A"] => 0, + ["B", "B"] => 1, + _ => 2, + }; + wins[k][slot] += 1; + } + } + println!( + "{:<28} {:>5} {:>5} {:>14} {:>5} {:>5} {:>14} {:>9}", + id, wins[0][0], wins[0][1], wins[0][2], wins[1][0], wins[1][1], wins[1][2], + format!("{:.0}->{:.0}", lix(&text_a), lix(&text_b)) + ); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn lix_separates_plain_words_from_official_prose() { + let plain = "Get someone to look at a building. We come and see it. Then we tell you what it takes."; + let official = "Submission of documentation regarding rehabilitation requirements necessitates comprehensive structural evaluation procedures."; + assert!(lix(plain) < 30.0, "{}", lix(plain)); + assert!(lix(official) > 60.0, "{}", lix(official)); + assert_eq!(lix_band(lix(plain)), "very easy"); + assert_eq!(lix(""), 0.0); + } + + #[test] + fn the_gutted_twin_keeps_the_question_and_loses_the_wording() { + let step = json!({ + "question": "What brings you here?", + "options": [{"name": "Get a price", "description": "For owners."}, {"name": "Work with us", "description": "For crews."}], + "alternative": "Get a price", + "fields": [{"label": "Email"}, {"label": "The build"}] + }); + let next = json!({"name": "What happens now?", "description": "A project manager reads it.", "alternatives": [{"name": "We call you within two days", "description": ""}]}); + let page = page_text(&step, None, Some(&next), false); + assert!(page.contains("Get a price") && page.contains("asks for: Email, The build")); + assert!(page.contains("After you send it") && page.contains("A project manager reads it.")); + // With the repo's page in hand, each option is its whole card. + let repo_page = json!({"name": "What brings you here?", "description": "", "alternatives": [ + {"name": "Get a price", "description": "For owners.", "consequence": ["Send it"], "encouragements": ["We answer within two days."], "features": [{"name": "Your brief", "requirements": [{"name": "email", "label": "Email"}]}]}, + {"name": "Work with us", "description": "For crews."}]}); + let rich = page_text(&step, Some(&repo_page), Some(&next), false); + assert!(rich.contains("Button: Send it") && rich.contains("We answer within two days.") && rich.contains("Asks for: Email")); + assert!(!rich.contains("asks for: Email, The build")); + let twin = page_text(&step, Some(&repo_page), Some(&next), true); + assert!(!twin.contains("Send it") && !twin.contains("Your brief")); + let twin = page_text(&step, None, Some(&next), true); + assert!(twin.contains("What brings you here?") && twin.contains("Option 2")); + assert!(!twin.contains("asks for")); + assert!(!twin.contains("Get a price") && !twin.contains("Email") && !twin.contains("project manager")); + // Readability is measured on the sentences, not the headings. + let prose = prose_text(&step, Some(&next)); + assert_eq!(prose, "What brings you here?\nFor owners.\nFor crews.\nA project manager reads it."); + } + + #[test] + fn a_mail_is_read_as_it_arrives_and_the_invite_is_one_of_them() { + let got = as_received("Hei {name}, se {site}/decide/x?chain={chain}. {link} {ikke en nøkkel}", "Tomter Vel"); + assert_eq!(got, "Hei [name], se https://example.site/decide/x?chain=8f3a1c. https://id.example.site/reset/8f3a1c {ikke en nøkkel}"); + let dir = std::env::temp_dir().join(format!("portal-trainer-mails-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("aggregates.yaml"), "aggregates:\n - bucket: b\n initial: open\n states:\n open: { event: received, mail: { to: submitter, subject: Fått, body: \"Vi har det, {name}.\" } }\n").unwrap(); + std::fs::write(dir.join("site.yaml"), "title: T\nmail: { from: x@y.no, invite: { subject: Konto, body: \"Hei {name}\\n{link}\" } }\n").unwrap(); + let mails = mail_texts(&dir).unwrap(); + assert_eq!(mails.iter().map(|m| m.0.as_str()).collect::>(), ["b:open", "invite"]); + assert_eq!(mails[0].2, "Vi har det, ."); + std::fs::remove_dir_all(dir).ok(); + } + + #[test] + fn the_followup_page_is_read_from_the_repo_by_following_the_action() { + let dir = std::env::temp_dir().join(format!("portal-trainer-rate-{}", std::process::id())); + std::fs::create_dir_all(dir.join("questions/received")).unwrap(); + std::fs::write(dir.join("questions/index.yaml"), "name: What?\nalternatives:\n - name: Get a price\n action: /received/price\n").unwrap(); + std::fs::write(dir.join("questions/received/price.yaml"), "name: What happens now?\ndescription: We read it.\nalternatives:\n - name: Back\n").unwrap(); + let step = json!({"page": "/", "alternative": "Get a price"}); + let next = followup_page(Some(&dir), &step).unwrap(); + assert_eq!(next["name"], "What happens now?"); + assert!(followup_page(None, &step).is_none()); + assert!(followup_page(Some(&dir), &json!({"page": "/", "alternative": "Nope"})).is_none()); + std::fs::remove_dir_all(dir).ok(); + } +} diff --git a/src/trainer/reference.md b/src/trainer/reference.md new file mode 100644 index 0000000..50f5f8d --- /dev/null +++ b/src/trainer/reference.md @@ -0,0 +1,196 @@ +# Portal content reference (for the generator) + +Portal serves a site that is one question per page. Each page offers a +few alternatives. An alternative may carry a form; submitting it +records the answer into a *bucket* whose records move through a state +machine, worked from a *desk*: a page gated to a group, listing the +bucket with one button per allowed move. There is no other mechanism. +Everything a business needs is expressed as: who answers which +question, where the answer lands, who moves it, through which states. + +A content repo is these files and nothing else: + +``` +needs.yaml what the business needs (the spec you are held to) +aggregates.yaml one state machine per bucket +site.yaml title and language +questions/index.yaml the front door, "/" +questions/.yaml "/" +questions//index.yaml "/" +questions//_section.yaml applies `qualifies` to the whole directory +questions//.yaml "//" - a followup page by default +``` + +## needs.yaml + +```yaml +needs: + - id: kebab-case + who: the kind of person, in plain words + wants: what they want from the business + enters: / # page they arrive on; default / + enters_as: # only when they are logged-in members of a group; omit for the public + via: # only when several alternatives on the entry page record into the same bucket + lands_in: + handled_by: # the role inside the business that must see and move the record + done_when: [, ] # every state that counts as finished, good or bad + max_steps: 1 # submissions before the record exists (1 or 2) + max_fields: 4 # required fields the visitor will tolerate across the path + success: [] # the done_when states that are a good outcome for the business + stages: [open, , , , ] + # EVERY stage the business said a case goes through, first to last, + # read off their own sentences one verb at a time ("they come in, we + # go and look, we send a quote, we win or lose it" is five stages). + # The state machine must have a state for each, or the verifier fails. +personas: + - id: kebab-case + need: + text: > + Two or three sentences in the visitor's own voice and vocabulary. +``` + +Rules: +- One need per (kind of person, thing they want). Internal work counts: + a role inside the business recording or posting something is a need + with `enters_as`. +- States come from the business's own description of how a thing + progresses. A pipeline is not an inbox: "answered" is rarely the real + outcome. +- `max_fields` is small on purpose. Reporting something from a phone + gets 3. A first enquiry gets 4 to 6. +- Two or three personas per public need. At least one must avoid the + trade's own vocabulary. A persona must never quote a page heading. + +## aggregates.yaml + +```yaml +aggregates: + - bucket: leads + initial: open + states: # state: { event: past-tense verb } + open: { event: received } + quoted: { event: quoted } + won: { event: won } + lost: { event: lost } + transitions: # only moves the business described + open: [quoted, lost] + quoted: [won, lost] +``` + +The initial state should be `open` (a desk transition with no `from` +means from `open`). + +## A page + +```yaml +name: What brings you here? # always a question the visitor could ask themselves +description: > + One or two sentences. +responsible: { name: , contact: } # required on every entry page +alternatives: + - name: # what the visitor picks. Must work WITHOUT the description. + description: > + One or two sentences. + action: /received # absolute path of an existing page + consequence: [Send] # the button label + record_as: leads # omit for a pure navigation alternative + features: + - name: About the building + description: Optional line of guidance. + requirements: + - { name: email, label: Email, type: email } + - { name: problem, label: What is wrong, type: textarea } + - { name: urgency, label: How soon, type: select, options: [This week, This month, No rush] } + - { name: address, label: Address, optional: true } +``` + +- Field types: `text` (default), `email`, `tel`, `textarea`, `date`, + `select` (must list `options`). Use no other types. +- Every `action` must name a page that exists. After a recording + alternative, send the visitor to a followup page that says who reads + it, when they answer, and what happens next. A followup page is a + top-level file with `followup: true`, or any non-index file in a + subdirectory. Give it one alternative with `action: /` to go back. +- A page for members of a group sets `qualifies: ` (or sits in a + directory whose `_section.yaml` does). Only `enters_as: ` + needs can use it. +- Every bucket that is recorded into must be read by a desk. + +## A desk + +```yaml +# questions/desk/_section.yaml +qualifies: project_managers +``` +```yaml +# questions/desk/index.yaml +name: What came in? +responsible: { name: ..., contact: ... } +alternatives: + - name: Leads + description: One line on what to do with them. + action: /desk + consequence: [Confirm] + features: + - name: "" + description: "" + resource: + source: { kind: kv, bucket: leads } + requires_group: project_managers + transitions: + - { to: quoted, label: Quote sent } + - { to: lost, label: Not for us } + - { from: quoted, to: won, label: Won } + - { from: quoted, to: lost, label: Lost } +``` + +- One desk directory per handling group; one alternative per bucket + that group handles. +- Offer every move the state machine allows and no other. A state with + no move out of it, on the way to a finished state, strands records. +- A group may read a bucket it does not handle: the same `resource` + block with its own `requires_group` and no `transitions`. + +## site.yaml + +```yaml +title: +lang: en +``` + +## Language + +Everything a person reads is written in the intake's `language`: page +names, headings, descriptions, field labels, button labels, the +`who`/`wants` of each need, and the personas. Write it the way people +in that trade and country actually speak, not as a translation. +Everything a machine reads stays plain ASCII snake_case or kebab-case: +need ids, persona ids, bucket names, state names, field `name`s, file +names and paths. Letters such as æ, ø and å belong in the copy and +never in an identifier. Set `lang` in site.yaml to the intake's code. + +## Copy rules + +- Headings say what the visitor gets, in the visitor's words. +- Lead with what a thing enables. Never define something by what it + is not. No negations in headings. +- Two alternatives must never be separable only by a distinction the + business makes and the visitor does not. +- Every required field is one the business will use to answer. + +## The verifier + +`question_lint` runs after every attempt. Non-zero exit means you are +not done. Its messages are exact: + +- `no path from ... records into ...`: no chain of alternatives from + the entry page reaches an alternative with that `record_as`. +- `the path asks for N required field(s), over the budget`: mark fields + `optional: true` or drop them. +- `no desk gated on "g" reads "b"`: add the desk. +- `"g" cannot move a record to "s"` / `records strand in "s"`: the desk + is missing a transition the state machine allows, or the state + machine is missing the edge. +- `action ... does not match any declared question id`: the page does + not exist. +- `entry page ... names no one responsible`: add `responsible`. diff --git a/src/trainer/report.rs b/src/trainer/report.rs new file mode 100644 index 0000000..e096589 --- /dev/null +++ b/src/trainer/report.rs @@ -0,0 +1,127 @@ +//! What a live site's storylines did, read back as counts. +//! +//! `portal-trainer report --site https://vel.example [--needs needs.yaml]` +//! fetches the site's `/report` (portal docs, Storylines): per bucket, +//! per storyline, per state, a count and nothing else. With the site's +//! `needs.yaml` the states that end well are known, and each storyline +//! is scored on how many cases it brought in and how many of those +//! ended well. Storylines are assigned evenly, so the counts compare +//! directly. This is the live half of what the scorer measures offline +//! with personas: the same tones, judged by what people did. +use std::collections::BTreeMap; + +use anyhow::{anyhow, bail, Context}; +use serde::Deserialize; + +#[derive(Debug, Deserialize)] +struct Report { + storylines: usize, + /// bucket -> storyline -> state -> count + buckets: BTreeMap>>, +} + +#[derive(Debug, Default, Deserialize)] +struct Needs { + #[serde(default)] + needs: Vec, + #[serde(default)] + tones: Vec, +} + +#[derive(Debug, Deserialize)] +struct Need { + lands_in: String, + #[serde(default)] + done_when: Vec, + #[serde(default)] + success: Vec, +} + +/// One storyline's showing in one bucket. +#[derive(Debug, Default, PartialEq)] +struct Showing { + arrived: u64, + ended_well: u64, +} + +/// Per bucket, per storyline index, how many arrived and how many +/// ended in a state the need calls success (or, without `success`, +/// any finished state). +fn showings(counts: &BTreeMap>>, needs: &[Need]) -> BTreeMap> { + let mut out = BTreeMap::new(); + for (bucket, per_story) in counts { + let well: Vec<&str> = needs + .iter() + .filter(|n| &n.lands_in == bucket) + .flat_map(|n| if n.success.is_empty() { n.done_when.iter() } else { n.success.iter() }) + .map(String::as_str) + .collect(); + let arity = per_story.keys().filter_map(|k| k.parse::().ok()).max().map(|m| m + 1).unwrap_or(1); + let mut rows: Vec = (0..arity).map(|_| Showing::default()).collect(); + for (story, states) in per_story { + let Ok(n) = story.parse::() else { continue }; + for (state, count) in states { + rows[n].arrived += count; + if well.contains(&state.as_str()) { + rows[n].ended_well += count; + } + } + } + out.insert(bucket.clone(), rows); + } + out +} + +pub fn run(argv: &[String]) -> anyhow::Result<()> { + let value = |flag: &str| argv.iter().position(|a| a == flag).and_then(|i| argv.get(i + 1)).cloned(); + let site = value("--site").ok_or_else(|| anyhow!("usage: portal-trainer report --site [--needs ]"))?; + let url = format!("{}/report", site.trim_end_matches('/')); + let fetched = std::process::Command::new("curl").args(["-sSf", "--max-time", "30", &url]).output().context("running curl")?; + if !fetched.status.success() { + bail!("could not read {url}: {}", String::from_utf8_lossy(&fetched.stderr).trim()); + } + let report: Report = serde_json::from_slice(&fetched.stdout).with_context(|| format!("reading {url}"))?; + let needs: Needs = match value("--needs") { + Some(path) => serde_yaml::from_str(&std::fs::read_to_string(&path).with_context(|| format!("reading {path}"))?)?, + None => Needs::default(), + }; + println!("{site}: {} storyline(s)", report.storylines); + for (i, tone) in needs.tones.iter().enumerate() { + println!(" [{i}] {tone}"); + } + if report.buckets.is_empty() { + println!("nothing sent in yet"); + return Ok(()); + } + let rows = showings(&report.buckets, &needs.needs); + for (bucket, per_story) in &rows { + println!("\n{bucket}"); + for (n, showing) in per_story.iter().enumerate() { + let rate = if showing.arrived > 0 { format!("{:.0}%", 100.0 * showing.ended_well as f64 / showing.arrived as f64) } else { "-".to_string() }; + println!(" storyline {n}: {} arrived, {} ended well ({rate})", showing.arrived, showing.ended_well); + } + if per_story.len() > 1 { + let best = per_story.iter().enumerate().max_by_key(|(_, s)| (s.ended_well, s.arrived)).map(|(n, _)| n).unwrap_or(0); + println!(" most so far: storyline {best}"); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_storyline_is_scored_on_what_arrived_and_what_ended_well() { + let counts: BTreeMap>> = + serde_json::from_str(r#"{"hazards": {"0": {"open": 3, "fixed": 4}, "1": {"open": 1, "fixed": 9, "dropped": 2}}}"#).unwrap(); + let needs = vec![Need { lands_in: "hazards".into(), done_when: vec!["fixed".into(), "dropped".into()], success: vec!["fixed".into()] }]; + let rows = showings(&counts, &needs); + assert_eq!(rows["hazards"][0], Showing { arrived: 7, ended_well: 4 }); + assert_eq!(rows["hazards"][1], Showing { arrived: 12, ended_well: 9 }); + // Without a success list, any finished state counts. + let any = vec![Need { lands_in: "hazards".into(), done_when: vec!["fixed".into(), "dropped".into()], success: vec![] }]; + assert_eq!(showings(&counts, &any)["hazards"][1].ended_well, 11); + } +} diff --git a/src/trainer/score.rs b/src/trainer/score.rs new file mode 100644 index 0000000..ce396ca --- /dev/null +++ b/src/trainer/score.rs @@ -0,0 +1,100 @@ +//! `score`: one command for the whole grading of a built site, which +//! every run under `runs/` used to carry as its own `score.sh`. The +//! lint (iris) writes the visitor tasks and the simulation model, the +//! blind visitor answers them with and without descriptions, the lint +//! scores the answers, and `rate` reads the cards. What comes out is +//! the two routing numbers, the misroutes, the rating table, and a +//! `summary.json` a run's README can quote. + +use anyhow::{anyhow, bail, Context}; +use std::path::{Path, PathBuf}; +use std::process::Command; + +pub fn run(argv: &[String]) -> anyhow::Result<()> { + let flag = |name: &str| argv.iter().position(|a| a == name).and_then(|i| argv.get(i + 1)).cloned(); + let usage = "usage: portal-trainer score --site --out [--iris ] [--llm 'cmd']"; + let site = PathBuf::from(flag("--site").ok_or_else(|| anyhow!(usage))?); + let out = PathBuf::from(flag("--out").ok_or_else(|| anyhow!(usage))?); + let iris = flag("--iris").unwrap_or_else(|| "iris".to_string()); + let cmd = flag("--llm").unwrap_or_else(|| "claude -p".to_string()); + let questions = site.join("questions"); + if !questions.is_dir() { + bail!("{} has no questions/ directory", site.display()); + } + std::fs::create_dir_all(&out)?; + + let tasks_full = out.join("tasks-full.jsonl"); + let tasks_skim = out.join("tasks-skim.jsonl"); + let model = out.join("model.json"); + let mut log = String::new(); + log.push_str(&lint(&iris, &questions, &["--needs-tasks", &s(&tasks_full), "--needs-sim", &s(&model)])?); + log.push_str(&lint(&iris, &questions, &["--needs-tasks", &s(&tasks_skim), "--skim"])?); + std::fs::write(out.join("iris.log"), &log)?; + + let mut summary = serde_json::Map::new(); + for (mode, tasks, skim) in [("full", &tasks_full, false), ("skim", &tasks_skim, true)] { + eprintln!("score: the visitor answers {mode}"); + let answers = crate::trainer::answer_blind(&cmd, &std::fs::read_to_string(tasks)?, Some(&site), 0)?; + let answers_path = out.join(format!("answers-{mode}.jsonl")); + std::fs::write(&answers_path, &answers)?; + let (tasks_arg, answers_arg) = (s(tasks), s(&answers_path)); + let mut args = vec!["--needs-tasks", tasks_arg.as_str(), "--needs-score", answers_arg.as_str()]; + if skim { + args.push("--skim"); + } + let report = lint(&iris, &questions, &args)?; + println!("== {}", if skim { "headings only" } else { "with descriptions" }); + let mut right = None; + for line in report.lines() { + if line.starts_with("MISROUTE") || line.starts_with("HESITANT") || line.starts_with("UNANSWERED") { + println!("{line}"); + } + if let Some(rest) = line.strip_prefix("wording: ") { + // "wording: 19/27 routed right (70%)" + let mut parts = rest.split_whitespace().next().unwrap_or_default().split('/'); + let a: usize = parts.next().and_then(|x| x.parse().ok()).unwrap_or(0); + let b: usize = parts.next().and_then(|x| x.parse().ok()).unwrap_or(0); + right = Some((a, b)); + println!("{line}"); + } + } + let (a, b) = right.ok_or_else(|| anyhow!("iris printed no wording score for {mode}; its output:\n{report}"))?; + summary.insert(mode.to_string(), serde_json::json!({ "right": a, "of": b })); + } + + println!("== rate"); + let rated = out.join("rated"); + let rate_argv: Vec = ["rate", "--model", &s(&model), "--out", &s(&rated), "--repo", &s(&site), "--llm", &cmd].iter().map(|x| x.to_string()).collect(); + crate::trainer::rate::rate(&rate_argv)?; + + summary.insert("site".into(), serde_json::json!(s(&site))); + summary.insert("visitor".into(), serde_json::json!(cmd)); + std::fs::write(out.join("summary.json"), serde_json::to_string_pretty(&serde_json::Value::Object(summary.clone()))?)?; + println!( + "== summary: with descriptions {} of {}, headings only {} of {} (rate.json in {})", + summary["full"]["right"], summary["full"]["of"], summary["skim"]["right"], summary["skim"]["of"], rated.display() + ); + Ok(()) +} + +fn s(path: &Path) -> String { + path.display().to_string() +} + +/// `iris check --path `; stdout and stderr together, +/// since the lint reports on both. A lint that fails is a site that +/// cannot be scored, and its output is the reason. +fn lint(iris: &str, questions: &Path, args: &[&str]) -> anyhow::Result { + let output = Command::new(iris) + .arg("check") + .arg("--path") + .arg(questions) + .args(args) + .output() + .with_context(|| format!("running {iris} (pass --iris if it is not on PATH)"))?; + let text = format!("{}{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr)); + if !output.status.success() { + bail!("{iris} check --path {} {} failed:\n{text}", questions.display(), args.join(" ")); + } + Ok(text) +} diff --git a/src/trainer/simulate.rs b/src/trainer/simulate.rs new file mode 100644 index 0000000..6d06558 --- /dev/null +++ b/src/trainer/simulate.rs @@ -0,0 +1,678 @@ +//! Plays the whole business, many times. +//! +//! Input is the model `question_lint --needs-sim` exports. Each case is +//! one persona on one run. A visitor model picks an alternative and +//! fills the form in that person's words, in a manner that varies by +//! run; the record lands in its bucket. Then the case is walked along +//! the handling group's own desk buttons to a finished state - a +//! different one each run, so every state and button gets exercised - +//! and at each button a handler model is asked the one thing a model +//! can judge: with only what the form collected, could this role +//! really do what the button claims? If not, its question goes back to +//! the visitor, who may or may not know the answer. +//! +//! What comes out is about the content, never the market: +//! - a **misroute** is wording; +//! - an **abandoned** form asks too much of that person; +//! - an **ask-back** at (state, button) means the form did not collect +//! what the role needs there, and the question names the field; +//! - an **unanswerable** ask-back is a fact the visitor never had, so +//! the process itself has to supply it; +//! - **no route** means a finished state the desk cannot reach. +//! +//! Who wins a job is not simulated. That comes from the live bucket: +//! the `success` states in needs.yaml are what a report over real +//! records counts. + +use std::collections::BTreeMap; +use std::io::Write as _; + +use anyhow::{anyhow, Context}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; + +use crate::trainer::llm_with; + +const TRAITS: &[&str] = &[ + "is precise and businesslike", + "types two short lines on a phone and skips anything optional", + "explains at length, with every detail they can think of", + "is unsure of the right terms and describes things loosely", + "is in a hurry and a little impatient", +]; + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +#[serde(tag = "outcome", content = "detail", rename_all = "snake_case")] +pub enum Outcome { + Finished(String), + Stuck(String), + Misrouted(String), + Abandoned(String), +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct AskBack { + pub state: String, + pub button: String, + pub question: String, + /// The visitor knew the answer. When false, no form field would + /// have helped: the process has to find it out another way. + pub answerable: bool, +} + +#[derive(Debug, Serialize)] +struct Case { + persona: String, + need: String, + run: usize, + target: String, + record: BTreeMap, + states: Vec, + /// "state -> button", in the order pressed. + pressed: Vec, + /// Who pressed each of them: a group, or `submitter`. + moved_by: Vec, + touches: usize, + ask_backs: Vec, + outcome: Outcome, +} + +#[derive(Debug, Default, Serialize, Deserialize)] +pub struct NeedScore { + pub cases: usize, + pub finished: usize, + pub stuck: usize, + pub misrouted: usize, + pub abandoned: usize, + pub touches: usize, + pub ask_backs: usize, + pub unanswerable: usize, + pub asked: Vec, + /// "state -> button": (times pressed, times the role had to ask first) + pub buttons: BTreeMap, + pub stuck_at: Vec, + pub misroutes: Vec, + pub abandon_reasons: Vec, +} + +pub fn run(argv: &[String]) -> anyhow::Result<()> { + let value = |flag: &str| { + argv.iter() + .position(|a| a == flag) + .and_then(|i| argv.get(i + 1)) + .cloned() + }; + let usage = "usage: portal-trainer simulate --model --out [--llm 'cmd'] [--handler-llm 'cmd'] [--runs 3] [--need ]"; + let model_path = value("--model").ok_or_else(|| anyhow!(usage))?; + let out = std::path::PathBuf::from(value("--out").ok_or_else(|| anyhow!(usage))?); + let visitor_cmd = value("--llm").unwrap_or_else(|| "claude -p".to_string()); + let handler_cmd = value("--handler-llm").unwrap_or_else(|| visitor_cmd.clone()); + let runs: usize = value("--runs").map(|v| v.parse()).transpose()?.unwrap_or(3); + let only = value("--need"); + + let model: Value = serde_json::from_str( + &std::fs::read_to_string(&model_path).with_context(|| format!("reading {model_path}"))?, + )?; + std::fs::create_dir_all(&out)?; + let mut cases_file = std::fs::File::create(out.join("cases.jsonl"))?; + let mut scores: BTreeMap = BTreeMap::new(); + + // A dry run asks no model anything: it walks every route of every + // aggregate, so each button of each role is pressed once. Its cases + // are what `needs_replay` puts through the real engine. + if argv.iter().any(|a| a == "--dry") { + for need in model["needs"].as_array().into_iter().flatten() { + let id = need["id"].as_str().unwrap_or_default(); + if only.as_deref().is_some_and(|o| o != id) { + continue; + } + let bucket = &model["buckets"][need["lands_in"].as_str().unwrap_or_default()]; + let done: Vec<&str> = need["done_when"].as_array().into_iter().flatten().filter_map(Value::as_str).collect(); + for (run, route) in all_routes(bucket, &done).into_iter().enumerate() { + let mut states = vec![bucket["initial"].as_str().unwrap_or("open").to_string()]; + states.extend(route.iter().map(|m| m.to.clone())); + let case = Case { + persona: "route".to_string(), + need: id.to_string(), + run, + target: states.last().cloned().unwrap_or_default(), + record: BTreeMap::from([("note".to_string(), json!("dry run"))]), + pressed: route.iter().map(|m| format!("{} -> {}", m.from, m.label)).collect(), + moved_by: route.iter().map(|m| m.by.clone()).collect(), + touches: route.len(), + ask_backs: Vec::new(), + outcome: Outcome::Finished(states.last().cloned().unwrap_or_default()), + states, + }; + tally(scores.entry(id.to_string()).or_default(), &case); + writeln!(cases_file, "{}", serde_json::to_string(&case)?)?; + } + } + std::fs::write(out.join("score.json"), serde_json::to_string_pretty(&scores)?)?; + let relays: usize = scores.values().map(|s| s.cases).sum(); + println!("dry run: {relays} route(s) across {} need(s) written to {}", scores.len(), out.join("cases.jsonl").display()); + return Ok(()); + } + + for persona in model["personas"].as_array().into_iter().flatten() { + let need_id = persona["need"].as_str().unwrap_or_default(); + if only.as_deref().is_some_and(|o| o != need_id) { + continue; + } + let Some(need) = model["needs"] + .as_array() + .into_iter() + .flatten() + .find(|n| n["id"] == need_id) + else { + continue; + }; + for run in 0..runs { + let case = play(&model, need, persona, run, &visitor_cmd, &handler_cmd)?; + eprintln!( + "{} #{run}: {:?} after {} touch(es), {} ask-back(s)", + case.persona, + case.outcome, + case.touches, + case.ask_backs.len() + ); + tally(scores.entry(need_id.to_string()).or_default(), &case); + writeln!(cases_file, "{}", serde_json::to_string(&case)?)?; + } + } + std::fs::write(out.join("score.json"), serde_json::to_string_pretty(&scores)?)?; + print_scores(&scores); + Ok(()) +} + +fn play( + model: &Value, + need: &Value, + persona: &Value, + run: usize, + visitor_cmd: &str, + handler_cmd: &str, +) -> anyhow::Result { + let who = persona["text"].as_str().unwrap_or_default(); + let manner = TRAITS[run % TRAITS.len()]; + let seed = |step: usize| (run * 1000 + step).to_string(); + let mut case = Case { + persona: persona["id"].as_str().unwrap_or_default().to_string(), + need: need["id"].as_str().unwrap_or_default().to_string(), + run, + target: String::new(), + record: BTreeMap::new(), + states: Vec::new(), + pressed: Vec::new(), + moved_by: Vec::new(), + touches: 0, + ask_backs: Vec::new(), + outcome: Outcome::Stuck("never started".to_string()), + }; + + // The visitor's side. + for (i, step) in need["steps"].as_array().into_iter().flatten().enumerate() { + let options = step["options"].as_array().cloned().unwrap_or_default(); + if options.len() > 1 { + let prompt = format!( + "You are this person: {who}\n\nYou open a web page that asks: {}\nIt offers:\n{}\nWhich one do you pick? Judge only from your own situation and the wording.\nReply as JSON: {{\"choice\": \"