Trainer: documents - a text word for word, one card per section
Test / test (pull_request) Successful in 1m8s
Test / test (pull_request) Successful in 1m8s
Bylaws and terms are not the model's to write. documents in a plan names a markdown file beside it; every heading with text under it is one alternative on a page of its own, its paragraphs the card's words. Tomter Vel's bylaws of 23.04.2018 are the first. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
3bb517be0f
commit
c7e0ee146f
+147
-1
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user