use std::path::Path;
use std::sync::Arc;
use anyhow::Result;
use duckdb::types::Value as DuckValue;
use uuid::Uuid;
use crate::storage::engine::StorageEngine;
use crate::world::proposals::now_secs;
use super::types::{EditorCategory, EditorFinding, EditorSeverity};
const INIT_SQL: &str = "
CREATE TABLE IF NOT EXISTS editor_findings (
id TEXT NOT NULL PRIMARY KEY,
paragraph_id TEXT,
chapter_id TEXT,
severity TEXT NOT NULL,
category TEXT NOT NULL,
language TEXT,
observation TEXT NOT NULL,
observation_en TEXT NOT NULL,
conditional INTEGER NOT NULL,
suppressed_by TEXT,
snapshot_id TEXT,
emitted_at BIGINT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_ef_para ON editor_findings(paragraph_id);
-- Same-paragraph cooldown: last Editor engagement and last edit, so an edit
-- during the cooldown window resets the timer (RFC ยง8.4).
CREATE TABLE IF NOT EXISTS editor_cooldown_state (
paragraph_id TEXT NOT NULL PRIMARY KEY,
last_engagement_at BIGINT NOT NULL,
last_edit_at BIGINT NOT NULL
);
-- inner_editor LLM usage, per day per sub-budget (separate tally from
-- Inner Socrates / WORLD-4; surfaces in `inkhaven cost`).
CREATE TABLE IF NOT EXISTS inner_editor_llm_usage (
day TEXT NOT NULL,
sub_budget TEXT NOT NULL,
calls BIGINT NOT NULL,
PRIMARY KEY (day, sub_budget)
);
";
fn text(v: Option<&DuckValue>) -> String {
match v {
Some(DuckValue::Text(s)) => s.clone(),
_ => String::new(),
}
}
fn opt_text(v: Option<&DuckValue>) -> Option<String> {
match v {
Some(DuckValue::Text(s)) if !s.is_empty() => Some(s.clone()),
_ => None,
}
}
fn int(v: Option<&DuckValue>) -> i64 {
match v {
Some(DuckValue::BigInt(i)) => *i,
Some(DuckValue::Int(i)) => *i as i64,
Some(DuckValue::HugeInt(i)) => *i as i64,
_ => 0,
}
}
#[derive(Debug, Clone)]
pub struct StoredEditorFinding {
pub id: Uuid,
pub paragraph_id: Option<Uuid>,
pub finding: EditorFinding,
}
#[derive(Debug, Clone, Copy)]
pub struct CooldownRow {
pub last_engagement_at: i64,
pub last_edit_at: i64,
}
#[derive(Clone)]
pub struct InnerEditorStore {
engine: Arc<StorageEngine>,
}
impl InnerEditorStore {
pub const DAILY_CALL_CAP: i64 = 200;
pub const ENGAGEMENT_SUB_BUDGET: &'static str = "editor_engagement";
pub const CONVERSATION_SUB_BUDGET: &'static str = "conversation";
pub fn open(path: &Path) -> Result<Self> {
Ok(Self { engine: Arc::new(StorageEngine::new(path, INIT_SQL, 2)?) })
}
pub fn open_for_project(project_root: &Path) -> Result<Self> {
Self::open(&project_root.join("inner_editor.db"))
}
pub fn insert_finding(
&self,
f: &EditorFinding,
paragraph_id: Option<Uuid>,
chapter_id: Option<&str>,
language: Option<&str>,
snapshot_id: Option<Uuid>,
) -> Result<Uuid> {
let id = Uuid::new_v4();
self.engine.execute_with(
"INSERT INTO editor_findings \
(id, paragraph_id, chapter_id, severity, category, language, \
observation, observation_en, conditional, suppressed_by, snapshot_id, emitted_at) \
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)",
&[
&id.to_string(),
¶graph_id.map(|p| p.to_string()).unwrap_or_default(),
&chapter_id.unwrap_or_default(),
&f.severity.id(),
&f.category.id(),
&language.unwrap_or_default(),
&f.observation,
&f.observation_en,
&(f.conditional as i64),
&f.suppressed_by.clone().unwrap_or_default(),
&snapshot_id.map(|s| s.to_string()).unwrap_or_default(),
&now_secs(),
],
)?;
Ok(id)
}
pub fn clear_findings_for_paragraph(&self, paragraph_id: Uuid) -> Result<()> {
self.engine.execute_with(
"DELETE FROM editor_findings WHERE paragraph_id = ?",
&[¶graph_id.to_string()],
)
}
pub fn list_findings(&self) -> Result<Vec<StoredEditorFinding>> {
let rows = self.engine.select_all(
"SELECT id, paragraph_id, severity, category, observation, observation_en, \
conditional, suppressed_by \
FROM editor_findings ORDER BY emitted_at DESC, id",
)?;
Ok(rows
.iter()
.filter_map(|r| {
let category = EditorCategory::from_id(&text(r.get(3)))?;
Some(StoredEditorFinding {
id: Uuid::parse_str(&text(r.first())).ok()?,
paragraph_id: opt_text(r.get(1)).and_then(|s| Uuid::parse_str(&s).ok()),
finding: EditorFinding {
category,
severity: EditorSeverity::from_id(&text(r.get(2))),
observation: text(r.get(4)),
observation_en: text(r.get(5)),
evidence: None,
conditional: int(r.get(6)) != 0,
suppressed_by: opt_text(r.get(7)),
},
})
})
.collect())
}
pub fn findings_history(&self, paragraph_id: Uuid) -> Result<Vec<(i64, EditorFinding)>> {
let rows = self.engine.select_all_with(
"SELECT emitted_at, severity, category, observation, observation_en, \
conditional, suppressed_by \
FROM editor_findings WHERE paragraph_id = ? ORDER BY emitted_at ASC, id",
&[¶graph_id.to_string()],
)?;
Ok(rows
.iter()
.filter_map(|r| {
let category = EditorCategory::from_id(&text(r.get(2)))?;
Some((
int(r.first()),
EditorFinding {
category,
severity: EditorSeverity::from_id(&text(r.get(1))),
observation: text(r.get(3)),
observation_en: text(r.get(4)),
evidence: None,
conditional: int(r.get(5)) != 0,
suppressed_by: opt_text(r.get(6)),
},
))
})
.collect())
}
pub fn cooldown(&self, paragraph_id: Uuid) -> Result<Option<CooldownRow>> {
let rows = self.engine.select_all_with(
"SELECT last_engagement_at, last_edit_at FROM editor_cooldown_state \
WHERE paragraph_id = ?",
&[¶graph_id.to_string()],
)?;
Ok(rows.first().map(|r| CooldownRow {
last_engagement_at: int(r.first()),
last_edit_at: int(r.get(1)),
}))
}
pub fn record_engagement(&self, paragraph_id: Uuid, at: i64) -> Result<()> {
self.engine.execute_with(
"INSERT INTO editor_cooldown_state (paragraph_id, last_engagement_at, last_edit_at) \
VALUES (?, ?, 0) \
ON CONFLICT (paragraph_id) DO UPDATE SET last_engagement_at = excluded.last_engagement_at",
&[¶graph_id.to_string(), &at],
)
}
pub fn record_edit(&self, paragraph_id: Uuid, at: i64) -> Result<()> {
self.engine.execute_with(
"INSERT INTO editor_cooldown_state (paragraph_id, last_engagement_at, last_edit_at) \
VALUES (?, 0, ?) \
ON CONFLICT (paragraph_id) DO UPDATE SET last_edit_at = excluded.last_edit_at",
&[¶graph_id.to_string(), &at],
)
}
pub fn record_llm_call(&self, day: &str, sub_budget: &str) -> Result<i64> {
self.engine.execute_with(
"INSERT INTO inner_editor_llm_usage (day, sub_budget, calls) VALUES (?, ?, 1) \
ON CONFLICT (day, sub_budget) DO UPDATE SET calls = calls + 1",
&[&day, &sub_budget],
)?;
self.llm_calls_today(day, sub_budget)
}
pub fn llm_calls_today(&self, day: &str, sub_budget: &str) -> Result<i64> {
let rows = self.engine.select_all_with(
"SELECT calls FROM inner_editor_llm_usage WHERE day = ? AND sub_budget = ?",
&[&day, &sub_budget],
)?;
Ok(rows.first().map(|r| int(r.first())).unwrap_or(0))
}
pub fn llm_usage_today(&self, day: &str) -> Result<Vec<(String, i64)>> {
let rows = self.engine.select_all_with(
"SELECT sub_budget, calls FROM inner_editor_llm_usage WHERE day = ? ORDER BY sub_budget",
&[&day],
)?;
Ok(rows.iter().map(|r| (text(r.get(0)), int(r.get(1)))).collect())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn finding(cat: EditorCategory, sev: EditorSeverity, text: &str) -> EditorFinding {
EditorFinding {
category: cat,
severity: sev,
observation: text.into(),
observation_en: text.into(),
evidence: None,
conditional: true,
suppressed_by: None,
}
}
fn temp_store() -> (InnerEditorStore, tempfile::TempDir) {
let dir = tempfile::tempdir().unwrap();
let store = InnerEditorStore::open_for_project(dir.path()).unwrap();
(store, dir)
}
#[test]
fn findings_roundtrip_and_clear() {
let (store, _d) = temp_store();
let pid = Uuid::new_v4();
store
.insert_finding(
&finding(EditorCategory::StyleObservation, EditorSeverity::Note, "the rhythm shifts"),
Some(pid),
Some("ch07"),
Some("en"),
None,
)
.unwrap();
store
.insert_finding(
&finding(EditorCategory::CraftPraise, EditorSeverity::Praise, "earned cadence"),
Some(pid),
Some("ch07"),
Some("en"),
None,
)
.unwrap();
let all = store.list_findings().unwrap();
assert_eq!(all.len(), 2);
assert!(all.iter().any(|f| f.finding.severity == EditorSeverity::Praise));
let hist = store.findings_history(pid).unwrap();
assert_eq!(hist.len(), 2);
store.clear_findings_for_paragraph(pid).unwrap();
assert!(store.list_findings().unwrap().is_empty());
}
#[test]
fn cooldown_edit_and_engagement_are_independent() {
let (store, _d) = temp_store();
let pid = Uuid::new_v4();
assert!(store.cooldown(pid).unwrap().is_none());
store.record_engagement(pid, 1000).unwrap();
let c = store.cooldown(pid).unwrap().unwrap();
assert_eq!(c.last_engagement_at, 1000);
assert_eq!(c.last_edit_at, 0);
store.record_edit(pid, 1050).unwrap();
let c = store.cooldown(pid).unwrap().unwrap();
assert_eq!(c.last_engagement_at, 1000);
assert_eq!(c.last_edit_at, 1050);
}
#[test]
fn usage_tallies_per_sub_budget() {
let (store, _d) = temp_store();
let day = "2026-06-29";
assert_eq!(store.record_llm_call(day, InnerEditorStore::ENGAGEMENT_SUB_BUDGET).unwrap(), 1);
assert_eq!(store.record_llm_call(day, InnerEditorStore::ENGAGEMENT_SUB_BUDGET).unwrap(), 2);
assert_eq!(store.record_llm_call(day, InnerEditorStore::CONVERSATION_SUB_BUDGET).unwrap(), 1);
let all = store.llm_usage_today(day).unwrap();
assert_eq!(all.len(), 2);
assert_eq!(store.llm_calls_today(day, InnerEditorStore::ENGAGEMENT_SUB_BUDGET).unwrap(), 2);
}
}