use std::path::PathBuf;
use anyhow::{anyhow, Result};
use uuid::Uuid;
use crate::config::Config;
use crate::inner_socrates::intent::FindingContext;
use crate::inner_socrates::storage::InnerSocratesStore;
use crate::project::ProjectLayout;
use crate::store::hierarchy::Hierarchy;
use crate::store::node::NodeKind;
use crate::store::Store;
use crate::world::proposals::now_secs;
use super::intent_consult::{consult, intent_summary};
use super::parse::parse_findings;
use super::prompt::{build_user_prompt, language_name, resolve_tuning, system_prompt, tuning_block};
use super::storage::InnerEditorStore;
use super::types::EditorFinding;
pub struct EngageInput {
pub project: PathBuf,
pub paragraph_id: Option<Uuid>,
pub chapter_id: Option<String>,
pub prose: String,
pub preceding: Vec<String>,
pub language: String,
pub snapshot_id: Option<Uuid>,
pub system_override: Option<String>,
pub force: bool,
}
pub struct EngageOutcome {
pub findings: Vec<EditorFinding>,
pub suppressed: usize,
pub calls_used: i64,
pub daily_cap: i64,
pub note: Option<String>,
}
impl EngageOutcome {
fn skipped(note: &str) -> Self {
Self { findings: Vec::new(), suppressed: 0, calls_used: 0, daily_cap: 0, note: Some(note.into()) }
}
}
pub fn engage(input: EngageInput) -> Result<EngageOutcome> {
let cfg = Config::load_layered(&ProjectLayout::new(&input.project).config_path())
.map_err(|e| anyhow!("config: {e}"))?;
if !cfg.inner_editor.enabled {
return Ok(EngageOutcome::skipped(
"Inner Editor is disabled (inner_editor.enabled: false)",
));
}
let tuning = resolve_tuning(&cfg.inner_editor.persona);
if tuning.active_categories.is_empty() {
return Ok(EngageOutcome::skipped("no Inner Editor categories are enabled"));
}
if input.prose.trim().is_empty() {
return Ok(EngageOutcome::skipped("paragraph is empty"));
}
crate::dayclock::set_boundary(cfg.goals.day_boundary);
let day = crate::dayclock::today_key();
let lang_code = if input.language.trim().is_empty() {
crate::ai::prompts::iso_from_long(&cfg.language).to_string()
} else {
input.language.trim().to_string()
};
let ie_store = InnerEditorStore::open_for_project(&input.project)
.map_err(|e| anyhow!("inner-editor store: {e}"))?;
let rows = InnerSocratesStore::open_for_project(&input.project)
.ok()
.and_then(|s| s.list_intent_rows_raw().ok())
.unwrap_or_default();
let tblock = tuning_block(&tuning, cfg.genre.as_deref());
let user = build_user_prompt(
&tblock,
&intent_summary(&rows),
&input.preceding,
&input.prose,
language_name(&lang_code),
);
let system = input
.system_override
.clone()
.unwrap_or_else(|| system_prompt(&lang_code).to_string());
let cap = cfg.inner_editor.llm.editor_engagement.max_calls_per_day;
let used = ie_store
.llm_calls_today(&day, InnerEditorStore::ENGAGEMENT_SUB_BUDGET)
.unwrap_or(0);
let mut note = None;
if !input.force && cap > 0 && used >= cap {
note = Some(format!(
"past today's Inner Editor budget ({used}/{cap} calls) — continuing \
(the cap is informative; see `inkhaven cost`)."
));
}
let ai = crate::ai::AiClient::from_config(&cfg.llm)
.map_err(|e| anyhow!("no LLM provider for Inner Editor: {e}"))?;
let (model, _env) = ai
.resolve_provider(&cfg.llm, None)
.map_err(|e| anyhow!("resolving provider: {e}"))?;
let max_attempts = cfg.inner_editor.llm.backoff_max_retries.max(1) as u32;
let mut last_err = String::new();
let mut raw = None;
for attempt in 0..max_attempts {
match crate::ai::stream::collect_blocking(
ai.client.clone(),
model.to_string(),
Some(system.clone()),
user.clone(),
) {
Ok(r) => {
let _ = ie_store.record_llm_call(&day, InnerEditorStore::ENGAGEMENT_SUB_BUDGET);
raw = Some(r);
break;
}
Err(e) => {
last_err = e.to_string();
if attempt + 1 < max_attempts
&& crate::world::fact_check_slow::is_transient(&last_err)
{
std::thread::sleep(crate::world::fact_check_slow::backoff_delay(attempt));
continue;
}
break;
}
}
}
let Some(raw) = raw else {
return Err(anyhow!("LLM error: {last_err}"));
};
let parsed = parse_findings(&raw, &tuning.active_categories);
let ctx = FindingContext {
paragraph_id: input.paragraph_id.map(|p| p.to_string()),
chapter_id: input.chapter_id.clone(),
..Default::default()
};
let (mut kept, suppressed) = consult(parsed, &rows, &ctx);
kept.sort_by(|a, b| b.severity.rank().cmp(&a.severity.rank()));
let max_findings = cfg.inner_editor.engagement.max_findings_per_paragraph;
if max_findings > 0 && kept.len() > max_findings {
kept.truncate(max_findings);
}
if let Some(pid) = input.paragraph_id {
let _ = ie_store.clear_findings_for_paragraph(pid);
for f in &kept {
let _ = ie_store.insert_finding(
f,
Some(pid),
input.chapter_id.as_deref(),
Some(&lang_code),
input.snapshot_id,
);
}
let _ = ie_store.record_engagement(pid, now_secs());
}
Ok(EngageOutcome { findings: kept, suppressed: suppressed.len(), calls_used: used + 1, daily_cap: cap, note })
}
pub struct GatheredContext {
pub prose: String,
pub preceding: Vec<String>,
pub chapter_id: Option<String>,
}
pub fn gather_context(
store: &Store,
hierarchy: &Hierarchy,
paragraph_id: Uuid,
n_preceding: usize,
) -> Option<GatheredContext> {
let paras: Vec<Uuid> = hierarchy
.flatten()
.into_iter()
.filter(|(n, _)| n.kind == NodeKind::Paragraph)
.map(|(n, _)| n.id)
.collect();
let idx = paras.iter().position(|&id| id == paragraph_id)?;
let lo = idx.saturating_sub(n_preceding);
let preceding: Vec<String> = paras[lo..idx]
.iter()
.map(|&id| body(store, id))
.filter(|s| !s.trim().is_empty())
.collect();
let chapter_id = hierarchy.get(paragraph_id).and_then(|node| {
hierarchy
.ancestors(node)
.into_iter()
.find(|a| a.kind == NodeKind::Chapter)
.map(|c| c.id.to_string())
});
Some(GatheredContext { prose: body(store, paragraph_id), preceding, chapter_id })
}
fn body(store: &Store, id: Uuid) -> String {
match store.get_content(id) {
Ok(Some(b)) => String::from_utf8_lossy(&b).into_owned(),
_ => String::new(),
}
}