plan: a spectator role goes back to the model once; bare-word movers and unlabelled groups parse
Re-planning the contractor showed three wasted rounds: `moves_by: {
checking: submitter }` written as a bare word, a group without a
label, and the two featured owner roles left with nothing to play,
which the builder only warned about after the fact. The bare word and
the missing label now parse (a label defaults to the id), and a plan
that parses with spectator roles is sent back once with the finding;
on the contractor, round three fixed both.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
6e49694211
commit
4d22f4cb44
+27
-6
@@ -112,10 +112,30 @@ fn english() -> String {
|
||||
"en".to_string()
|
||||
}
|
||||
|
||||
/// `moves_by: { checking: submitter }` is what a model writes for a
|
||||
/// single mover, and means the same as `[submitter]`; a bare word or
|
||||
/// a list are both read.
|
||||
fn one_or_many_by_key<'de, D: serde::Deserializer<'de>>(d: D) -> Result<BTreeMap<String, Vec<String>>, D::Error> {
|
||||
#[derive(Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum OneOrMany {
|
||||
One(String),
|
||||
Many(Vec<String>),
|
||||
}
|
||||
let raw: BTreeMap<String, OneOrMany> = BTreeMap::deserialize(d)?;
|
||||
Ok(raw
|
||||
.into_iter()
|
||||
.map(|(k, v)| (k, match v { OneOrMany::One(s) => vec![s], OneOrMany::Many(l) => l }))
|
||||
.collect())
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct GroupDef {
|
||||
pub id: String,
|
||||
/// The name the public knows the group by; empty means the id
|
||||
/// made readable.
|
||||
#[serde(default)]
|
||||
pub label: String,
|
||||
/// Groups that may invite people into this one, besides itself and
|
||||
/// the plan's `invites_by`.
|
||||
@@ -183,6 +203,7 @@ pub struct PlanNeed {
|
||||
/// how roles overlap on one aggregate: a project manager prices a
|
||||
/// change, the client approves it, a site manager completes it.
|
||||
#[serde(default)]
|
||||
#[serde(deserialize_with = "one_or_many_by_key")]
|
||||
pub moves_by: BTreeMap<String, Vec<String>>,
|
||||
/// Groups that see these cases on their desk and move nothing.
|
||||
#[serde(default)]
|
||||
@@ -306,8 +327,8 @@ impl Plan {
|
||||
}
|
||||
let mut labelled = BTreeSet::new();
|
||||
for g in &self.groups {
|
||||
if !is_id(&g.id) || !labelled.insert(&g.id) || g.label.trim().is_empty() {
|
||||
bail!("group {:?}: id must be unique ASCII lowercase with - or _, and it needs a label", g.id);
|
||||
if !is_id(&g.id) || !labelled.insert(&g.id) {
|
||||
bail!("group {:?}: id must be unique ASCII lowercase with - or _", g.id);
|
||||
}
|
||||
}
|
||||
for g in self.invites_by.iter().chain(self.groups.iter().flat_map(|g| g.invited_by.iter())) {
|
||||
@@ -938,7 +959,7 @@ fn inviters(plan: &Plan, group: &str) -> BTreeSet<String> {
|
||||
}
|
||||
|
||||
fn label_of(plan: &Plan, group: &str) -> String {
|
||||
plan.groups.iter().find(|g| g.id == group).map(|g| g.label.clone()).unwrap_or_else(|| humanize(group))
|
||||
plan.groups.iter().find(|g| g.id == group).map(|g| g.label.trim().to_string()).filter(|l| !l.is_empty()).unwrap_or_else(|| humanize(group))
|
||||
}
|
||||
|
||||
/// Buckets the public may read, with the needs that fill them.
|
||||
@@ -951,10 +972,10 @@ fn public_view_path(bucket: &str) -> String {
|
||||
}
|
||||
|
||||
/// The options of a select: its own list, or the plan's labelled groups.
|
||||
fn select_options<'a>(plan: &'a Plan, field: &'a Field) -> Vec<&'a str> {
|
||||
fn select_options(plan: &Plan, field: &Field) -> Vec<String> {
|
||||
match field.options_from.as_deref() {
|
||||
Some("groups") => plan.groups.iter().map(|g| g.label.as_str()).collect(),
|
||||
_ => field.options.iter().map(String::as_str).collect(),
|
||||
Some("groups") => plan.groups.iter().map(|g| label_of(plan, &g.id)).collect(),
|
||||
_ => field.options.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+12
-1
@@ -50,7 +50,7 @@ doors: # a first question before the forms, only when the
|
||||
who: who comes through it # ten offers on one page; six offers on one page beat doors)
|
||||
|
||||
groups: # groups with a label the public may see: the committees
|
||||
- { id: traffic, label: Traffic, invited_by: [board] } # a person can ask to join (optional)
|
||||
- { id: traffic, label: Traffic, invited_by: [board] } # a person can ask to join (optional; label defaults to the id)
|
||||
invites_by: [board] # groups whose desk may invite people into every desk
|
||||
|
||||
needs:
|
||||
@@ -163,6 +163,10 @@ pub fn run(argv: &[String]) -> anyhow::Result<()> {
|
||||
std::fs::create_dir_all(out.join("llm"))?;
|
||||
|
||||
let mut feedback = String::new();
|
||||
// A structural finding the builder would only warn about (a role
|
||||
// the opener speaks for with nothing to play) goes back to the
|
||||
// model once; a second time it is the reader's, not another round.
|
||||
let mut spectators_sent = false;
|
||||
for round in 1..=rounds {
|
||||
let prompt = format!(
|
||||
"{REFERENCE}\n\n# Task\n\nA business filled in this intake. Write its plan.yaml: every need the intake implies, for the public and for each role inside the business, with the stages read off how the business says things progress, the form each need collects, and how much each person will tolerate.\n\n## Intake\n\n{intake}\n{feedback}\nReply with the YAML only, no prose, no code fences."
|
||||
@@ -172,6 +176,13 @@ pub fn run(argv: &[String]) -> anyhow::Result<()> {
|
||||
std::fs::write(out.join(format!("llm/{round:02}-reply.md")), &reply)?;
|
||||
let yaml = extract_yaml(&reply);
|
||||
match check(&yaml) {
|
||||
Ok(plan) if !plan.spectators().is_empty() && !spectators_sent && round < rounds => {
|
||||
spectators_sent = true;
|
||||
let findings = plan.spectators().join("\n- ");
|
||||
eprintln!("plan round {round}: the plan parses, and a role the opener speaks for has no move after sending; asking once more");
|
||||
feedback = format!("\n## Your last attempt parsed, with one thing to fix\n\n- {findings}\n\nGive each such need a move the person makes themselves (`moves_by: {{<stage>: [submitter, <group>]}}`, with a required `email` in its form) or `watched_by: [public]`, and return the whole plan again.\n\n```yaml\n{yaml}\n```\n");
|
||||
continue;
|
||||
}
|
||||
Ok(plan) => {
|
||||
let mut text = yaml.trim_end().to_string();
|
||||
text.push('\n');
|
||||
|
||||
Reference in New Issue
Block a user