1use rusqlite::Transaction;
11
12use crate::Engine;
13use crate::auth::ScopedSpace;
14use crate::error::{Result, SconeError};
15use crate::llm::ExtractedFact;
16
17#[derive(Debug, Default, PartialEq)]
18pub struct DistillReport {
19 pub processed: usize,
20 pub facts_added: usize,
21 pub facts_closed: usize,
22 pub failed: usize,
23}
24
25#[derive(Debug, Default, PartialEq)]
26pub struct ApplyReport {
27 pub added: usize,
28 pub closed: usize,
29 pub deduplicated: usize,
30}
31
32fn canonicalize(name: &str) -> String {
33 name.trim().to_lowercase()
34}
35
36fn resolve_entity(tx: &Transaction, name: &str) -> Result<i64> {
37 let canonical = canonicalize(name);
38 if canonical.is_empty() {
39 return Err(SconeError::InvalidInput("empty entity name".into()));
40 }
41 if let Some(id) = tx
42 .query_row(
43 "SELECT entity_id FROM entity_aliases WHERE alias = ?1",
44 [&canonical],
45 |r| r.get::<_, i64>(0),
46 )
47 .map(Some)
48 .or_else(|e| match e {
49 rusqlite::Error::QueryReturnedNoRows => Ok(None),
50 other => Err(SconeError::Db(other)),
51 })?
52 {
53 return Ok(id);
54 }
55 tx.execute(
56 "INSERT OR IGNORE INTO entities (canonical) VALUES (?1)",
57 [&canonical],
58 )?;
59 Ok(tx.query_row(
60 "SELECT id FROM entities WHERE canonical = ?1",
61 [&canonical],
62 |r| r.get(0),
63 )?)
64}
65
66impl Engine {
67 pub fn distill(&mut self, space: &ScopedSpace, limit: usize) -> Result<DistillReport> {
73 if self.llm.is_none() {
74 return Err(SconeError::Llm(
75 "no LLM configured — semantic lane paused; set [llm] in config.toml or pass --llm (episodic search is unaffected)"
76 .into(),
77 ));
78 }
79 let pending: Vec<(i64, i64, String)> = {
80 let mut stmt = self.conn.prepare(
81 "SELECT q.id, q.episode_id, e.content
82 FROM distill_queue q JOIN episodes e ON e.id = q.episode_id
83 WHERE q.state = 'pending' AND e.space_id = ?1
84 ORDER BY q.id LIMIT ?2",
85 )?;
86 let rows = stmt.query_map(rusqlite::params![space.id(), limit as i64], |r| {
87 Ok((r.get(0)?, r.get(1)?, r.get(2)?))
88 })?;
89 rows.collect::<std::result::Result<Vec<_>, _>>()?
90 };
91 let mut report = DistillReport::default();
92 for (queue_id, episode_id, content) in pending {
93 let extraction = match &self.llm {
94 Some(llm) => llm.extract_facts(&content),
95 None => unreachable!("checked above"),
96 };
97 match extraction {
98 Ok(facts) => {
99 let usable: Vec<_> = facts
103 .into_iter()
104 .filter(|f| {
105 !f.subject.trim().is_empty()
106 && !f.predicate.trim().is_empty()
107 && !f.object.trim().is_empty()
108 })
109 .collect();
110 let applied = match self.apply_facts(space, episode_id, &usable) {
111 Ok(applied) => applied,
112 Err(e) => {
113 self.conn.execute(
114 "UPDATE distill_queue SET attempts = attempts + 1,
115 last_error = ?1,
116 state = CASE WHEN attempts + 1 >= 3
117 THEN 'failed' ELSE 'pending' END
118 WHERE id = ?2",
119 rusqlite::params![e.to_string(), queue_id],
120 )?;
121 report.failed += 1;
122 continue;
123 }
124 };
125 self.conn.execute(
126 "UPDATE distill_queue SET state = 'done', last_error = NULL
127 WHERE id = ?1",
128 [queue_id],
129 )?;
130 report.processed += 1;
131 report.facts_added += applied.added;
132 report.facts_closed += applied.closed;
133 }
134 Err(e) => {
135 self.conn.execute(
136 "UPDATE distill_queue SET attempts = attempts + 1,
137 last_error = ?1,
138 state = CASE WHEN attempts + 1 >= 3
139 THEN 'failed' ELSE 'pending' END
140 WHERE id = ?2",
141 rusqlite::params![e.to_string(), queue_id],
142 )?;
143 report.failed += 1;
144 }
145 }
146 }
147 Ok(report)
148 }
149
150 pub fn add_entity_alias(&mut self, alias: &str, canonical: &str) -> Result<()> {
152 let tx = self.conn.transaction()?;
153 let entity_id = resolve_entity(&tx, canonical)?;
154 tx.execute(
155 "INSERT OR REPLACE INTO entity_aliases (alias, entity_id) VALUES (?1, ?2)",
156 rusqlite::params![canonicalize(alias), entity_id],
157 )?;
158 tx.commit()?;
159 Ok(())
160 }
161
162 pub fn apply_facts(
164 &mut self,
165 space: &ScopedSpace,
166 episode_id: i64,
167 facts: &[ExtractedFact],
168 ) -> Result<ApplyReport> {
169 let mut report = ApplyReport::default();
170 let tx = self.conn.transaction()?;
171 for fact in facts {
172 let subject = resolve_entity(&tx, &fact.subject)?;
173 let predicate = fact.predicate.trim().to_lowercase();
174 let object = fact.object.trim().to_owned();
175 if predicate.is_empty() || object.is_empty() {
176 return Err(SconeError::InvalidInput(
177 "fact predicate/object must be non-empty".into(),
178 ));
179 }
180
181 let existing: Option<i64> = tx
183 .query_row(
184 "SELECT id FROM facts
185 WHERE space_id = ?1 AND subject_entity = ?2 AND predicate = ?3
186 AND object = ?4 AND status = 'active'",
187 rusqlite::params![space.id(), subject, predicate, object],
188 |r| r.get(0),
189 )
190 .map(Some)
191 .or_else(|e| match e {
192 rusqlite::Error::QueryReturnedNoRows => Ok(None),
193 other => Err(SconeError::Db(other)),
194 })?;
195 if let Some(fact_id) = existing {
196 tx.execute(
197 "UPDATE facts SET confidence = max(confidence, ?1) WHERE id = ?2",
198 rusqlite::params![fact.confidence, fact_id],
199 )?;
200 tx.execute(
201 "INSERT OR IGNORE INTO fact_provenance (fact_id, episode_id) VALUES (?1, ?2)",
202 rusqlite::params![fact_id, episode_id],
203 )?;
204 report.deduplicated += 1;
205 continue;
206 }
207
208 tx.execute(
209 "INSERT INTO facts (space_id, subject_entity, predicate, object, confidence)
210 VALUES (?1, ?2, ?3, ?4, ?5)",
211 rusqlite::params![space.id(), subject, predicate, object, fact.confidence],
212 )?;
213 let new_id = tx.last_insert_rowid();
214 tx.execute(
215 "INSERT INTO fact_provenance (fact_id, episode_id) VALUES (?1, ?2)",
216 rusqlite::params![new_id, episode_id],
217 )?;
218 report.added += 1;
219
220 let closed = tx.execute(
223 "UPDATE facts SET status = 'closed',
224 valid_until = strftime('%Y-%m-%dT%H:%M:%fZ','now'),
225 status_reason = 'superseded by fact ' || ?1
226 WHERE space_id = ?2 AND subject_entity = ?3 AND predicate = ?4
227 AND status = 'active' AND id != ?1",
228 rusqlite::params![new_id, space.id(), subject, predicate],
229 )?;
230 report.closed += closed;
231 }
232 tx.commit()?;
233 Ok(report)
234 }
235}
236
237#[derive(Debug)]
239pub struct ProvenanceItem {
240 pub episode_id: i64,
241 pub kind: String,
242 pub source: Option<String>,
243 pub created_at: String,
244}
245
246impl Engine {
247 pub fn facts_list(&self, space: &ScopedSpace, all: bool) -> Result<Vec<crate::FactItem>> {
249 let mut stmt = self.conn.prepare(
250 "SELECT f.id, en.canonical, f.predicate, f.object, f.confidence,
251 f.valid_from, f.valid_until, f.status, f.status_reason
252 FROM facts f JOIN entities en ON en.id = f.subject_entity
253 WHERE f.space_id = ?1 AND (?2 OR f.status = 'active')
254 ORDER BY f.id",
255 )?;
256 let rows = stmt.query_map(rusqlite::params![space.id(), all], |r| {
257 Ok((
258 crate::FactItem {
259 fact_id: r.get(0)?,
260 subject: r.get(1)?,
261 predicate: r.get(2)?,
262 object: r.get(3)?,
263 confidence: r.get(4)?,
264 valid_from: r.get(5)?,
265 valid_until: r.get(6)?,
266 status: r.get(7)?,
267 },
268 r.get::<_, Option<String>>(8)?,
269 ))
270 })?;
271 let mut out = Vec::new();
272 for row in rows {
273 let (mut item, reason) = row?;
274 if let Some(reason) = reason {
276 item.status = format!("{} ({reason})", item.status);
277 }
278 out.push(item);
279 }
280 Ok(out)
281 }
282
283 pub fn facts_why(&self, space: &ScopedSpace, fact_id: i64) -> Result<Vec<ProvenanceItem>> {
285 let mut stmt = self.conn.prepare(
286 "SELECT e.id, e.kind, e.source, e.created_at
287 FROM fact_provenance fp
288 JOIN facts f ON f.id = fp.fact_id
289 JOIN episodes e ON e.id = fp.episode_id
290 WHERE fp.fact_id = ?1 AND f.space_id = ?2
291 ORDER BY e.id",
292 )?;
293 let rows = stmt.query_map(rusqlite::params![fact_id, space.id()], |r| {
294 Ok(ProvenanceItem {
295 episode_id: r.get(0)?,
296 kind: r.get(1)?,
297 source: r.get(2)?,
298 created_at: r.get(3)?,
299 })
300 })?;
301 let out = rows.collect::<std::result::Result<Vec<_>, _>>()?;
302 if out.is_empty() {
303 return Err(SconeError::NotFound(format!(
304 "fact {fact_id} in space {}",
305 space.name()
306 )));
307 }
308 Ok(out)
309 }
310
311 pub fn facts_close(&mut self, space: &ScopedSpace, fact_id: i64, reason: &str) -> Result<()> {
313 let changed = self.conn.execute(
314 "UPDATE facts SET status = 'closed',
315 valid_until = strftime('%Y-%m-%dT%H:%M:%fZ','now'),
316 status_reason = ?1
317 WHERE id = ?2 AND space_id = ?3 AND status = 'active'",
318 rusqlite::params![reason, fact_id, space.id()],
319 )?;
320 if changed == 0 {
321 return Err(SconeError::NotFound(format!(
322 "active fact {fact_id} in space {}",
323 space.name()
324 )));
325 }
326 Ok(())
327 }
328}
329
330impl Engine {
331 pub fn facts_about(&self, space: &ScopedSpace, entity: &str) -> Result<Vec<crate::FactItem>> {
334 let canonical = entity.trim().to_lowercase();
335 let mut stmt = self.conn.prepare(
336 "SELECT f.id, en.canonical, f.predicate, f.object, f.confidence,
337 f.valid_from, f.valid_until, f.status
338 FROM facts f JOIN entities en ON en.id = f.subject_entity
339 WHERE f.space_id = ?1 AND f.status = 'active'
340 AND f.subject_entity IN (
341 SELECT id FROM entities WHERE canonical = ?2
342 UNION
343 SELECT entity_id FROM entity_aliases WHERE alias = ?2)
344 ORDER BY f.confidence DESC, f.id",
345 )?;
346 let rows = stmt.query_map(rusqlite::params![space.id(), canonical], |r| {
347 Ok(crate::FactItem {
348 fact_id: r.get(0)?,
349 subject: r.get(1)?,
350 predicate: r.get(2)?,
351 object: r.get(3)?,
352 confidence: r.get(4)?,
353 valid_from: r.get(5)?,
354 valid_until: r.get(6)?,
355 status: r.get(7)?,
356 })
357 })?;
358 Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
359 }
360}
361
362impl Engine {
363 pub fn decay_facts(&mut self, space: &ScopedSpace, max_idle_days: u32) -> Result<usize> {
369 const DECAY_CONFIDENCE_CEILING: f64 = 0.6;
370 let cutoff = format!("-{max_idle_days} days");
371 let expired = self.conn.execute(
372 "UPDATE facts SET status = 'expired',
373 valid_until = strftime('%Y-%m-%dT%H:%M:%fZ','now'),
374 status_reason = 'decayed: unaccessed for ' || ?1 || '+ days'
375 WHERE space_id = ?2 AND status = 'active'
376 AND confidence < ?3
377 AND access_count = 0
378 AND valid_from < strftime('%Y-%m-%dT%H:%M:%fZ','now', ?4)
379 AND (last_accessed IS NULL
380 OR last_accessed < strftime('%Y-%m-%dT%H:%M:%fZ','now', ?4))",
381 rusqlite::params![max_idle_days, space.id(), DECAY_CONFIDENCE_CEILING, cutoff],
382 )?;
383 Ok(expired)
384 }
385}
386
387impl Engine {
388 pub fn pending_episodes(
391 &self,
392 space: &ScopedSpace,
393 limit: usize,
394 ) -> Result<Vec<(i64, String, String)>> {
395 let mut stmt = self.conn.prepare(
396 "SELECT e.id, substr(e.content, 1, 4000), e.created_at
397 FROM distill_queue q JOIN episodes e ON e.id = q.episode_id
398 WHERE q.state = 'pending' AND e.space_id = ?1
399 ORDER BY q.id LIMIT ?2",
400 )?;
401 let rows = stmt.query_map(
402 rusqlite::params![space.id(), limit.clamp(1, 20) as i64],
403 |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
404 )?;
405 Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
406 }
407
408 pub fn complete_distillation(
412 &mut self,
413 space: &ScopedSpace,
414 episode_id: i64,
415 facts: &[crate::llm::ExtractedFact],
416 ) -> Result<ApplyReport> {
417 let pending: i64 = self.conn.query_row(
418 "SELECT count(*) FROM distill_queue q JOIN episodes e ON e.id = q.episode_id
419 WHERE q.episode_id = ?1 AND e.space_id = ?2",
420 rusqlite::params![episode_id, space.id()],
421 |r| r.get(0),
422 )?;
423 if pending == 0 {
424 return Err(SconeError::NotFound(format!(
425 "episode {episode_id} in space {}",
426 space.name()
427 )));
428 }
429 let usable: Vec<crate::llm::ExtractedFact> = facts
430 .iter()
431 .filter(|f| {
432 !f.subject.trim().is_empty()
433 && !f.predicate.trim().is_empty()
434 && !f.object.trim().is_empty()
435 })
436 .cloned()
437 .collect();
438 let report = self.apply_facts(space, episode_id, &usable)?;
439 self.conn.execute(
440 "UPDATE distill_queue SET state = 'done', last_error = NULL
441 WHERE episode_id = ?1",
442 [episode_id],
443 )?;
444 Ok(report)
445 }
446}