diff --git a/README.md b/README.md index 392d530..e966627 100644 --- a/README.md +++ b/README.md @@ -328,6 +328,49 @@ using the [Mistral](https://mistral.ai/) chat API. (auto-numbered if that name is taken) and opens it in the editor, or * **⧉ Copy** — copies the beats to the clipboard. +### Filling in an act you haven't written + +If the proposal is **already partly beaten out**, the generator switches to a +different brief: instead of producing a whole sheet, it writes only the acts you +are missing and leaves the ones you have written alone. + +It counts the numbered beats under each act heading it finds (`### Act One:`, +`## Act 2`, `# Act III — Resolution` — the notation doesn't matter). An act with +**three or more** beats is treated as drafted; anything fewer, or an act you +never mention, is treated as still to write. If at least one act is drafted and +at least one is not, you get the fill-the-gaps brief. + +So a proposal whose Act One and Act Three are beaten out in detail and whose +Act Two is a note saying *"this is what I need help with"* sends this: + +``` +It is already partly beaten out: +- Act One: 11 beats already drafted — context only +- Act Two: write this one +- Act Three: 10 beats already drafted — context only + +Write only the missing act(s), under exactly these headings: + ### Act Two + +Do not output the drafted acts. +``` + +Why it matters: the full-sheet brief tells the model to aim for *four to eight +beats per act*, which would **compress** an act you had already written to +eleven. The fill-the-gaps brief drops that cap, tells the model to match the +density of your drafted acts, and treats them as canon — including any reveal +that lands in a later act, which it must work backwards from and plant for. +It also reuses your own heading text and depth, so what comes back drops +straight into your document. + +The result is labelled accordingly: the status line says which acts were +written, the saved file is named ` — beats Two.md` rather than +` — beats.md`, and its title says which acts it contains — so a +partial sheet is never mistaken for a whole one. + +To force a full three-act regeneration anyway, run it against a copy of the +proposal with the act headings removed. + The chosen proposal text is sent to Mistral to generate the beats; nothing else in your workspace is transmitted. The API key is stored in the app's config file in plain text. Because the request uses the pure-Rust `ureq`/rustls stack, the diff --git a/src/app/beats.rs b/src/app/beats.rs index 6d5d412..ccd3f78 100644 --- a/src/app/beats.rs +++ b/src/app/beats.rs @@ -68,8 +68,16 @@ impl App { self.beats_rx = None; match result { Ok(beats) => { - self.beats_status = "Done — review, then save or copy.".to_string(); - self.beats_output = Some(beats); + self.beats_status = match beats.filled.as_slice() { + // A proposal with no beats of its own gets a full sheet. + [] => "Done — review, then save or copy.".to_string(), + acts => format!( + "Done — wrote {}. Your drafted acts were left alone.", + join_names(acts) + ), + }; + self.beats_filled = beats.filled; + self.beats_output = Some(beats.markdown); } Err(e) => { self.beats_status = format!("✖ {e}"); @@ -87,17 +95,31 @@ impl App { .beats_source_stem .clone() .unwrap_or_else(|| "plot".to_string()); - let mut name = format!("{stem} — beats.md"); + let suffix = match self.beats_filled.as_slice() { + [] => "beats".to_string(), + acts => format!( + "beats {}", + acts.iter() + .map(|a| a.name().trim_start_matches("Act ").to_string()) + .collect::>() + .join("+") + ), + }; + let mut name = format!("{stem} — {suffix}.md"); let mut n = 2; while self.path_for(&name).exists() { - name = format!("{stem} — beats-{n}.md"); + name = format!("{stem} — {suffix}-{n}.md"); n += 1; } let path = self.path_for(&name); if let Some(parent) = path.parent() { let _ = std::fs::create_dir_all(parent); } - let contents = format!("# Plot Beats — {stem}\n\n{}\n", beats.trim()); + let heading = match self.beats_filled.as_slice() { + [] => format!("# Plot Beats — {stem}"), + acts => format!("# Plot Beats — {stem} ({})", join_names(acts)), + }; + let contents = format!("{heading}\n\n{}\n", beats.trim()); match std::fs::write(&path, contents) { Ok(_) => { if !self.files.contains(&name) { @@ -258,3 +280,14 @@ impl App { self.show_mistral_settings = now_open; } } + +/// Join act names for a status line or title: "Act Two", "Act Two and Act +/// Three", "Act One, Act Two and Act Three". +fn join_names(acts: &[crate::mistral::Act]) -> String { + let names: Vec<&str> = acts.iter().map(|a| a.name()).collect(); + match names.as_slice() { + [] => String::new(), + [one] => one.to_string(), + [rest @ .., last] => format!("{} and {last}", rest.join(", ")), + } +} diff --git a/src/app/mod.rs b/src/app/mod.rs index 8b6760d..bc6e6d7 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -276,7 +276,11 @@ pub struct App { /// Whether the new-file template settings window is open. show_template_settings: bool, /// In-flight background plot-beat generation, if any. - beats_rx: Option>>, + beats_rx: Option>>, + /// Acts the last generation was asked to fill. Empty means it wrote a whole + /// three-act sheet; non-empty means the proposal was already partly beaten + /// out and only these acts came back. + beats_filled: Vec, /// One-line status for the plot-beat generator. beats_status: String, /// The generated (and user-editable) plot beats; `Some` opens the results @@ -361,6 +365,7 @@ impl App { show_template_settings: false, beats_rx: None, beats_status: String::new(), + beats_filled: Vec::new(), beats_output: None, beats_source_stem: None, show_new_project: false, diff --git a/src/mistral.rs b/src/mistral.rs index 998581b..44eb86d 100644 --- a/src/mistral.rs +++ b/src/mistral.rs @@ -19,9 +19,10 @@ pub const DEFAULT_BASE_URL: &str = "https://api.mistral.ai"; /// Default model: a large model, well suited to long-form structured reasoning. pub const DEFAULT_MODEL: &str = "mistral-large-latest"; -/// Instruction given to the model. It fixes the output shape (three acts, one -/// numbered beat list per act) so the result drops straight into a manuscript. -const SYSTEM_PROMPT: &str = "\ +/// Instruction used when the proposal carries no beats of its own. It fixes the +/// output shape (three acts, one numbered beat list per act) so the result drops +/// straight into a manuscript. +const FULL_SYSTEM_PROMPT: &str = "\ You are a developmental fiction editor who structures novels using the classic \ three-act structure. You will be given a novel proposal and must lay the story \ out as a sequence of concrete plot beats. @@ -53,6 +54,241 @@ climax, the resolution, and a brief denouement (Act III). is needed to connect the beats into a coherent, causal arc. - Aim for roughly four to eight beats per act."; +// ---- Reading what a proposal already contains ------------------------------- + +/// The three acts the generator targets. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Act { + One, + Two, + Three, +} + +impl Act { + /// Every act, in story order. + pub const ALL: [Act; 3] = [Act::One, Act::Two, Act::Three]; + + /// Heading used when the proposal has no heading of its own for this act. + pub fn canonical_heading(self) -> &'static str { + match self { + Act::One => "Act I — Setup", + Act::Two => "Act II — Confrontation", + Act::Three => "Act III — Resolution", + } + } + + /// Short name for status lines and the saved file's title. + pub fn name(self) -> &'static str { + match self { + Act::One => "Act One", + Act::Two => "Act Two", + Act::Three => "Act Three", + } + } + + /// Recognise the label after the word "Act": `One`, `1`, `I`, `i`, … + fn from_label(label: &str) -> Option { + let l = label.trim().trim_end_matches([':', '.', '—', '-']).trim(); + match l.to_ascii_lowercase().as_str() { + "one" | "1" | "i" | "1st" | "first" => Some(Act::One), + "two" | "2" | "ii" | "2nd" | "second" => Some(Act::Two), + "three" | "3" | "iii" | "3rd" | "third" => Some(Act::Three), + _ => None, + } + } +} + +/// One act heading found in a proposal. +struct FoundAct { + act: Act, + /// The heading text exactly as the author wrote it, so generated acts can + /// be given headings that match the surrounding document. + heading: String, + /// `#` count of that heading. + level: usize, + /// Numbered beats counted beneath it. + beats: usize, +} + +/// What a proposal already contains, which decides how the model is briefed. +pub struct Outline { + found: Vec, +} + +impl Outline { + /// An act with fewer numbered beats than this is treated as still to write. + /// Two stray lines are a note to self, not a drafted act. + const DRAFTED_MIN_BEATS: usize = 3; + + /// Acts already beaten out, in story order. + pub fn drafted(&self) -> Vec { + Act::ALL + .into_iter() + .filter(|a| self.beats_in(*a) >= Self::DRAFTED_MIN_BEATS) + .collect() + } + + /// Acts still to write: absent from the proposal, or present but thin. + pub fn to_write(&self) -> Vec { + Act::ALL + .into_iter() + .filter(|a| self.beats_in(*a) < Self::DRAFTED_MIN_BEATS) + .collect() + } + + /// Whether this is a partly finished beat sheet — something is drafted and + /// something is not — which is when filling the gaps beats regenerating. + pub fn is_partial(&self) -> bool { + !self.drafted().is_empty() && !self.to_write().is_empty() + } + + /// Numbered beats found under `act` (0 when the proposal never names it). + fn beats_in(&self, act: Act) -> usize { + self.found + .iter() + .filter(|f| f.act == act) + .map(|f| f.beats) + .max() + .unwrap_or(0) + } + + /// The heading to give a generated act: the author's own wording when they + /// already have a heading for it, otherwise the canonical one. + fn heading_for(&self, act: Act) -> String { + let level = self.heading_level(); + let text = self + .found + .iter() + .find(|f| f.act == act) + .map(|f| f.heading.clone()) + .unwrap_or_else(|| act.canonical_heading().to_string()); + format!("{} {}", "#".repeat(level), text) + } + + /// Heading depth the proposal uses for acts, defaulting to `##`. + fn heading_level(&self) -> usize { + self.found.first().map(|f| f.level).unwrap_or(2) + } +} + +/// Read a proposal's act headings and count the numbered beats under each. +pub fn analyse(proposal: &str) -> Outline { + let mut found: Vec = Vec::new(); + // Index into `found` of the act currently accumulating beats, if any. + let mut current: Option = None; + + for line in proposal.lines() { + if let Some((level, text)) = heading(line) { + match act_heading(text) { + Some((act, heading_text)) => { + found.push(FoundAct { + act, + heading: heading_text, + level, + beats: 0, + }); + current = Some(found.len() - 1); + } + // A heading at or above the acts' own depth ends the open act, + // so numbered lines in a later section are not counted as its + // beats. A deeper heading is a subsection and does not. + None => { + if current.is_some_and(|i| level <= found[i].level) { + current = None; + } + } + } + continue; + } + if is_numbered_beat(line) { + if let Some(i) = current { + found[i].beats += 1; + } + } + } + Outline { found } +} + +/// Split a Markdown ATX heading into its depth and its text. +fn heading(line: &str) -> Option<(usize, &str)> { + let trimmed = line.trim_start(); + let hashes = trimmed.chars().take_while(|c| *c == '#').count(); + if hashes == 0 || hashes > 6 { + return None; + } + Some((hashes, trimmed[hashes..].trim())) +} + +/// If a heading names an act, return which one and the heading's own text. +fn act_heading(text: &str) -> Option<(Act, String)> { + let cleaned = text.trim(); + let mut words = cleaned.split_whitespace(); + if !words.next()?.eq_ignore_ascii_case("act") { + return None; + } + let act = Act::from_label(words.next()?)?; + Some((act, cleaned.trim_end_matches(':').trim().to_string())) +} + +/// Whether a line opens a numbered list item (`1.` or `1)`). +fn is_numbered_beat(line: &str) -> bool { + let trimmed = line.trim_start(); + let digits = trimmed.chars().take_while(|c| c.is_ascii_digit()).count(); + if digits == 0 { + return false; + } + let rest = &trimmed[digits..]; + let mut chars = rest.chars(); + matches!(chars.next(), Some('.') | Some(')')) && matches!(chars.next(), Some(c) if c.is_whitespace()) +} + +/// Instruction used when the proposal is already partly beaten out. The point +/// of a separate prompt is that the full-draft one above caps each act at four +/// to eight beats, which would *compress* acts the author has already written +/// past that. Here the drafted acts are context the model must not touch. +const GAPS_SYSTEM_PROMPT: &str = "\ +You are a developmental fiction editor who structures novels using the classic \ +three-act structure. You will be given a novel proposal that has already been \ +partly beaten out: some acts carry a numbered list of plot beats, while others \ +are empty, a stub, or a note about what the author still needs. + +Your job is to write only the acts you are told are missing. + +First, read the whole proposal and note every attribute it provides. These may \ +include, but are not limited to: the title, setting, genre, tone, characters, \ +target age (e.g. adult, new adult, young adult, middle grade), and a loose plot \ +summary. Honour all of them: +- Follow the conventions and expected structure of the stated genre. +- Keep the emotional register consistent with the stated tone. +- Make the content, stakes, and complexity appropriate to the stated target age. +- Ground the setting, and use the named characters, throughout the beats. +If an attribute is absent, infer a sensible choice from the rest of the proposal \ +rather than contradicting what is given. + +The acts that are already drafted are canon. They are context, not raw material: +- Do not rewrite, reorder, summarise, condense or reproduce them. +- Everything you write must be consistent with them, including any reveal that \ +lands in a later act. Work backwards from those reveals and plant what they \ +need, using the setups the author has already placed. +- Where a drafted act makes a character's knowledge, location or state explicit \ +at a point in time, do not contradict it. +- Match the drafted acts in voice, in specificity, and in how much happens per \ +beat. + +Output requirements: +- Return GitHub-flavoured Markdown only. No preamble, commentary, or closing \ +remarks — begin with the first heading. +- Write only the acts you are asked for, each under the exact heading given to \ +you, in story order. +- Under each heading, give a numbered list of plot beats starting at 1. Each \ +beat is one or two sentences describing what concretely happens, naming \ +characters from the proposal where relevant. +- Give each act enough beats to match the density of the drafted acts, rather \ +than a fixed number. If the drafted acts run to ten or eleven beats, yours \ +should too. +- Invent only what is needed to carry the story from the end of the preceding \ +drafted material to the start of the following drafted material."; + // ---- Wire format ----------------------------------------------------------- #[derive(Serialize)] @@ -89,14 +325,17 @@ struct RespMessage { /// /// `api_key` is your Mistral API key (sent as a bearer token); `model` is the /// model id (e.g. `mistral-large-latest`); `base_url` is the API root -/// (`https://api.mistral.ai`). Returns the model's Markdown beats, or a -/// human-readable error message. +/// (`https://api.mistral.ai`). Returns the model's Markdown beats together with +/// the acts it was asked to write, or a human-readable error message. +/// +/// A proposal that already has some acts beaten out gets the fill-the-gaps +/// briefing, which leaves the drafted acts alone; see [`build_body`]. pub fn generate_beats( api_key: &str, model: &str, base_url: &str, proposal: &str, -) -> Result { +) -> Result { let key = api_key.trim(); if key.is_empty() { return Err("No Mistral API key set (open Settings ▸ Mistral)".to_string()); @@ -109,24 +348,7 @@ pub fn generate_beats( }; let url = format!("{base}/v1/chat/completions"); - // Frame the proposal so the model reads it as source material, not as new - // instructions. - let user = format!( - "Here is the novel proposal. It may state the title, setting, genre, \ - tone, characters, target age, and a loose plot summary. Read all of it \ - and produce the plot beats.\n\n---\n\n{}", - proposal.trim() - ); - let request = ChatRequest { - model, - messages: vec![ - ChatMessage { role: "system", content: SYSTEM_PROMPT }, - ChatMessage { role: "user", content: &user }, - ], - temperature: 0.6, - }; - let body = - serde_json::to_string(&request).map_err(|e| format!("Could not build request: {e}"))?; + let (body, filled) = build_body(model, proposal)?; let agent = ureq::AgentBuilder::new() .timeout(Duration::from_secs(180)) @@ -143,7 +365,79 @@ pub fn generate_beats( let text = resp .into_string() .map_err(|e| format!("Could not read Mistral reply: {e}"))?; - parse_content(&text) + Ok(Beats { + markdown: parse_content(&text)?, + filled, + }) +} + +/// The Markdown beats a run produced, and what it was asked to write. +#[derive(Debug)] +pub struct Beats { + /// The model's Markdown. + pub markdown: String, + /// Acts the model was asked to fill in. Empty when it wrote a whole sheet + /// from a proposal that had no beats of its own. + pub filled: Vec, +} + +/// Build the chat-completions request body for a proposal, choosing between the +/// full-draft and fill-the-gaps briefings. Returns the body and the acts asked +/// for, so the caller can say which they got. +fn build_body(model: &str, proposal: &str) -> Result<(String, Vec), String> { + let proposal = proposal.trim(); + let outline = analyse(proposal); + let partial = outline.is_partial(); + + // Frame the proposal so the model reads it as source material, not as new + // instructions. + let (system, user, filled) = if partial { + let to_write = outline.to_write(); + let mut inventory = String::new(); + for act in Act::ALL { + let beats = outline.beats_in(act); + let state = if to_write.contains(&act) { + "write this one".to_string() + } else { + format!("{beats} beats already drafted — context only") + }; + inventory.push_str(&format!("- {}: {state}\n", act.name())); + } + let headings = to_write + .iter() + .map(|a| format!(" {}", outline.heading_for(*a))) + .collect::>() + .join("\n"); + let user = format!( + "Here is the novel proposal. It may state the title, setting, genre, \ + tone, characters, target age, and a loose plot summary. Read all of \ + it.\n\n\ + It is already partly beaten out:\n{inventory}\n\ + Write only the missing act(s), under exactly these headings:\n\ + {headings}\n\n\ + Do not output the drafted acts.\n\n---\n\n{proposal}" + ); + (GAPS_SYSTEM_PROMPT, user, to_write) + } else { + let user = format!( + "Here is the novel proposal. It may state the title, setting, genre, \ + tone, characters, target age, and a loose plot summary. Read all of it \ + and produce the plot beats.\n\n---\n\n{proposal}" + ); + (FULL_SYSTEM_PROMPT, user, Vec::new()) + }; + + let request = ChatRequest { + model, + messages: vec![ + ChatMessage { role: "system", content: system }, + ChatMessage { role: "user", content: &user }, + ], + temperature: 0.6, + }; + let body = + serde_json::to_string(&request).map_err(|e| format!("Could not build request: {e}"))?; + Ok((body, filled)) } /// Extract the assistant message content from a chat-completions JSON reply. @@ -264,4 +558,191 @@ mod tests { ); assert_eq!(extract_api_message(r#"{"unrelated": 1}"#), None); } + + /// A proposal shaped like the one this was built for: two acts beaten out + /// in detail, the middle one left as a note. + const PARTIAL: &str = "\ +## Title : The Winter Gate +## Genre : Horror +## Loose Plot : +### Act One: +1. Ada lectures. +2. Ada visits her mother. +3. The double arrives. +4. The double reveals herself. + +### Act Two +This is what I need help with. People start dying. + +### Act Three +1. The mother is dead. +2. The double kills the father. +3. The thing in the ice arrives. +"; + + #[test] + fn counts_the_beats_under_each_act() { + let o = analyse(PARTIAL); + assert_eq!(o.beats_in(Act::One), 4); + assert_eq!(o.beats_in(Act::Two), 0); + assert_eq!(o.beats_in(Act::Three), 3); + } + + #[test] + fn a_partly_drafted_proposal_is_recognised() { + let o = analyse(PARTIAL); + assert!(o.is_partial()); + assert_eq!(o.drafted(), vec![Act::One, Act::Three]); + assert_eq!(o.to_write(), vec![Act::Two]); + } + + #[test] + fn a_proposal_with_no_beats_asks_for_the_whole_sheet() { + let plain = "## Title : A Book\n\nA woman finds a door in the ice.\n"; + let o = analyse(plain); + assert!(!o.is_partial(), "nothing is drafted, so there is no gap to fill"); + assert!(o.drafted().is_empty()); + } + + /// Only Act One written: the acts that are missing entirely still count as + /// work to do, even though the proposal never names them. + #[test] + fn absent_acts_count_as_missing() { + let only_first = "### Act One\n1. a\n2. b\n3. c\n4. d\n"; + let o = analyse(only_first); + assert!(o.is_partial()); + assert_eq!(o.drafted(), vec![Act::One]); + assert_eq!(o.to_write(), vec![Act::Two, Act::Three]); + } + + /// A couple of stray numbered lines is a note to self, not a drafted act. + #[test] + fn a_thin_act_is_treated_as_still_to_write() { + let thin = "### Act One\n1. a\n2. b\n3. c\n\n### Act Two\n1. maybe this\n"; + let o = analyse(thin); + assert_eq!(o.to_write(), vec![Act::Two, Act::Three]); + } + + #[test] + fn act_labels_are_read_in_several_notations() { + for (text, want) in [ + ("### Act One:", Act::One), + ("## Act 2", Act::Two), + ("# Act III — Resolution", Act::Three), + ("### act two", Act::Two), + ("## Act 1st", Act::One), + ] { + let body = format!("{text}\n1. a\n2. b\n3. c\n"); + let o = analyse(&body); + assert!( + o.drafted().contains(&want), + "{text} should read as {want:?}, got {:?}", + o.drafted() + ); + } + } + + #[test] + fn a_heading_that_is_not_an_act_is_ignored() { + let o = analyse("## Characters\n1. Ada\n2. Bram\n3. Kayla\n4. Emma\n"); + assert!(o.drafted().is_empty(), "a character list is not a beat list"); + } + + /// Numbered lines after the acts end must not be credited to the last act. + #[test] + fn a_later_section_does_not_inflate_the_previous_act() { + let body = "\ +## Loose Plot : +### Act One +1. a +2. b +3. c + +## Notes +1. remember the dog +2. and the car +3. and the rain +"; + let o = analyse(body); + assert_eq!(o.beats_in(Act::One), 3, "the Notes list is not Act One's"); + } + + /// A deeper heading is a subsection of the act, so its beats still count. + #[test] + fn a_subsection_keeps_feeding_its_act() { + let body = "### Act One\n1. a\n\n#### Scene detail\n2. b\n3. c\n"; + let o = analyse(body); + assert_eq!(o.beats_in(Act::One), 3); + } + + #[test] + fn both_bullet_forms_count_as_beats() { + assert!(is_numbered_beat("1. a")); + assert!(is_numbered_beat("10) a")); + assert!(is_numbered_beat(" 3. indented")); + assert!(!is_numbered_beat("- a")); + assert!(!is_numbered_beat("1.no space")); + assert!(!is_numbered_beat("1")); + assert!(!is_numbered_beat("word 1. a")); + } + + // ---- Which briefing gets sent ------------------------------------------ + + fn body_of(proposal: &str) -> (serde_json::Value, Vec) { + let (body, filled) = build_body(DEFAULT_MODEL, proposal).unwrap(); + (serde_json::from_str(&body).unwrap(), filled) + } + + #[test] + fn a_partly_drafted_proposal_gets_the_fill_the_gaps_briefing() { + let (v, filled) = body_of(PARTIAL); + assert_eq!(filled, vec![Act::Two]); + let system = v["messages"][0]["content"].as_str().unwrap(); + assert!(system.contains("only the acts you are told are missing")); + // The cap that would compress the author's drafted acts must be absent. + assert!(!system.contains("four to eight beats per act")); + assert!(system.contains("Do not rewrite, reorder, summarise, condense or reproduce them")); + } + + #[test] + fn the_gaps_briefing_names_the_state_of_every_act() { + let (v, _) = body_of(PARTIAL); + let user = v["messages"][1]["content"].as_str().unwrap(); + assert!(user.contains("- Act One: 4 beats already drafted — context only")); + assert!(user.contains("- Act Two: write this one")); + assert!(user.contains("- Act Three: 3 beats already drafted — context only")); + assert!(user.contains("Do not output the drafted acts.")); + // And it asks for the author's own heading, at their own depth. + assert!(user.contains("### Act Two"), "got: {user}"); + } + + #[test] + fn a_generated_act_borrows_the_canonical_heading_when_absent() { + let only_first = "### Act One\n1. a\n2. b\n3. c\n"; + let (v, filled) = body_of(only_first); + assert_eq!(filled, vec![Act::Two, Act::Three]); + let user = v["messages"][1]["content"].as_str().unwrap(); + assert!(user.contains("### Act II — Confrontation"), "got: {user}"); + assert!(user.contains("### Act III — Resolution"), "got: {user}"); + } + + #[test] + fn a_plain_proposal_still_gets_the_full_draft_briefing() { + let (v, filled) = body_of("## Title : A Book\n\nA woman finds a door.\n"); + assert!(filled.is_empty()); + let system = v["messages"][0]["content"].as_str().unwrap(); + assert!(system.contains("four to eight beats per act")); + assert!(!system.contains("already been partly beaten out")); + } + + #[test] + fn the_proposal_is_sent_whole_under_both_briefings() { + for proposal in [PARTIAL, "## Title : A Book\n\nA woman finds a door.\n"] { + let (v, _) = body_of(proposal); + let user = v["messages"][1]["content"].as_str().unwrap(); + assert!(user.contains(proposal.trim()), "proposal must be sent verbatim"); + assert!(user.contains("\n---\n"), "and kept behind the separator"); + } + } + }