A need may name which of its `done_when` states are a good outcome
for the business ("lost" is finished, "won" is success), checked to be
a subset of `done_when`.
`question_lint --needs-sim <model.json>` writes everything a
simulation of the business needs, resolved the way the runtime
resolves content: each need's path with the choice on every page and
the form that gets filled, each bucket's state machine with every
desk's labelled buttons, and the personas. Portal exports it because
only portal loads content correctly; the simulator (uhhm/portal-trainer)
reads plain JSON.
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
188f9bf574
commit
7f90c14554
@@ -22,6 +22,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
let mut answers_in: Option<String> = None;
|
let mut answers_in: Option<String> = None;
|
||||||
let mut min_accuracy: f64 = 0.0;
|
let mut min_accuracy: f64 = 0.0;
|
||||||
let mut skim = false;
|
let mut skim = false;
|
||||||
|
let mut sim_out: Option<String> = None;
|
||||||
|
|
||||||
while let Some(arg) = args.next() {
|
while let Some(arg) = args.next() {
|
||||||
match arg.as_str() {
|
match arg.as_str() {
|
||||||
@@ -31,6 +32,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
"--path" => path = args.next(),
|
"--path" => path = args.next(),
|
||||||
"--needs-tasks" => tasks_out = args.next(),
|
"--needs-tasks" => tasks_out = args.next(),
|
||||||
"--skim" => skim = true,
|
"--skim" => skim = true,
|
||||||
|
"--needs-sim" => sim_out = args.next(),
|
||||||
"--needs-score" => answers_in = args.next(),
|
"--needs-score" => answers_in = args.next(),
|
||||||
"--min-accuracy" => {
|
"--min-accuracy" => {
|
||||||
min_accuracy = args.next().and_then(|v| v.parse().ok()).unwrap_or(min_accuracy)
|
min_accuracy = args.next().and_then(|v| v.parse().ok()).unwrap_or(min_accuracy)
|
||||||
@@ -59,7 +61,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
(questions, aggregates_map, site, needs_raw)
|
(questions, aggregates_map, site, needs_raw)
|
||||||
} else {
|
} else {
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"usage: question-lint (--repo <gitea-url> [--branch main] [--subdir questions] | --path <local-dir>) [--needs-tasks <out.jsonl> [--skim]] [--needs-score <answers.jsonl> [--min-accuracy 0.8]]"
|
"usage: question-lint (--repo <gitea-url> [--branch main] [--subdir questions] | --path <local-dir>) [--needs-tasks <out.jsonl> [--skim]] [--needs-sim <model.json>] [--needs-score <answers.jsonl> [--min-accuracy 0.8]]"
|
||||||
);
|
);
|
||||||
std::process::exit(2);
|
std::process::exit(2);
|
||||||
};
|
};
|
||||||
@@ -94,6 +96,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
&aggregates_map,
|
&aggregates_map,
|
||||||
tasks_out.as_deref(),
|
tasks_out.as_deref(),
|
||||||
skim,
|
skim,
|
||||||
|
sim_out.as_deref(),
|
||||||
answers_in.as_deref(),
|
answers_in.as_deref(),
|
||||||
min_accuracy,
|
min_accuracy,
|
||||||
)
|
)
|
||||||
@@ -205,6 +208,7 @@ fn check_needs(
|
|||||||
aggregates_map: &std::collections::HashMap<String, aggregates::AggregateSchema>,
|
aggregates_map: &std::collections::HashMap<String, aggregates::AggregateSchema>,
|
||||||
tasks_out: Option<&str>,
|
tasks_out: Option<&str>,
|
||||||
skim: bool,
|
skim: bool,
|
||||||
|
sim_out: Option<&str>,
|
||||||
answers_in: Option<&str>,
|
answers_in: Option<&str>,
|
||||||
min_accuracy: f64,
|
min_accuracy: f64,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
@@ -255,6 +259,12 @@ fn check_needs(
|
|||||||
// The wording pass still runs for the needs that do have a path -
|
// The wording pass still runs for the needs that do have a path -
|
||||||
// a closed flow elsewhere is no reason to stop grading the rest.
|
// a closed flow elsewhere is no reason to stop grading the rest.
|
||||||
|
|
||||||
|
if let Some(out) = sim_out {
|
||||||
|
let model = needs::sim_model(&file, questions, aggregates_map);
|
||||||
|
std::fs::write(out, serde_json::to_string_pretty(&model)?)
|
||||||
|
.map_err(|e| anyhow::anyhow!("writing {out}: {e}"))?;
|
||||||
|
println!("wrote the simulation model to {out}");
|
||||||
|
}
|
||||||
let tasks = needs::tasks(&file, questions, skim);
|
let tasks = needs::tasks(&file, questions, skim);
|
||||||
if let Some(out) = tasks_out {
|
if let Some(out) = tasks_out {
|
||||||
let mut lines = String::new();
|
let mut lines = String::new();
|
||||||
|
|||||||
+144
-4
@@ -74,6 +74,11 @@ pub struct Need {
|
|||||||
/// as a warning, so the gap stays visible without failing CI.
|
/// as a warning, so the gap stays visible without failing CI.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub planned: bool,
|
pub planned: bool,
|
||||||
|
/// The finished states that are a good outcome for the business -
|
||||||
|
/// a subset of `done_when`. "lost" is finished; "won" is success.
|
||||||
|
/// What a simulation or a live bucket report counts toward.
|
||||||
|
#[serde(default)]
|
||||||
|
pub success: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn root() -> String {
|
fn root() -> String {
|
||||||
@@ -220,7 +225,8 @@ pub fn path_for(questions: &Questions, need: &Need) -> Option<Vec<Step>> {
|
|||||||
struct Desk {
|
struct Desk {
|
||||||
page: String,
|
page: String,
|
||||||
group: Option<String>,
|
group: Option<String>,
|
||||||
moves: Vec<(String, String)>,
|
/// (from, to, button label)
|
||||||
|
moves: Vec<(String, String, String)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn desks_over(questions: &Questions, bucket: &str) -> Vec<Desk> {
|
fn desks_over(questions: &Questions, bucket: &str) -> Vec<Desk> {
|
||||||
@@ -239,7 +245,7 @@ fn desks_over(questions: &Questions, bucket: &str) -> Vec<Desk> {
|
|||||||
moves: resource
|
moves: resource
|
||||||
.transitions
|
.transitions
|
||||||
.iter()
|
.iter()
|
||||||
.map(|t| (t.from.clone(), t.to.clone()))
|
.map(|t| (t.from.clone(), t.to.clone(), t.label.clone()))
|
||||||
.collect(),
|
.collect(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -407,8 +413,8 @@ pub fn check(
|
|||||||
let mut to: Vec<String> = own
|
let mut to: Vec<String> = own
|
||||||
.iter()
|
.iter()
|
||||||
.flat_map(|d| &d.moves)
|
.flat_map(|d| &d.moves)
|
||||||
.filter(|(from, to)| from == state && allowed.contains(to))
|
.filter(|(from, to, _)| from == state && allowed.contains(to))
|
||||||
.map(|(_, to)| to.clone())
|
.map(|(_, to, _)| to.clone())
|
||||||
.collect();
|
.collect();
|
||||||
to.extend(selfs.iter().filter(|to| allowed.contains(to)).cloned());
|
to.extend(selfs.iter().filter(|to| allowed.contains(to)).cloned());
|
||||||
to
|
to
|
||||||
@@ -449,6 +455,14 @@ pub fn check(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for state in &need.success {
|
||||||
|
if !need.done_when.contains(state) {
|
||||||
|
report(
|
||||||
|
Level::Fail,
|
||||||
|
format!("success names {state:?}, which is not one of done_when"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
stranded.sort();
|
stranded.sort();
|
||||||
for state in stranded {
|
for state in stranded {
|
||||||
report(
|
report(
|
||||||
@@ -474,6 +488,102 @@ pub fn check(
|
|||||||
findings
|
findings
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Everything a simulation of the business needs, resolved: each
|
||||||
|
/// need's path with the choice on every page and the form that gets
|
||||||
|
/// filled, each bucket's state machine with the buttons every desk
|
||||||
|
/// offers, and the personas. Portal resolves it because only portal
|
||||||
|
/// loads content the way the runtime does (derived ids, relative
|
||||||
|
/// refs, sections); whoever simulates reads plain JSON.
|
||||||
|
pub fn sim_model(
|
||||||
|
file: &NeedsFile,
|
||||||
|
questions: &Questions,
|
||||||
|
aggregates: &HashMap<String, AggregateSchema>,
|
||||||
|
) -> serde_json::Value {
|
||||||
|
use serde_json::json;
|
||||||
|
let mut needs = Vec::new();
|
||||||
|
let mut buckets = serde_json::Map::new();
|
||||||
|
for need in &file.needs {
|
||||||
|
let Some(path) = path_for(questions, need) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let steps: Vec<serde_json::Value> = path
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, step)| {
|
||||||
|
let page = &questions[&step.page];
|
||||||
|
let mut trail: Vec<String> = path[..=i].iter().map(|s| s.page.clone()).collect();
|
||||||
|
let expect: Vec<String> =
|
||||||
|
viable(questions, need, page, need.max_steps - i, i == 0, false, &mut trail)
|
||||||
|
.into_iter()
|
||||||
|
.map(|v| v.alternative)
|
||||||
|
.collect();
|
||||||
|
let fields: Vec<serde_json::Value> = page
|
||||||
|
.alternatives
|
||||||
|
.iter()
|
||||||
|
.find(|a| a.name == step.alternative)
|
||||||
|
.into_iter()
|
||||||
|
.flat_map(|a| &a.features)
|
||||||
|
.flat_map(|f| &f.requirements)
|
||||||
|
.map(|r| {
|
||||||
|
json!({
|
||||||
|
"name": r.name,
|
||||||
|
"label": r.label.clone().unwrap_or_else(|| r.name.clone()),
|
||||||
|
"type": r.kind,
|
||||||
|
"optional": r.optional,
|
||||||
|
"options": r.options,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
json!({
|
||||||
|
"page": step.page,
|
||||||
|
"question": page.name,
|
||||||
|
"options": page.alternatives.iter().filter(|a| !a.disabled).map(|a| json!({
|
||||||
|
"name": a.name,
|
||||||
|
"description": plain(&a.description),
|
||||||
|
})).collect::<Vec<_>>(),
|
||||||
|
"expect": expect,
|
||||||
|
"alternative": step.alternative,
|
||||||
|
"fields": fields,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
needs.push(json!({
|
||||||
|
"id": need.id,
|
||||||
|
"who": need.who,
|
||||||
|
"wants": need.wants,
|
||||||
|
"enters_as": need.enters_as,
|
||||||
|
"lands_in": need.lands_in,
|
||||||
|
"handled_by": need.handled_by,
|
||||||
|
"done_when": need.done_when,
|
||||||
|
"success": need.success,
|
||||||
|
"steps": steps,
|
||||||
|
}));
|
||||||
|
if let Some(schema) = aggregates.get(&need.lands_in) {
|
||||||
|
buckets.entry(need.lands_in.clone()).or_insert_with(|| {
|
||||||
|
json!({
|
||||||
|
"initial": schema.initial,
|
||||||
|
"transitions": schema.transitions,
|
||||||
|
"desks": desks_over(questions, &need.lands_in).iter().map(|d| json!({
|
||||||
|
"page": d.page,
|
||||||
|
"group": d.group,
|
||||||
|
"moves": d.moves.iter().map(|(from, to, label)| json!({
|
||||||
|
"from": from, "to": to, "label": label,
|
||||||
|
})).collect::<Vec<_>>(),
|
||||||
|
})).collect::<Vec<_>>(),
|
||||||
|
"self_moves": self_moves(questions, &need.lands_in),
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
json!({
|
||||||
|
"needs": needs,
|
||||||
|
"buckets": buckets,
|
||||||
|
"personas": file.personas.iter().map(|p| json!({
|
||||||
|
"id": p.id, "need": p.need, "text": plain(&p.text),
|
||||||
|
})).collect::<Vec<_>>(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// One decision for the answering engine - the shape is laya's
|
/// One decision for the answering engine - the shape is laya's
|
||||||
/// `predict(state, questions)` input, so an adapter is a loop and
|
/// `predict(state, questions)` input, so an adapter is a loop and
|
||||||
/// nothing more; any other engine reads the same fields.
|
/// nothing more; any other engine reads the same fields.
|
||||||
@@ -708,6 +818,36 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn success_must_be_a_finished_state_and_the_sim_model_carries_the_whole_flow() {
|
||||||
|
let (questions, aggregates) = site(FULL_DESK);
|
||||||
|
let mut file = needs(" success: [won]\n");
|
||||||
|
assert!(check(&file, &questions, &aggregates).is_empty());
|
||||||
|
|
||||||
|
let model = sim_model(&file, &questions, &aggregates);
|
||||||
|
let need = &model["needs"][0];
|
||||||
|
assert_eq!(need["success"][0], "won");
|
||||||
|
let step = &need["steps"][0];
|
||||||
|
assert_eq!(step["question"], "What are you here for?");
|
||||||
|
assert_eq!(step["expect"][0], "Hire us");
|
||||||
|
assert_eq!(step["options"].as_array().unwrap().len(), 2);
|
||||||
|
// The form the visitor fills, required and optional alike.
|
||||||
|
let fields = step["fields"].as_array().unwrap();
|
||||||
|
assert_eq!(fields.len(), 3);
|
||||||
|
assert_eq!(fields[2]["optional"], true);
|
||||||
|
// The desk's buttons, labelled, per state.
|
||||||
|
let moves = model["buckets"]["projects"]["desks"][0]["moves"].as_array().unwrap();
|
||||||
|
assert!(moves.iter().any(|m| m["from"] == "quoted" && m["to"] == "won" && m["label"] == "Win"));
|
||||||
|
assert_eq!(model["buckets"]["projects"]["initial"], "open");
|
||||||
|
assert_eq!(model["personas"][0]["id"], "founder");
|
||||||
|
|
||||||
|
file.needs[0].success = vec!["quoted".to_string()];
|
||||||
|
assert_eq!(
|
||||||
|
fails(&check(&file, &questions, &aggregates)),
|
||||||
|
vec!["success names \"quoted\", which is not one of done_when"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn the_wrong_group_holding_the_desk_is_a_failure() {
|
fn the_wrong_group_holding_the_desk_is_a_failure() {
|
||||||
let (questions, aggregates) = site(FULL_DESK);
|
let (questions, aggregates) = site(FULL_DESK);
|
||||||
|
|||||||
Reference in New Issue
Block a user