`portal-trainer simulate` reads the model `question_lint --needs-sim` exports. A visitor model picks an alternative and fills the form in a manner that varies by run; the case is then walked along the handling group's own desk buttons to a finished state, a different one each run, so every state and button is exercised. At each button the handler model is asked one thing: with only what the form collected, could this role really do what the button claims? Its question goes back to the visitor, who may not know. Who wins is deliberately not simulated. The first version let the handler decide: qwen3:8b sent every case to "won", and after one sentence of prompt change, every case to "lost". Outcomes belong to the live bucket; `success` in needs.yaml is what a report over real records counts. `compare` sets two score files side by side. engines/ollama.sh honours SEED, FORMAT=json and TEMPERATURE so the same case replays the same way against two versions of a site. generator/reference.md documents `success` and `stages`. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
186 lines
7.2 KiB
Markdown
186 lines
7.2 KiB
Markdown
# 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/<name>.yaml "/<name>"
|
|
questions/<dir>/index.yaml "/<dir>"
|
|
questions/<dir>/_section.yaml applies `qualifies` to the whole directory
|
|
questions/<dir>/<name>.yaml "/<dir>/<name>" - 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: <group> # only when they are logged-in members of a group; omit for the public
|
|
via: <alternative name> # only when several alternatives on the entry page record into the same bucket
|
|
lands_in: <bucket>
|
|
handled_by: <group> # the role inside the business that must see and move the record
|
|
done_when: [<state>, <state>] # 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: [<state>] # the done_when states that are a good outcome for the business
|
|
stages: [open, <stage>, <stage>, <end>, <end>]
|
|
# 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: <need id>
|
|
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: <person or team>, contact: <email> } # required on every entry page
|
|
alternatives:
|
|
- name: <heading> # 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: <group>` (or sits in a
|
|
directory whose `_section.yaml` does). Only `enters_as: <group>`
|
|
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: <business name>
|
|
lang: en
|
|
```
|
|
|
|
## 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`.
|