Generator, LLM backends, and the onboarding site

`portal-trainer generate` turns a plain-words business intake into a
content repo in three phases, each held to portal's question_lint:
needs.yaml (then frozen, so the generator cannot weaken the spec to
pass), structure (revised against the lint's findings until green),
and wording (personas answered blind, misroutes fed back; a rewrite
that breaks structure is reverted). Model output is written to disk
only under aggregates.yaml, site.yaml and questions/**.yaml.
`portal-trainer answer` is the blind answerer alone.

The LLM is an external command, prompt on stdin, text on stdout:
engines/ollama.sh (local Ollama), engines/anthropic.sh (API key),
engines/mailbox.sh (hand each prompt to an agent or a person).

portal-portal/ is the onboarding site as a portal content repo: the
front door is the intake interview, records land in `onboardings`
(open -> drafted <-> revising -> live | declined), and it passes its
own needs spec.

runs/uhhm-2026-09-21/ollama: qwen3:8b and granite4.1:8b as blind
answerers on the same eight tasks.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
This commit is contained in:
Bendik Aagaard Lynghaug
2026-09-21 18:14:25 +02:00
co-authored by Claude Fable 5.1
parent 7caaa27ba7
commit b6bdac6f62
21 changed files with 1029 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
target/
out/
Generated
+162
View File
@@ -0,0 +1,162 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "anyhow"
version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
[[package]]
name = "indexmap"
version = "2.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
dependencies = [
"equivalent",
"hashbrown",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "portal-trainer"
version = "0.1.0"
dependencies = [
"anyhow",
"serde",
"serde_json",
"serde_yaml",
]
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "ryu"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.151"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "serde_yaml"
version = "0.9.34+deprecated"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
dependencies = [
"indexmap",
"itoa",
"ryu",
"serde",
"unsafe-libyaml",
]
[[package]]
name = "syn"
version = "3.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
[[package]]
name = "unsafe-libyaml"
version = "0.2.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
+11
View File
@@ -0,0 +1,11 @@
[package]
name = "portal-trainer"
version = "0.1.0"
edition = "2021"
description = "Generates a portal content repo from a business intake, held to portal's question_lint"
[dependencies]
anyhow = "1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
serde_yaml = "0.9"
+11
View File
@@ -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("")'
+12
View File
@@ -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"
+13
View File
@@ -0,0 +1,13 @@
#!/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.
set -eu
jq -Rs --arg model "${MODEL:-qwen3:8b}" --argjson ctx "${NUM_CTX:-32768}" \
'{model: $model, stream: false, think: false,
options: {num_ctx: $ctx, temperature: 0.2},
messages: [{role: "user", content: .}]}' |
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'
+179
View File
@@ -0,0 +1,179 @@
# 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
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`.
+44
View File
@@ -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.
+17
View File
@@ -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]
+41
View File
@@ -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.
@@ -0,0 +1 @@
qualifies: owners
+25
View File
@@ -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 }
+21
View File
@@ -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]
+49
View File
@@ -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]
+15
View File
@@ -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]
+2
View File
@@ -0,0 +1,2 @@
title: Portal
lang: en
@@ -0,0 +1,8 @@
{"id":"architecture-firm@/","choice":"Work happens through dialogue, not tickets","confidence":0.95}
{"id":"climbing-gym-founder@/","choice":"Good software begins with a clear argument","confidence":0.98}
{"id":"municipality-buyer@/","choice":"Good software begins with a clear argument","confidence":0.97}
{"id":"exhibition-visitor@/","choice":"Attention is earned","confidence":0.9}
{"id":"tech-journalist@/","choice":"Attention is earned","confidence":0.92}
{"id":"rust-developer@/","choice":"Work happens through dialogue, not tickets","confidence":0.96}
{"id":"interaction-designer@/","choice":"Work happens through dialogue, not tickets","confidence":0.94}
{"id":"student-intern@/","choice":"Work happens through dialogue, not tickets","confidence":0.88}
@@ -0,0 +1,8 @@
{"id":"architecture-firm@/","choice":"Good software begins with a clear argument","confidence":0.95}
{"id":"climbing-gym-founder@/","choice":"Work happens through dialogue, not tickets","confidence":0.95}
{"id":"municipality-buyer@/","choice":"Good software begins with a clear argument","confidence":0.95}
{"id":"exhibition-visitor@/","choice":"Attention is earned","confidence":0.95}
{"id":"tech-journalist@/","choice":"Attention is earned","confidence":0.95}
{"id":"rust-developer@/","choice":"Work happens through dialogue, not tickets","confidence":0.95}
{"id":"interaction-designer@/","choice":"Work happens through dialogue, not tickets","confidence":0.95}
{"id":"student-intern@/","choice":"Work happens through dialogue, not tickets","confidence":0.95}
@@ -0,0 +1,8 @@
{"id":"architecture-firm@/","choice":"Work happens through dialogue, not tickets","confidence":0.95}
{"id":"climbing-gym-founder@/","choice":"Good software begins with a clear argument","confidence":0.9}
{"id":"municipality-buyer@/","choice":"Good software begins with a clear argument","confidence":0.93}
{"id":"exhibition-visitor@/","choice":"Attention is earned","confidence":0.96}
{"id":"tech-journalist@/","choice":"Attention is earned","confidence":0.94}
{"id":"rust-developer@/","choice":"Work happens through dialogue, not tickets","confidence":0.92}
{"id":"interaction-designer@/","choice":"Good software begins with a clear argument","confidence":0.91}
{"id":"student-intern@/","choice":"Work happens through dialogue, not tickets","confidence":0.88}
@@ -0,0 +1,8 @@
{"id":"architecture-firm@/","choice":"Good software begins with a clear argument","confidence":0.95}
{"id":"climbing-gym-founder@/","choice":"Work happens through dialogue, not tickets","confidence":0.85}
{"id":"municipality-buyer@/","choice":"Good software begins with a clear argument","confidence":0.90}
{"id":"exhibition-visitor@/","choice":"Attention is earned","confidence":0.80}
{"id":"tech-journalist@/","choice":"Attention is earned","confidence":0.85}
{"id":"rust-developer@/","choice":"Work happens through dialogue, not tickets","confidence":0.80}
{"id":"interaction-designer@/","choice":"Work happens through dialogue, not tickets","confidence":0.90}
{"id":"student-intern@/","choice":"Work happens through dialogue, not tickets","confidence":0.75}
+392
View File
@@ -0,0 +1,392 @@
//! 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};
const REFERENCE: &str = include_str!("../generator/reference.md");
struct Opts {
intake: PathBuf,
out: PathBuf,
lint: String,
llm: String,
rounds: usize,
wording_rounds: usize,
}
fn main() -> anyhow::Result<()> {
// `answer --tasks <file> [--llm <cmd>]`: the blind answerer alone,
// for grading an existing site with any backend. Answers go to
// stdout, ready for `question_lint --needs-score`.
let argv: Vec<String> = std::env::args().skip(1).collect();
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 <tasks.jsonl> [--llm 'cmd']"))?;
let cmd = value("--llm").unwrap_or_else(|| "claude -p".to_string());
print!("{}", answer_blind(&cmd, &std::fs::read_to_string(&tasks).with_context(|| format!("reading {tasks}"))?)?);
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"))?;
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 files = parse_files(&llm(&opts.llm, &prompt)?)?;
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}"));
break;
}
let needs = std::fs::read_to_string(opts.out.join("needs.yaml")).context("no needs.yaml was produced")?;
// Phase 2: structure.
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)?
)
};
apply(&opts.out, parse_files(&llm(&opts.llm, &prompt)?)?)?;
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.llm, &std::fs::read_to_string(&tasks_path)?)?;
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.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<<<FILE relative/path.yaml>>>\n...complete file content...\n<<<END>>>\n\nNo prose, no code fences.";
fn parse_args() -> anyhow::Result<Opts> {
let mut args = std::env::args().skip(1);
if args.next().as_deref() != Some("generate") {
bail!("usage: portal-trainer generate --intake <file> --out <dir> [--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,
};
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()?,
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.
fn llm(cmd: &str, prompt: &str) -> anyhow::Result<String> {
let mut child = Command::new("sh")
.arg("-c")
.arg(cmd)
.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))
}
/// `<<<FILE path>>> ... <<<END>>>` blocks. Anything outside a block is
/// ignored, so a model that adds a sentence does no harm.
fn parse_files(text: &str) -> anyhow::Result<BTreeMap<String, String>> {
let mut files = BTreeMap::new();
let mut current: Option<(String, String)> = None;
for line in text.lines() {
if let Some(path) = line.trim().strip_prefix("<<<FILE ").and_then(|r| r.strip_suffix(">>>")) {
current = Some((path.trim().to_string(), String::new()));
} else if line.trim() == "<<<END>>>" {
if let Some((path, body)) = current.take() {
files.insert(path, body);
}
} else if let Some((_, body)) = current.as_mut() {
body.push_str(line);
body.push('\n');
}
}
if files.is_empty() {
bail!("the model returned no <<<FILE ...>>> 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<String, String>) -> 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<Vec<PathBuf>> {
fn walk(dir: &Path, found: &mut Vec<PathBuf>) -> 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<Vec<(PathBuf, String)>> {
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<String> {
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!("<<<FILE {rel}>>>\n{}<<<END>>>\n\n", std::fs::read_to_string(&path)?));
}
Ok(text)
}
/// The answering side never sees `expect` or which need a persona has.
fn answer_blind(cmd: &str, tasks: &str) -> anyhow::Result<String> {
let mut blind = String::new();
for line in tasks.lines().filter(|l| !l.trim().is_empty()) {
let mut task: serde_json::Value = serde_json::from_str(line)?;
if let Some(map) = task.as_object_mut() {
map.remove("expect");
map.remove("need");
}
blind.push_str(&serde_json::to_string(&task)?);
blind.push('\n');
}
if blind.is_empty() {
return Ok(String::new());
}
let prompt = format!(
"You are a typed-decision answering engine. Each line below is a JSON task: `state.body` is a website visitor describing their situation in their own words; `questions.pick` holds `instructions` and `criteria`, a map from option label to what that option says.\n\nFor each task decide which single option that visitor would most plausibly choose, judging only from their text and the option wording, and give your honest confidence between 0 and 1. If two options are similarly plausible, confidence is low.\n\nReply with ONLY JSONL, one line per task, same order:\n{{\"id\":\"<task id>\",\"choice\":\"<option label copied exactly>\",\"confidence\":0.00}}\n\n{blind}"
);
let reply = llm(cmd, &prompt)?;
Ok(reply
.lines()
.map(str::trim)
.filter(|l| l.starts_with('{') && serde_json::from_str::<serde_json::Value>(l).is_ok())
.collect::<Vec<_>>()
.join("\n")
+ "\n")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn files_are_parsed_from_blocks_and_prose_is_ignored() {
let files = parse_files("Sure!\n<<<FILE site.yaml>>>\ntitle: A\n<<<END>>>\nnoise\n<<<FILE questions/index.yaml>>>\nname: Q?\n<<<END>>>\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 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"));
}
}