Merge pull request 'Trainer: documents - a text word for word, one card per section' (#8) from documents-as-cards into main
docs / deploy (push) Successful in 3s
Test / test (push) Successful in 1m7s

This commit was merged in pull request #8.
This commit is contained in:
bl
2026-09-30 15:06:28 +00:00
5 changed files with 250 additions and 1 deletions
+4
View File
@@ -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<version>` tag; the
content repos pin the tag they run (see the end of this file).
## Unreleased
- Trainer: `documents` in a plan. A text the site carries word for word (bylaws, terms) becomes a page of its own where every section is one alternative: its heading the card's, its paragraphs the card's words, from a markdown file beside the plan. No model touches it.
## 0.6.0 (2026-09-30)
- 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".
+88
View File
@@ -0,0 +1,88 @@
# Vedtekter
### av 23.04.2018
##### § 1
Tomter vel er upolitisk. Foreningen har som formål å virke for stedets velferd, forskjønnelse og utvikling og ellers ivareta Tomters interesser.
##### § 2
Alle som er bosatt i Tomter kan bli medlem av foreningen. Midlertidig utflyttede medlemmer kan beholde sitt medlemskap.
##### § 3
Årsmøte, som er vellets øverste myndighet, holdes innen utgangen av mars hvert år. Møtet kunngjøres med minst tre ukers varsel. Forslag skal også gjøres kjent minst to uker før møtet. Årsmøte godkjenner beretning, regnskap og budsjett. Den velger styre, komitéledere, og valgkomite.
##### § 4.
Ekstraordinært årsmøte holdes når styret, eller minst 15 medlemmer krever det i skriftlig form. Gjennomføringen følger samme prosedyre som ordinært årsmøte
##### § 5
Tomter Vel ledes av et styre på seks medlemmer. Leder, nestleder, sekretær, kasserer og to styremedlemmer, som alle velges på årsmøtet. Funksjonstiden er to år. Leder, sekretær og ett styremedlem velges det ene året, nestleder, kasserer og det andre styremedlemmet det andre året. Det skal dessuten på årsmøte velges ett varamedlem til styret med funksjonstid to år. Styret engasjerer revisor eller ekstern regnskapsfører. Oppnevning av andre komitémedlemmer og representanter foretas av styret etter behov. Komitéledere har møterett på styremøter.
##### § 6
Medlemsmøte avholdes når styret finner det nødvendig og skal bekjentgjøres minst en uke i forveien. Det bør avholdes minst ett medlemmsmøte mellom hvert årsmøte.
##### § 7.1
Styret skal:
iverksette årsmøtets vedtak og bestemmelser.
Stå for velforeningens daglige ledelse og ivareta medlemmenes interesser overfor andre instanser.
Stå for den alminnelige forvaltningen av foreningen, herunder forvalte velforeningens eiendeler og føre kontroll med foreningens økonomi.
etter behov opprette komitéer eller bemyndige personer for å løse spesielle oppgaver.
etter beste evne søke å oppfylle foreningens formål eller gjeldende regler.
sørge for at foreningen er forsvarlig forsikret.
##### § 7.2
Styret har disposisjonsrett over velforeningens midler til alminnelige administrative formål og rett til å bevilge beløp til gjennomføring av vedtak på årsmøtet. Alle utbetalinger skal attesteres av leder og kasserer før utbetaling. Styret har imidlertid ikke adgang til uten samtykke fra årsmøtet å treffe beslutninger av større betydning for foreningen.
##### § 7.3
Styret holder møte når leder bestemmer eller et flertall av styremedlemmene forlanger det. Varamedlemmet kan møte på samtlige styremøter, men har kun stemmerett når styret ikke er fulltallig.
Alle saker og all korrespondanse skal legges fram på styremøtet. Styrets vedtak skal protokolleres. Det skal fremgå av protokollen om vedtaket er enstemmig, og eventuelle dissenser protokolleres. Alle medlemmene har rett til innsyn i protokollene fra styremøtene. Innsyn kan nektes av styret i enkeltsaker, dersom hensynet til tredjemann tilsier det eller dersom andre saklige grunner for nektelse foreligger.
##### § 7.4
Styret er beslutningsdyktig når et flertall av styremedlemmene, deriblant leder eller nestleder, er tilstede. Leder eller i hans/hennes fravær nestleder, leder styrets møter. Styrebeslutninger krever alminnelig flertall. Ved stemmelikhet har fungerende leder dobbeltstemme.
Et styremedlem kan ikke være med å stemme i saker hvor dette har særinteresser.
##### § 8
Regnskapsåret går fra 01.01–31.12. Styret skal påse at regnskapet blir ført.
##### § 9
Medlemskontingenten er den som til enhver tid er/blir fastsatt av Årsmøte. Kontingenten innkreves av styret. Den betales forskuddsvis.
##### § 10
Tomter Vel kan bare oppløses ved vedtak på Årsmøte etter forslag fra styret. Til slikt vedtak kreves to tredjedels stemmeflertall blant de frammøtte medlemmer. Forslag om oppløsning skal gjøres kjent 3 uker før årsmøtet holdes. Eventuelle midler som Tomter Vel sitter inne med, anvendes da straks til formål som kommer Tomter til gode.
##### § 11
Styret eller årsmøtet oppretter komiteer for definerte formål. Komitéer skal bestå av komiteleder som rapporterer til styret. Komiteleder tilknytter et egnet antall personer til formålet og organiserer arbeidet.
##### § 12
Valgkomiteen består av to medlemmer med funksjonstid ett år. Valgkomiteen foreslår overfor Årsmøte medlemmer og varamedlemmer til styre, komitéer og valgkomite, samt revisor.
##### § 13
Tomter Vel skal ha en revisorer med funksjonstid to år. Revisoren skal revidere regnskapet og legge fram revisjonsberetning for Årsmøtet.
##### § 14
Forandringer i disse lover kan bare skje ved vedtak på Årsmøte. Slikt vedtak krever to tredjedels stemmeflertall blant de frammøtte medlemmene.
+8
View File
@@ -62,6 +62,14 @@ does:
nabohjelpsgruppa deg med en nabo som kan. Du sier selv fra når
det er ordnet.
# Vedtektene, ord for ord slik årsmøtet vedtok dem: en side der hver
# paragraf er sitt eget kort. Ingen modell rører teksten.
documents:
- id: vedtekter
question: Hva sier vedtektene?
description: Vedtektene for Tomter Vel, av 23.04.2018.
file: documents/tomtervel-vedtekter.md
doors:
- id: medlem
who: en husstand i Tomter som vil bli medlem, betale kontingent, eller melde flytting, ny e-post eller utmelding
+147 -1
View File
@@ -62,6 +62,12 @@ pub struct Plan {
/// is what the page-load limit asks for.
#[serde(default)]
pub doors: Vec<Door>,
/// Texts the site carries word for word - bylaws, terms - each as
/// a page of its own where every section of the text is one
/// alternative: its heading the card's, its paragraphs the card's
/// words. No model touches them and no form hangs off them.
#[serde(default)]
pub documents: Vec<Document>,
/// 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
@@ -158,6 +164,85 @@ pub struct GroupDef {
pub invited_by: Vec<String>,
}
/// A text carried word for word (`Plan.documents`).
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Document {
/// The page's path: `vedtekter` is served at `/vedtekter`.
pub id: String,
/// The question the page asks, which the text answers.
pub question: String,
#[serde(default)]
pub description: String,
/// A markdown file beside the plan: every heading with text under
/// it is a section. Read into `sections` when the plan is loaded.
#[serde(default)]
pub file: Option<String>,
#[serde(default)]
pub sections: Vec<Section>,
}
#[derive(Debug, Deserialize, Clone, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct Section {
pub heading: String,
/// The section's paragraphs, exactly as written.
pub text: Vec<String>,
}
/// A markdown text as sections: each heading with text under it, and
/// that text as paragraphs. A heading with nothing under it is a title
/// and no section; text before the first heading belongs to nobody and
/// is left out. Nothing is reworded: a paragraph's lines are joined
/// with one space, and that is all.
pub fn sections_of(markdown: &str) -> Vec<Section> {
let mut out: Vec<Section> = Vec::new();
let mut heading: Option<String> = None;
let mut paragraphs: Vec<String> = Vec::new();
let mut current: Vec<&str> = Vec::new();
let mut close = |heading: &mut Option<String>, paragraphs: &mut Vec<String>, out: &mut Vec<Section>| {
if let Some(h) = heading.take() {
if !paragraphs.is_empty() {
out.push(Section { heading: h, text: std::mem::take(paragraphs) });
}
}
paragraphs.clear();
};
for line in markdown.lines().chain(std::iter::once("")) {
let trimmed = line.trim();
let is_heading = trimmed.starts_with('#') && trimmed.trim_start_matches('#').starts_with(' ');
if trimmed.is_empty() || is_heading {
if !current.is_empty() {
paragraphs.push(current.join(" "));
current.clear();
}
}
if is_heading {
close(&mut heading, &mut paragraphs, &mut out);
heading = Some(trimmed.trim_start_matches('#').trim().trim_end_matches('.').to_string());
} else if !trimmed.is_empty() {
current.push(trimmed);
}
}
close(&mut heading, &mut paragraphs, &mut out);
out
}
impl Plan {
/// Reads every document's `file`, relative to `dir`, into its
/// sections. Done once, when a plan is loaded from disk.
pub fn load_documents(&mut self, dir: &Path) -> anyhow::Result<()> {
for doc in &mut self.documents {
if let Some(file) = &doc.file {
let path = dir.join(file);
let raw = std::fs::read_to_string(&path).with_context(|| format!("document {:?}: reading {}", doc.id, path.display()))?;
doc.sections = sections_of(&raw);
}
}
Ok(())
}
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Door {
@@ -410,6 +495,17 @@ impl Plan {
bail!("contact: group {group:?} has no desk: it must handle, watch or move something");
}
}
for doc in &self.documents {
if !is_id(&doc.id) || ["status", "received", "decide"].contains(&doc.id.as_str()) || groups(self).contains(&doc.id) || self.doors.iter().any(|d| d.id == doc.id) {
bail!("document {:?}: id must be ASCII lowercase with - or _, and a path no door, group or builder page has", doc.id);
}
if doc.question.trim().is_empty() {
bail!("document {:?}: the page needs the question its text answers", doc.id);
}
if doc.sections.is_empty() {
bail!("document {:?} has no sections: give it a `file` with headings, or `sections`", doc.id);
}
}
let mut doors = BTreeSet::new();
for door in &self.doors {
if !is_id(&door.id) || !doors.insert(&door.id) {
@@ -2160,6 +2256,27 @@ pub fn render(plan: &Plan, words: &BTreeMap<String, Value>) -> anyhow::Result<BT
);
}
// Documents: a public page per text, one alternative per section,
// every word the plan's own. The first paragraph is the card's
// description; any further ones follow as unnamed features.
for doc in &plan.documents {
files.insert(
format!("questions/{}.yaml", doc.id),
json!({
"name": doc.question.trim(),
"description": doc.description.trim(),
"responsible": responsible,
"alternatives": doc.sections.iter().map(|section| {
let mut alt = json!({ "name": section.heading, "description": section.text.first().cloned().unwrap_or_default() });
if section.text.len() > 1 {
alt["features"] = json!(section.text[1..].iter().map(|p| json!({ "name": "", "description": p })).collect::<Vec<_>>());
}
alt
}).collect::<Vec<_>>(),
}),
);
}
// 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.
@@ -2317,8 +2434,9 @@ pub fn run(argv: &[String]) -> anyhow::Result<()> {
let usage = "usage: portal-trainer build --plan <plan.yaml> --out <dir> [--words-from <existing repo>] [--words <words.json>]... [--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}"))?)
let mut 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.load_documents(Path::new(&plan_path).parent().unwrap_or_else(|| Path::new(".")))?;
plan.validate()?;
for warning in plan.spectators() {
eprintln!("build: {warning}");
@@ -3267,6 +3385,34 @@ mod invite_tests {
assert!(blank.validate().unwrap_err().to_string().contains("tones"));
}
#[test]
fn a_document_is_a_page_with_one_card_per_section_word_for_word() {
let text = "# Vedtekter\n\n### av 23.04.2018\n\n##### § 1\n\nTomter vel er upolitisk. Foreningen har som\nformål å virke for stedets velferd.\n\n##### § 4.\n\nEkstraordinært årsmøte holdes når styret krever det.\n\nGjennomføringen følger samme prosedyre.\n";
let sections = sections_of(text);
// Titles with nothing under them are not sections; a wrapped
// paragraph is one paragraph; the words are untouched.
assert_eq!(sections.len(), 2);
assert_eq!(sections[0], Section { heading: "§ 1".into(), text: vec!["Tomter vel er upolitisk. Foreningen har som formål å virke for stedets velferd.".into()] });
assert_eq!(sections[1].heading, "§ 4");
assert_eq!(sections[1].text.len(), 2);
let mut plan: Plan = serde_yaml::from_str(&format!("{PLAN}documents:\n - id: vedtekter\n question: Hva sier vedtektene?\n description: Vedtatt 23.04.2018.\n")).unwrap();
assert!(plan.validate().unwrap_err().to_string().contains("no sections"));
plan.documents[0].sections = sections;
plan.validate().unwrap();
let files = render(&plan, &fallback_words(&plan)).unwrap();
let page: Value = serde_yaml::from_str(&files["questions/vedtekter.yaml"]).unwrap();
assert_eq!(page["name"], "Hva sier vedtektene?");
let alts = page["alternatives"].as_array().unwrap();
assert_eq!(alts.len(), 2);
assert_eq!(alts[0]["name"], "§ 1");
assert_eq!(alts[0]["description"], "Tomter vel er upolitisk. Foreningen har som formål å virke for stedets velferd.");
assert!(alts[0].get("action").is_none() && alts[0].get("features").is_none());
assert_eq!(alts[1]["features"][0]["description"], "Gjennomføringen følger samme prosedyre.");
// A path something else has is refused.
plan.documents[0].id = "status".into();
assert!(plan.validate().unwrap_err().to_string().contains("document"));
}
#[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();
+3
View File
@@ -175,6 +175,9 @@ rest. Each is one line in the plan, and the builder does the wiring:
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.
- **Carry a text word for word.** `documents` puts bylaws or terms on
a page of their own, one card per section, from a markdown file
beside the plan. Nothing rewords them.
- **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