Fill in only the missing acts when a proposal is part-drafted
The beat generator had one brief, which assumed the proposal was a prose premise and asked for a whole three-act sheet at "roughly four to eight beats per act". Handed a proposal that already had acts beaten out past that, it would regenerate them — compressing the author's own work into a shorter paraphrase, losing the specific detail that made it worth keeping, and burying the one act they actually wanted among two they did not. `mistral::analyse` now reads a proposal's act headings and counts the numbered beats under each, in whatever notation the author used. An act with three or more beats is drafted; fewer, or an act never mentioned, is still to write. Two stray numbered lines are a note to self, not an act. When some acts are drafted and some are not, a second briefing is sent. That briefing asks for the missing acts only, and asks for them under the author's own heading text and depth, so what comes back drops into their document. It drops the per-act cap in favour of matching the density of the drafted acts, and states which acts are context and which to write. The drafted acts are canon: not to be rewritten, reordered, summarised, condensed or reproduced, and — since a reveal often lands in a later act — the model is told to work backwards from those reveals and plant what they need using setups already placed. The model is asked not to reproduce the drafted acts at all, rather than to echo them unchanged. Reproducing twenty-odd beats verbatim is exactly the drift being avoided; not asking makes it impossible. Because the output is then partial, it is labelled: the status line names the acts written, the file is saved as `<proposal> — beats Two.md`, and its title says what it holds, so a partial sheet is not later mistaken for a whole one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011ZGoPiDuZ7vmryNCJWjYSD
This commit is contained in:
@@ -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 `<proposal> — beats Two.md` rather than
|
||||
`<proposal> — 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
|
||||
|
||||
+38
-5
@@ -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::<Vec<_>>()
|
||||
.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(", ")),
|
||||
}
|
||||
}
|
||||
|
||||
+6
-1
@@ -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<std::sync::mpsc::Receiver<Result<String, String>>>,
|
||||
beats_rx: Option<std::sync::mpsc::Receiver<Result<crate::mistral::Beats, String>>>,
|
||||
/// 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<crate::mistral::Act>,
|
||||
/// 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,
|
||||
|
||||
+506
-25
@@ -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<Act> {
|
||||
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<FoundAct>,
|
||||
}
|
||||
|
||||
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> {
|
||||
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> {
|
||||
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<FoundAct> = Vec::new();
|
||||
// Index into `found` of the act currently accumulating beats, if any.
|
||||
let mut current: Option<usize> = 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<String, String> {
|
||||
) -> Result<Beats, String> {
|
||||
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<Act>,
|
||||
}
|
||||
|
||||
/// 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<Act>), 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::<Vec<_>>()
|
||||
.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<Act>) {
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user