First generated site, a held-out test, and the leak it exposed
runs/contractor-2026-09-21: the generator's first end-to-end run, on intakes/contractor.yaml. Needs parsed first round, structure green first round, 10/10 on its own personas - and 8/10 headings-only on ten held-out personas with qwen3:8b as the visitor, because the pages had been written with the personas in view. Phase 2 now shows the model the needs without the personas. `portal-trainer answer` exposes the blind answerer on its own. README rewritten around what this is: an intake in, a verified content repo out. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
b6bdac6f62
commit
e163c22792
@@ -1,123 +1,136 @@
|
||||
# portal-trainer
|
||||
|
||||
Onboarding a business onto [portal](https://project.uhhm.no/uhhm/portal)
|
||||
is mostly a wording problem: the engine is generic, and whether it
|
||||
works for a given business depends on whether its pages say, in the
|
||||
visitor's words, the thing the business needs them to answer. This
|
||||
repo is where that gets tested and, eventually, learned.
|
||||
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. `portal-trainer` turns that into a working
|
||||
[portal](https://project.uhhm.no/uhhm/portal) 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 a verifier.
|
||||
|
||||
## The loop
|
||||
## How it works
|
||||
|
||||
```
|
||||
interview ─► needs.yaml ─► pages + aggregates + desks
|
||||
│ │
|
||||
│ question_lint (structure: pass/fail)
|
||||
│ │
|
||||
└─► personas ─► tasks.jsonl ─► answering engine ─► answers.jsonl
|
||||
│
|
||||
question_lint --needs-score ◄──┘
|
||||
│
|
||||
misroutes = wording defects ─► revise pages
|
||||
intake (plain words)
|
||||
│
|
||||
▼ phase 1 frozen after this phase: the generator
|
||||
needs.yaml ─────────────────────── may not weaken the spec to pass
|
||||
│
|
||||
▼ phase 2 ◄── question_lint findings, until it exits 0
|
||||
aggregates.yaml + questions/** + desks
|
||||
│
|
||||
▼ phase 3 ◄── misroutes from a blind visitor model
|
||||
wording
|
||||
```
|
||||
|
||||
- **`needs.yaml`** lives in the business's own content repo. It says
|
||||
who wants what, which bucket the answer lands in, which group
|
||||
carries it to a finished state, and what the visitor will tolerate
|
||||
in submissions and required fields.
|
||||
- **The verifier is portal's `question_lint`**, not code in this repo.
|
||||
It reads portal's own content types and ships in every portal
|
||||
release, so a content repo's CI enforces its needs with no new
|
||||
dependency. Structure is deterministic and gates CI. Wording is
|
||||
graded, and gates only if you pass `--min-accuracy`.
|
||||
- **This repo holds what sits around the verifier**: the trainer
|
||||
procedure (`skill/SKILL.md`), sector specs to start from
|
||||
(`sectors/`), and the runs (`runs/`).
|
||||
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 portal's `question_lint` 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.
|
||||
|
||||
```
|
||||
question_lint --path <repo>/questions # structure
|
||||
question_lint --path <repo>/questions --needs-tasks tasks.jsonl # full wording
|
||||
question_lint --path <repo>/questions --needs-tasks skim.jsonl --skim # headings only
|
||||
question_lint --path <repo>/questions --needs-score answers.jsonl [--min-accuracy 0.8]
|
||||
portal-trainer generate --intake intakes/contractor.yaml --out out/nordvik \
|
||||
--lint /srv/app/<inst>/current/question_lint --llm engines/ollama.sh
|
||||
portal-trainer answer --tasks tasks.jsonl --llm engines/ollama.sh > answers.jsonl
|
||||
```
|
||||
|
||||
## The engine contract
|
||||
The verifier is portal's `question_lint`, 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.
|
||||
|
||||
A task is one page on one persona's path, as a typed `choice`:
|
||||
## LLM backends
|
||||
|
||||
```json
|
||||
{"id":"architecture-firm@/","persona":"architecture-firm","page":"/",
|
||||
"state":{"body":"We run a small architecture firm and ..."},
|
||||
"questions":{"pick":{"type":"choice",
|
||||
"instructions":"A visitor reads the page question \"What are you here for?\". Which alternative do they choose?",
|
||||
"criteria":{"<alternative heading>":"<its description>", "...":"..."}}},
|
||||
"expect":["<every alternative that still meets the need>"]}
|
||||
```
|
||||
A backend is any command that takes a prompt on stdin and prints text.
|
||||
|
||||
Strip `expect` (and `need`) before an engine sees it. An answer is one
|
||||
line: `{"id": "...", "choice": "<heading, verbatim>", "confidence": 0.0-1.0}`.
|
||||
`state` and `questions` are exactly the arguments of
|
||||
[laya](https://github.com/NandhaKishorM/laya)'s
|
||||
`router.predict(state, questions)`, so a laya adapter is a loop over
|
||||
the file; an LLM adapter is a prompt around the same two fields.
|
||||
|
||||
## Engines, and what is known about them
|
||||
|
||||
| Engine | Status | What it is good for |
|
||||
|---|---|---|
|
||||
| LLM, blind (no `expect`) | used for the first run | Reads like a visitor; slow and costs per task, so use it to find defects, not to gate CI. |
|
||||
| laya `english` (ModernBERT-large, 421M) | not run yet | 33 ms per question, so it can gate CI and score thousands of generated variants. Its authors measure 0.78 intent accuracy in English. |
|
||||
| laya `multilingual` | not run yet | Its authors' own sweep puts Norwegian Bokmål at **0.53 accuracy with 0.86 mean confidence**. It is confidently wrong too often to grade Norwegian wording without fine-tuning. Test a Norwegian site in English translation, or with an LLM. |
|
||||
|
||||
An engine is a measuring instrument. Before trusting one on a sector,
|
||||
score it on tasks whose right answer nobody disputes; an engine that
|
||||
misses those is measuring itself, not the page.
|
||||
|
||||
## First run: uhhm.no, 2026-09-21
|
||||
|
||||
Nine personas against the live uhhm.no front door, answered by a small
|
||||
LLM that never saw `expect`. One run each; eight tasks; treat as a
|
||||
lead, not a measurement.
|
||||
|
||||
| What the engine could read | Routed right |
|
||||
| Backend | Use |
|
||||
|---|---|
|
||||
| Headings and descriptions | 7 of 8 |
|
||||
| Headings only (`--skim`) | 5 of 8 |
|
||||
| `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/anthropic.sh` | The Anthropic API, given a working `ANTHROPIC_API_KEY`. |
|
||||
| `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. |
|
||||
|
||||
- A founder who wants a first version built picked **"Work happens
|
||||
through dialogue, not tickets"** - the collaborator door - in both
|
||||
modes. With headings only, so did a firm wanting to pay for storage
|
||||
and backups. The heading reads as how UHHM works with clients.
|
||||
- With headings only, a student seeking an internship picked
|
||||
**"Attention is earned"**.
|
||||
- Structure: four needs met. The site-improvement flow is advertised
|
||||
and `disabled`, so it is declared `planned`.
|
||||
The visitor and the generator should be different models. A model
|
||||
grading its own wording agrees with itself.
|
||||
|
||||
## Sectors
|
||||
## The onboarding site
|
||||
|
||||
A sector file is a needs spec with no pages behind it: a failing test
|
||||
suite for the trainer to make pass, carrying what the sector demands
|
||||
(why a lead is a pipeline, why a deviation report gets three fields).
|
||||
`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`.
|
||||
|
||||
- `sectors/software-studio/needs.yaml` - UHHM's own, and green against
|
||||
`uhhm/questions`.
|
||||
- `sectors/construction/needs.yaml` - a contractor with project
|
||||
managers, a safety officer, crew and subcontractors: winning survey
|
||||
and design work, vetting subcontractors, requesting and answering
|
||||
bids, hiring, deviation reporting. No pages yet.
|
||||
## 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.
|
||||
|
||||
**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.
|
||||
|
||||
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, on purpose. The order that makes
|
||||
training possible:
|
||||
|
||||
1. The verifier has to be trusted first. It is the reward. A generator
|
||||
trained against a judge that is wrong half the time learns the
|
||||
judge.
|
||||
2. Every onboarding leaves a run: needs, the pages at each revision,
|
||||
the scores. That corpus - what wording routed which persona right,
|
||||
per sector - is the training data, and it does not exist until the
|
||||
loop has been run by hand a few dozen times.
|
||||
3. With a fast engine (laya-class) as the scorer, generating and
|
||||
scoring many page variants per need is cheap, which is what makes
|
||||
search, and later fine-tuning a small generator on the winners,
|
||||
practical.
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user