From 74809f0386fcc9074fd9e224bfc764261aac4583 Mon Sep 17 00:00:00 2001 From: bk-ty Date: Thu, 30 Apr 2026 10:44:54 -0500 Subject: [PATCH] fix: wiki multi-level headings, tag hierarchy scope, and baseline advance Three related fixes to wiki generation and the update proposal flow: Multi-level heading support (section_ops.rs, wiki/mod.rs): - Parser now recognises h3+ headings as addressable sections instead of swallowing them into the nearest h2 body. heading and after_heading fields in ops can now reference any ## or deeper heading. - extract_current_headings returns (level, text) pairs; the prompt renders sub-headings with indentation so the LLM sees the hierarchy. - apply_section_ops switches to a soft-fail mode per op: if a single op references a hallucinated heading, the rest still apply and the bad op is reported in the returned error list rather than aborting the whole update. - Prompt wording updated: '## headings' -> 'section headings' to avoid confusing models that generate h3+ content. Tag hierarchy scope (sqlite/wiki.rs, postgres/wiki.rs): - get_new_atoms_since and atom_count queries now use a recursive CTE to span the full tag descendant tree, matching the scope used by generation and get_article_status. Previously these queries only looked at atoms directly tagged with the exact tag_id, causing the 'N new atoms' banner to miscount atoms in child tags and fire spuriously. Advance baseline on no-op updates (lib.rs, storage/traits.rs, storage/mod.rs, sqlite/wiki.rs, postgres/wiki.rs): - When generate_wiki_update finds no changes worth proposing, it now calls advance_wiki_baseline to bump atom_count and updated_at to current values. This clears the 'N new atoms' banner and prevents the same atoms from being re-evaluated on every subsequent 'Generate Update' click. - accept_wiki_proposal: use proposal.created_at instead of a fresh Utc::now() for updated_at so the stored timestamp matches the proposal's age rather than the accept time. --- crates/atomic-core/src/lib.rs | 14 +- crates/atomic-core/src/storage/mod.rs | 2 + .../atomic-core/src/storage/postgres/wiki.rs | 67 +++++- crates/atomic-core/src/storage/sqlite/wiki.rs | 66 +++++- crates/atomic-core/src/storage/traits.rs | 7 + crates/atomic-core/src/wiki/mod.rs | 17 +- crates/atomic-core/src/wiki/section_ops.rs | 204 ++++++++++++++---- 7 files changed, 317 insertions(+), 60 deletions(-) diff --git a/crates/atomic-core/src/lib.rs b/crates/atomic-core/src/lib.rs index b9b7cb18..e014402a 100644 --- a/crates/atomic-core/src/lib.rs +++ b/crates/atomic-core/src/lib.rs @@ -1568,7 +1568,16 @@ impl AtomicCore { { Some(d) => d, None => { - tracing::info!(tag_id, "[wiki] No update warranted; no proposal created"); + // The LLM either found no new chunks or evaluated them and decided + // nothing needs to change. Advance the article's atom_count and + // updated_at to the current values so the "N new atoms" banner + // clears and the same atoms are not re-evaluated on every + // subsequent "Generate Update" click. + if let Err(e) = self.storage.advance_wiki_baseline_sync(tag_id).await { + tracing::warn!(tag_id, error = %e, "[wiki] Failed to advance article baseline on no-change"); + } else { + tracing::info!(tag_id, "[wiki] No update warranted; article baseline advanced"); + } return Ok(None); } }; @@ -1640,13 +1649,12 @@ impl AtomicCore { )); } - let now = chrono::Utc::now().to_rfc3339(); let article = WikiArticle { id: existing.article.id.clone(), tag_id: tag_id.to_string(), content: proposal.content.clone(), created_at: existing.article.created_at.clone(), - updated_at: now, + updated_at: proposal.created_at.clone(), atom_count: existing.article.atom_count + proposal.new_atom_count, }; diff --git a/crates/atomic-core/src/storage/mod.rs b/crates/atomic-core/src/storage/mod.rs index 13fcb2db..569a04f6 100644 --- a/crates/atomic-core/src/storage/mod.rs +++ b/crates/atomic-core/src/storage/mod.rs @@ -553,6 +553,8 @@ dispatch! { => sqlite: get_wiki_proposal_sync, pg_trait: WikiStore, pg_method: get_wiki_proposal; fn delete_wiki_proposal_sync(&self, tag_id: &str) -> Result<(), AtomicCoreError> => sqlite: delete_wiki_proposal_sync, pg_trait: WikiStore, pg_method: delete_wiki_proposal; + fn advance_wiki_baseline_sync(&self, tag_id: &str) -> Result<(), AtomicCoreError> + => sqlite: advance_wiki_baseline_sync, pg_trait: WikiStore, pg_method: advance_wiki_baseline; // ---- BriefingStore ---- fn list_new_atoms_since_sync(&self, since: &str, limit: i32) -> Result, AtomicCoreError> diff --git a/crates/atomic-core/src/storage/postgres/wiki.rs b/crates/atomic-core/src/storage/postgres/wiki.rs index 8711a793..91514101 100644 --- a/crates/atomic-core/src/storage/postgres/wiki.rs +++ b/crates/atomic-core/src/storage/postgres/wiki.rs @@ -533,11 +533,18 @@ impl WikiStore for PostgresStorage { last_update: &str, max_source_tokens: usize, ) -> StorageResult, i32)>> { - // Get atoms added after the last update + // Get atoms added after the last update, spanning the full tag hierarchy. let new_atom_ids: Vec = sqlx::query_scalar( - "SELECT DISTINCT a.id FROM atoms a + "WITH RECURSIVE descendant_tags(id) AS ( + SELECT $1 + UNION ALL + SELECT t.id FROM tags t + INNER JOIN descendant_tags dt ON t.parent_id = dt.id + ) + SELECT DISTINCT a.id FROM atoms a INNER JOIN atom_tags at ON a.id = at.atom_id - WHERE at.tag_id = $1 AND a.created_at > $2 AND a.db_id = $3 AND at.db_id = $3", + WHERE at.tag_id IN (SELECT id FROM descendant_tags) + AND a.created_at > $2 AND a.db_id = $3 AND at.db_id = $3", ) .bind(tag_id) .bind(last_update) @@ -626,13 +633,22 @@ impl WikiStore for PostgresStorage { return Ok(None); } - let atom_count: Option = - sqlx::query_scalar("SELECT COUNT(*) FROM atom_tags WHERE tag_id = $1 AND db_id = $2") - .bind(tag_id) - .bind(&self.db_id) - .fetch_one(&self.pool) - .await - .map_err(|e| AtomicCoreError::Wiki(e.to_string()))?; + // Count uses the same descendant CTE as get_article_status. + let atom_count: Option = sqlx::query_scalar( + "WITH RECURSIVE descendant_tags(id) AS ( + SELECT $1 + UNION ALL + SELECT t.id FROM tags t + INNER JOIN descendant_tags dt ON t.parent_id = dt.id + ) + SELECT COUNT(DISTINCT atom_id) FROM atom_tags + WHERE tag_id IN (SELECT id FROM descendant_tags) AND db_id = $2", + ) + .bind(tag_id) + .bind(&self.db_id) + .fetch_one(&self.pool) + .await + .map_err(|e| AtomicCoreError::Wiki(e.to_string()))?; Ok(Some((new_chunks, atom_count.unwrap_or(0) as i32))) } @@ -802,6 +818,37 @@ impl WikiStore for PostgresStorage { .map_err(|e| AtomicCoreError::DatabaseOperation(e.to_string()))?; Ok(()) } + + async fn advance_wiki_baseline(&self, tag_id: &str) -> StorageResult<()> { + // Use the same descendant CTE as get_article_status so the counts agree. + let current_count: Option = sqlx::query_scalar( + "WITH RECURSIVE descendant_tags(id) AS ( + SELECT $1 + UNION ALL + SELECT t.id FROM tags t + INNER JOIN descendant_tags dt ON t.parent_id = dt.id + ) + SELECT COUNT(DISTINCT atom_id) FROM atom_tags + WHERE tag_id IN (SELECT id FROM descendant_tags) AND db_id = $2", + ) + .bind(tag_id) + .bind(&self.db_id) + .fetch_one(&self.pool) + .await + .map_err(|e| AtomicCoreError::Wiki(e.to_string()))?; + let now = chrono::Utc::now().to_rfc3339(); + sqlx::query( + "UPDATE wiki_articles SET atom_count = $1, updated_at = $2 WHERE tag_id = $3 AND db_id = $4", + ) + .bind(current_count.unwrap_or(0) as i32) + .bind(&now) + .bind(tag_id) + .bind(&self.db_id) + .execute(&self.pool) + .await + .map_err(|e| AtomicCoreError::Wiki(e.to_string()))?; + Ok(()) + } } // Private helper methods diff --git a/crates/atomic-core/src/storage/sqlite/wiki.rs b/crates/atomic-core/src/storage/sqlite/wiki.rs index 6759c5e8..9d6b19e0 100644 --- a/crates/atomic-core/src/storage/sqlite/wiki.rs +++ b/crates/atomic-core/src/storage/sqlite/wiki.rs @@ -233,12 +233,21 @@ impl SqliteStorage { ) -> StorageResult, i32)>> { let conn = self.db.read_conn()?; - // Get atoms added after the last update + // Get atoms added after the last update, spanning the full tag hierarchy + // (same scope as generation and get_article_status — prevents "N new atoms" + // banners for atoms in child tags that the LLM can never see as updates). let mut new_atom_stmt = conn .prepare( - "SELECT DISTINCT a.id FROM atoms a + "WITH RECURSIVE descendant_tags(id) AS ( + SELECT ?1 + UNION ALL + SELECT t.id FROM tags t + INNER JOIN descendant_tags dt ON t.parent_id = dt.id + ) + SELECT DISTINCT a.id FROM atoms a INNER JOIN atom_tags at ON a.id = at.atom_id - WHERE at.tag_id = ?1 AND a.created_at > ?2", + WHERE at.tag_id IN (SELECT id FROM descendant_tags) + AND a.created_at > ?2", ) .map_err(|e| { AtomicCoreError::Wiki(format!("Failed to prepare new atoms query: {}", e)) @@ -283,9 +292,18 @@ impl SqliteStorage { return Ok(None); } + // Count uses the same descendant CTE as get_article_status so the + // stored atom_count stays in sync with what the banner reports. let atom_count: i32 = conn .query_row( - "SELECT COUNT(*) FROM atom_tags WHERE tag_id = ?1", + "WITH RECURSIVE descendant_tags(id) AS ( + SELECT ?1 + UNION ALL + SELECT t.id FROM tags t + INNER JOIN descendant_tags dt ON t.parent_id = dt.id + ) + SELECT COUNT(DISTINCT atom_id) FROM atom_tags + WHERE tag_id IN (SELECT id FROM descendant_tags)", [tag_id], |row| row.get(0), ) @@ -413,6 +431,36 @@ impl SqliteStorage { .map_err(|e| AtomicCoreError::Wiki(format!("Failed to delete wiki proposal: {}", e)))?; Ok(()) } + + pub(crate) fn advance_wiki_baseline_sync(&self, tag_id: &str) -> StorageResult<()> { + let conn = self + .db + .conn + .lock() + .map_err(|e| AtomicCoreError::Lock(e.to_string()))?; + // Use the same descendant CTE as get_article_status so the counts agree. + let current_count: i32 = conn + .query_row( + "WITH RECURSIVE descendant_tags(id) AS ( + SELECT ?1 + UNION ALL + SELECT t.id FROM tags t + INNER JOIN descendant_tags dt ON t.parent_id = dt.id + ) + SELECT COUNT(DISTINCT atom_id) FROM atom_tags + WHERE tag_id IN (SELECT id FROM descendant_tags)", + [tag_id], + |row| row.get(0), + ) + .map_err(|e| AtomicCoreError::Wiki(format!("Failed to count atoms for baseline advance: {}", e)))?; + let now = chrono::Utc::now().to_rfc3339(); + conn.execute( + "UPDATE wiki_articles SET atom_count = ?1, updated_at = ?2 WHERE tag_id = ?3", + (current_count, &now, tag_id), + ) + .map_err(|e| AtomicCoreError::Wiki(format!("Failed to advance wiki baseline: {}", e)))?; + Ok(()) + } } #[async_trait] @@ -573,4 +621,12 @@ impl WikiStore for SqliteStorage { .await .map_err(|e| AtomicCoreError::Lock(e.to_string()))? } -} + + async fn advance_wiki_baseline(&self, tag_id: &str) -> StorageResult<()> { + let storage = self.clone(); + let tag_id = tag_id.to_string(); + tokio::task::spawn_blocking(move || storage.advance_wiki_baseline_sync(&tag_id)) + .await + .map_err(|e| AtomicCoreError::Lock(e.to_string()))? + } +} \ No newline at end of file diff --git a/crates/atomic-core/src/storage/traits.rs b/crates/atomic-core/src/storage/traits.rs index 6356fb5a..546fec83 100644 --- a/crates/atomic-core/src/storage/traits.rs +++ b/crates/atomic-core/src/storage/traits.rs @@ -700,6 +700,13 @@ pub trait WikiStore: Send + Sync { /// Delete the pending wiki proposal for a tag (idempotent). async fn delete_wiki_proposal(&self, tag_id: &str) -> StorageResult<()>; + + /// Advance the article baseline without changing content: update `atom_count` + /// to the current tag-hierarchy total and `updated_at` to now. Called when a + /// generate-update pass finds no changes worth proposing so the "N new atoms" + /// banner clears and the same atoms are not re-evaluated on every subsequent + /// "Generate Update" click. + async fn advance_wiki_baseline(&self, tag_id: &str) -> StorageResult<()>; } // ==================== Briefing Storage ==================== diff --git a/crates/atomic-core/src/wiki/mod.rs b/crates/atomic-core/src/wiki/mod.rs index 2d58763e..2b25fc37 100644 --- a/crates/atomic-core/src/wiki/mod.rs +++ b/crates/atomic-core/src/wiki/mod.rs @@ -340,11 +340,16 @@ async fn generate_section_ops_proposal( // Enumerate current section headings for the LLM to reference verbatim. let heading_list = extract_current_headings(&existing.article.content); let headings_block = if heading_list.is_empty() { - "(no ## headings — the article has no sections yet; use InsertSection with after_heading=\"\" to add one at the end)".to_string() + "(no section headings — the article has no sections yet; use InsertSection with after_heading=\"\" to add one at the end)".to_string() } else { heading_list .iter() - .map(|h| format!("- {}", h)) + .map(|(level, h)| { + // Indent sub-headings so the LLM can see the hierarchy. + // Level 2 = no indent; each extra level adds two spaces. + let indent = " ".repeat((*level as usize).saturating_sub(2)); + format!("{}{}", indent, h) + }) .collect::>() .join("\n") }; @@ -498,7 +503,7 @@ async fn generate_section_ops_proposal( /// stay embedded in their parent section's body. Surfacing `###` headings to /// the LLM would let it target a heading the applier can't resolve, which /// discards the entire proposal as a hallucination. -fn extract_current_headings(content: &str) -> Vec { +fn extract_current_headings(content: &str) -> Vec<(u8, String)> { let mut headings = Vec::new(); for line in content.lines() { let stripped = line.trim_start(); @@ -507,8 +512,8 @@ fn extract_current_headings(content: &str) -> Vec { while hashes < bytes.len() && bytes[hashes] == b'#' { hashes += 1; } - if hashes == 2 && hashes < bytes.len() && bytes[hashes] == b' ' { - headings.push(stripped[hashes + 1..].trim().to_string()); + if hashes >= 2 && hashes < bytes.len() && bytes[hashes] == b' ' { + headings.push((hashes as u8, stripped[hashes + 1..].trim().to_string())); } } headings @@ -561,7 +566,7 @@ Operations (value of the `op` field): - "InsertSection": add a brand-new section (use only for genuinely new topics not covered elsewhere). Set `heading` to the new section's heading. Set `after_heading` to the exact existing heading you want to insert AFTER, or leave it empty ("") to append the new section at the end of the article. Set `content` to the new section body. Rules: -- `heading` and `after_heading` values must EXACTLY match one of the headings listed under CURRENT SECTION HEADINGS when they reference existing sections. Do not paraphrase, reword, or change capitalization. Do not include the ## prefix. +- `heading` and `after_heading` values must EXACTLY match one of the headings listed under CURRENT SECTION HEADINGS when they reference existing sections. Do not paraphrase, reword, or change capitalization. Sub-headings appear indented under their parent in the list; use the exact heading text without any # prefix characters. - Prefer AppendToSection over ReplaceSection. Prefer editing an existing section over creating a new one. - Every new factual claim MUST have a [N] citation using the next-available citation numbers shown in the user message. - Keep tone consistent with the existing article. diff --git a/crates/atomic-core/src/wiki/section_ops.rs b/crates/atomic-core/src/wiki/section_ops.rs index 1c59fdfe..5e6609ca 100644 --- a/crates/atomic-core/src/wiki/section_ops.rs +++ b/crates/atomic-core/src/wiki/section_ops.rs @@ -131,6 +131,8 @@ struct Section { pub fn apply_section_ops(existing: &str, ops: &[WikiSectionOp]) -> Result { let (preamble, mut sections) = parse_sections(existing); + let mut errors: Vec = Vec::new(); + let mut fallible_count: usize = 0; for op in ops { match op { WikiSectionOp::NoChange => { @@ -139,54 +141,84 @@ pub fn apply_section_ops(existing: &str, ops: &[WikiSectionOp]) -> Result { - let idx = find_section_idx(§ions, heading).ok_or_else(|| { - format!( - "AppendToSection: heading '{}' not found. Existing headings: [{}]", - heading, - list_headings(§ions) - ) - })?; - append_to_body(&mut sections[idx].body, content); + fallible_count += 1; + match find_section_idx(§ions, heading) { + Some(idx) => append_to_body(&mut sections[idx].body, content), + None => { + let e = format!( + "AppendToSection: heading '{}' not found. Existing headings: [{}]", + heading, + list_headings(§ions) + ); + tracing::warn!(error = %e, "[wiki] Skipping op with unmatched heading"); + errors.push(e); + } + } } WikiSectionOp::ReplaceSection { heading, content } => { - let idx = find_section_idx(§ions, heading).ok_or_else(|| { - format!( - "ReplaceSection: heading '{}' not found. Existing headings: [{}]", - heading, - list_headings(§ions) - ) - })?; - sections[idx].body = ensure_trailing_blank(content); + fallible_count += 1; + match find_section_idx(§ions, heading) { + Some(idx) => sections[idx].body = ensure_trailing_blank(content), + None => { + let e = format!( + "ReplaceSection: heading '{}' not found. Existing headings: [{}]", + heading, + list_headings(§ions) + ); + tracing::warn!(error = %e, "[wiki] Skipping op with unmatched heading"); + errors.push(e); + } + } } WikiSectionOp::InsertSection { after_heading, heading, content, } => { - let new_section = Section { - level: 2, - heading: heading.clone(), - body: ensure_trailing_blank(content), - }; match after_heading { Some(h) => { - let idx = find_section_idx(§ions, h).ok_or_else(|| { - format!( - "InsertSection: after_heading '{}' not found. Existing headings: [{}]", - h, - list_headings(§ions) - ) - })?; - sections.insert(idx + 1, new_section); + fallible_count += 1; + match find_section_idx(§ions, h) { + Some(idx) => { + // Inherit the level of the anchor section so that + // inserting after an H3 produces another H3, not H2. + let level = sections[idx].level; + sections.insert(idx + 1, Section { + level, + heading: heading.clone(), + body: ensure_trailing_blank(content), + }); + } + None => { + let e = format!( + "InsertSection: after_heading '{}' not found. Existing headings: [{}]", + h, + list_headings(§ions) + ); + tracing::warn!(error = %e, "[wiki] Skipping op with unmatched heading"); + errors.push(e); + } + } } None => { - sections.push(new_section); + sections.push(Section { + level: 2, + heading: heading.clone(), + body: ensure_trailing_blank(content), + }); } } } } } + // If every fallible op failed, propagate the first error unchanged (same + // behaviour as before — a proposal with nothing valid should not land). + // If only some failed, accept the partial merge; warnings already logged. + if !errors.is_empty() && errors.len() == fallible_count { + return Err(errors.remove(0)); + } + Ok(serialize_sections(&preamble, §ions)) } @@ -200,7 +232,7 @@ fn parse_sections(content: &str) -> (String, Vec
) { for line in content.split_inclusive('\n') { if let Some((level, heading)) = parse_heading(line) { - if level == 2 { + if level >= 2 { if let Some(sec) = current.take() { sections.push(sec); } @@ -442,13 +474,18 @@ Status body. } #[test] - fn subsection_does_not_split_parent() { - // Details has a ### Subsection — parsing must keep it inside Details. + fn h3_heading_becomes_its_own_section() { + // After the multi-level parse change, ### Subsection is an addressable + // section rather than being swallowed into the ## Details body. let (_, sections) = parse_sections(SAMPLE); let headings: Vec<&str> = sections.iter().map(|s| s.heading.as_str()).collect(); - assert_eq!(headings, vec!["Overview", "Details", "Status"]); + assert_eq!(headings, vec!["Overview", "Details", "Subsection", "Status"]); + let sub = sections.iter().find(|s| s.heading == "Subsection").unwrap(); + assert_eq!(sub.level, 3); + assert!(sub.body.contains("Subsection text.")); + // Details body must NOT include the H3 heading line any more. let details = sections.iter().find(|s| s.heading == "Details").unwrap(); - assert!(details.body.contains("### Subsection")); + assert!(!details.body.contains("### Subsection")); } #[test] @@ -614,4 +651,99 @@ Status body. let roundtrip: Vec = serde_json::from_str(&json).unwrap(); assert_eq!(ops, roundtrip); } -} + + // ── Multi-level heading tests ──────────────────────────────────────────── + + #[test] + fn append_to_h3_section() { + let ops = vec![WikiSectionOp::AppendToSection { + heading: "Subsection".to_string(), + content: "New subsection detail [4].".to_string(), + }]; + let out = apply_section_ops(SAMPLE, &ops).unwrap(); + assert!(out.contains("### Subsection\n\nSubsection text.")); + assert!(out.contains("New subsection detail [4].")); + // Parent H2 section and sibling sections must be byte-identical. + assert!(out.contains("## Details\n\nDetails body.")); + assert!(out.contains("## Overview\n\nOverview body with [1] citation.")); + } + + #[test] + fn replace_h3_section() { + let ops = vec![WikiSectionOp::ReplaceSection { + heading: "Subsection".to_string(), + content: "Replaced subsection [4].".to_string(), + }]; + let out = apply_section_ops(SAMPLE, &ops).unwrap(); + assert!(out.contains("### Subsection\n\nReplaced subsection [4].")); + assert!(!out.contains("Subsection text.")); + } + + #[test] + fn insert_after_h3_inherits_level() { + let ops = vec![WikiSectionOp::InsertSection { + after_heading: Some("Subsection".to_string()), + heading: "Another Sub".to_string(), + content: "More sub content [4].".to_string(), + }]; + let out = apply_section_ops(SAMPLE, &ops).unwrap(); + // Inserted section inherits level 3 from the H3 anchor. + assert!(out.contains("### Another Sub\n\nMore sub content [4].")); + let sub_pos = out.find("### Subsection").unwrap(); + let another_pos = out.find("### Another Sub").unwrap(); + let status_pos = out.find("## Status").unwrap(); + assert!(sub_pos < another_pos); + assert!(another_pos < status_pos); + } + + #[test] + fn insert_after_h2_still_produces_h2() { + let ops = vec![WikiSectionOp::InsertSection { + after_heading: Some("Overview".to_string()), + heading: "Background".to_string(), + content: "Background content [4].".to_string(), + }]; + let out = apply_section_ops(SAMPLE, &ops).unwrap(); + assert!(out.contains("## Background\n\nBackground content [4].")); + } + + // ── Soft-fail tests ────────────────────────────────────────────────────── + + #[test] + fn soft_fail_skips_bad_op_keeps_good_ops() { + // One hallucinated heading + one valid op: the valid op lands, the bad + // one is silently skipped and the function succeeds. + let ops = vec![ + WikiSectionOp::AppendToSection { + heading: "Nonexistent Section".to_string(), + content: "should be dropped".to_string(), + }, + WikiSectionOp::AppendToSection { + heading: "Overview".to_string(), + content: "Valid addition [3].".to_string(), + }, + ]; + let out = apply_section_ops(SAMPLE, &ops).unwrap(); + assert!(out.contains("Valid addition [3].")); + assert!(!out.contains("should be dropped")); + } + + #[test] + fn soft_fail_all_bad_ops_returns_error() { + // When every fallible op has an unmatched heading the call still fails + // — we do not silently return an unchanged article. + let ops = vec![ + WikiSectionOp::AppendToSection { + heading: "Ghost Section".to_string(), + content: "x".to_string(), + }, + WikiSectionOp::ReplaceSection { + heading: "Phantom".to_string(), + content: "y".to_string(), + }, + ]; + let err = apply_section_ops(SAMPLE, &ops).unwrap_err(); + assert!(err.contains("Ghost Section")); + + } +} \ No newline at end of file