--- name: portal-trainer description: Onboard a business onto portal - interview them, write their needs.yaml, then write the content repo (pages, aggregates.yaml, desks) until question_lint proves every need is met and an answering engine routes the personas right. Use when someone wants a new portal site, a new flow on an existing 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. You have a verifier. Use it instead of your judgment wherever it can speak: `question_lint --path /questions` exits non-zero until the structure meets every need in `/needs.yaml`. ## The order of work The short path, when a business has filled in an intake: `plan` reads it into a `plan.yaml` (a model decides needs, stages, forms and budgets; a person reads the result), `build` turns the plan into every file of the site with no model deciding anything, and a model writes only the words. See the repo README. The steps below are the same procedure done by hand, and what to do when the numbers come back. 1. **Interview, then write `needs.yaml` first.** Never pages first. Ask who comes to the business and what each of them wants, who in the business handles it, and what "handled" means - the states a case ends in. Ask what it costs the visitor to ask: how many submissions, how many required fields they will tolerate. That number is `max_fields`, and it comes from the person, never from counting the form you would like to write. If the business is in a sector under `sectors/`, start from that file and cut it down with them; do not start blank. 2. **Write personas in the visitor's words, not the business's.** Two or three per need, including one who would not use the trade's own vocabulary. Never reuse a phrase you intend to put on a page - a persona that quotes the page tests nothing. 3. **Run the lint. It fails. Read every line.** Each failure names what is missing: no path, a closed (`disabled`) path, a field budget blown, no desk held by the handling group, a finished state no desk move reaches, a state records strand in. 4. **Write the least content that turns it green**: `aggregates.yaml` (one aggregate per bucket, states the business named, only the transitions they described), the visitor pages, one followup page per recording alternative saying what happens next, and one desk page per handling group with exactly the moves the state machine allows. Mark a need `planned: true` only when the business says "later" - it keeps warning. 5. **Then word it.** `question_lint ... --needs-tasks tasks.jsonl`, and again with `--skim` (headings only - most visitors read nothing else). Give the tasks to an answering engine that never sees `expect`; grade with `--needs-score answers.jsonl`. - A **misroute** is a wording defect. Fix the heading before the description: a heading that needs its description has failed. - Two alternatives a persona confuses are usually one distinction the business makes and the visitor does not. Name the visitor's side of it. - **Hesitant** (right, under 0.6) means it works and is still a coin flip. Same treatment. - Change the page, never the persona, to make a score go up. 6. **Keep the run.** Copy tasks, answers and the score into `runs/-/` with a line on which engine answered. Runs are the record of which wording worked, per sector. ## What a page is Full reference: portal's `README.md` and `docs/design/filesystem-routes.md`; a live example is `uhhm/questions`. The file 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: # 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 } - { name: address, label: Address, optional: true } ``` ```yaml # aggregates.yaml aggregates: - bucket: leads initial: open states: open: { event: received } quoted: { event: quoted } won: { event: won } lost: { event: lost } transitions: open: [quoted, lost] quoted: [won, lost] ``` ```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 - { to: lost, label: Not for us } - { from: quoted, to: won, label: Won } - { from: quoted, to: lost, label: Lost } ``` ## House rules for the copy - The page name is a question the visitor could ask themselves. - 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 actually use to answer. If they can't say what they do with it, it's optional or gone. - A followup page says who reads it, when they answer, and what the visitor can do meanwhile. ## Engines Any engine that reads a task's `state` and `questions` and returns `{"id","choice","confidence"}` per line works. Compare at least two before trusting a score, and re-run the same engine before trusting a change: one run of eight tasks is an anecdote. See the repo README for what has been measured about each.