Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions crates/atomic-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
};
Expand Down Expand Up @@ -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,
};

Expand Down
2 changes: 2 additions & 0 deletions crates/atomic-core/src/storage/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Vec<AtomWithTags>, AtomicCoreError>
Expand Down
67 changes: 57 additions & 10 deletions crates/atomic-core/src/storage/postgres/wiki.rs
Original file line number Diff line number Diff line change
Expand Up @@ -533,11 +533,18 @@ impl WikiStore for PostgresStorage {
last_update: &str,
max_source_tokens: usize,
) -> StorageResult<Option<(Vec<ChunkWithContext>, 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<String> = 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)
Expand Down Expand Up @@ -626,13 +633,22 @@ impl WikiStore for PostgresStorage {
return Ok(None);
}

let atom_count: Option<i64> =
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<i64> = 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)))
}
Expand Down Expand Up @@ -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<i64> = 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
Expand Down
66 changes: 61 additions & 5 deletions crates/atomic-core/src/storage/sqlite/wiki.rs
Original file line number Diff line number Diff line change
Expand Up @@ -233,12 +233,21 @@ impl SqliteStorage {
) -> StorageResult<Option<(Vec<ChunkWithContext>, 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))
Expand Down Expand Up @@ -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),
)
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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()))?
}
}
7 changes: 7 additions & 0 deletions crates/atomic-core/src/storage/traits.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 ====================
Expand Down
17 changes: 11 additions & 6 deletions crates/atomic-core/src/wiki/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<Vec<_>>()
.join("\n")
};
Expand Down Expand Up @@ -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<String> {
fn extract_current_headings(content: &str) -> Vec<(u8, String)> {
let mut headings = Vec::new();
for line in content.lines() {
let stripped = line.trim_start();
Expand All @@ -507,8 +512,8 @@ fn extract_current_headings(content: &str) -> Vec<String> {
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
Expand Down Expand Up @@ -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.
Expand Down
Loading