Skip to main content

recall_echo/graph/
ingest.rs

1//! Ingestion orchestrator — chunk → episode → extract → dedup → relationships.
2
3use std::collections::HashMap;
4
5use futures::stream::{self, StreamExt};
6
7use super::confidence::{ExtractionContext, Provenance};
8use super::crud;
9use super::dedup::{self, ResolvedEntity};
10use super::error::GraphError;
11use super::extract;
12use super::llm::LlmProvider;
13use super::types::*;
14use super::utility;
15use super::GraphMemory;
16
17/// Maximum number of concurrent LLM calls during extraction and dedup.
18const LLM_CONCURRENCY: usize = 10;
19
20/// Role headings written by the archive pipeline, lower-cased.
21const USER_TURN_HEADING: &str = "### user";
22const ASSISTANT_TURN_HEADING: &str = "### assistant";
23
24/// How one ingestion run assigns a provenance class to what it writes.
25#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
26pub enum ProvenancePolicy {
27    /// Read the class off each chunk's conversation turn roles. Text with no
28    /// visible human-only turn is treated as the agent's own.
29    #[default]
30    FromTurnRoles,
31    /// Stamp every episode in the run with one class — document ingestion
32    /// (`--external`), and any caller that knows better than the heuristic.
33    Fixed(Provenance),
34}
35
36impl ProvenancePolicy {
37    /// The class this policy assigns to one chunk of archive text.
38    #[must_use]
39    pub fn classify(self, chunk: &str) -> Provenance {
40        match self {
41            Self::Fixed(provenance) => provenance,
42            Self::FromTurnRoles => infer_from_turn_roles(chunk),
43        }
44    }
45}
46
47/// Where a run of ingestion is reading from, and what that makes its output.
48///
49/// Carried as one value rather than three parameters because every write the
50/// run performs — episodes and confidence updates alike — must agree on it.
51#[derive(Debug, Clone)]
52pub struct IngestContext {
53    session_id: String,
54    log_number: Option<u32>,
55    provenance: ProvenancePolicy,
56}
57
58impl IngestContext {
59    /// Context for a conversation archive: provenance is read off turn roles.
60    #[must_use]
61    pub fn new(session_id: impl Into<String>, log_number: Option<u32>) -> Self {
62        Self {
63            session_id: session_id.into(),
64            log_number,
65            provenance: ProvenancePolicy::default(),
66        }
67    }
68
69    /// Override the class assignment for the whole run.
70    #[must_use]
71    pub fn with_provenance(mut self, provenance: ProvenancePolicy) -> Self {
72        self.provenance = provenance;
73        self
74    }
75
76    /// Force every episode in the run to one class, or infer per chunk when
77    /// `provenance` is `None`. The shape a CLI `--external` flag arrives in.
78    #[must_use]
79    pub fn with_override(self, provenance: Option<Provenance>) -> Self {
80        match provenance {
81            Some(class) => self.with_provenance(ProvenancePolicy::Fixed(class)),
82            None => self,
83        }
84    }
85
86    /// Session this text belongs to.
87    #[must_use]
88    pub fn session_id(&self) -> &str {
89        &self.session_id
90    }
91
92    /// Archive log number, when the text came from a numbered archive.
93    #[must_use]
94    pub fn log_number(&self) -> Option<u32> {
95        self.log_number
96    }
97}
98
99/// Infer authorship from the role headings the archive pipeline writes.
100///
101/// A chunk is credited to the human only when every role heading in it is a
102/// user turn. Anything else — mixed turns, assistant turns, or text with no
103/// headings at all (pipeline documents, summaries) — is the agent's own, per
104/// the conservative default: never over-credit.
105fn infer_from_turn_roles(chunk: &str) -> Provenance {
106    let mut saw_user = false;
107    for line in chunk.lines() {
108        let heading = line.trim().to_lowercase();
109        if heading == ASSISTANT_TURN_HEADING {
110            return Provenance::SelfGenerated;
111        }
112        if heading == USER_TURN_HEADING {
113            saw_user = true;
114        }
115    }
116
117    if saw_user {
118        Provenance::User
119    } else {
120        Provenance::SelfGenerated
121    }
122}
123
124/// Ingest a conversation archive into the knowledge graph.
125///
126/// Flow:
127/// 1. Chunk the conversation text
128/// 2. Create an Episode for each chunk, stamped with its provenance (always,
129///    even without LLM)
130/// 3. If LLM provided: extract entities/relationships, dedup, store
131/// 4. Return a report of what was created/merged/skipped
132pub async fn ingest_archive(
133    gm: &GraphMemory,
134    archive_text: &str,
135    context: &IngestContext,
136    llm: Option<&dyn LlmProvider>,
137) -> Result<IngestionReport, GraphError> {
138    let mut report = IngestionReport::default();
139
140    let chunks = extract::chunk_conversation(archive_text, 500);
141    if chunks.is_empty() {
142        return Ok(report);
143    }
144
145    // Create episodes for each chunk, each stamped with its own authorship —
146    // one archive can hold both the human's words and the agent's.
147    for (i, chunk) in chunks.iter().enumerate() {
148        let abstract_text = build_episode_abstract(chunk);
149        let episode = NewEpisode {
150            session_id: context.session_id.clone(),
151            abstract_text,
152            overview: None,
153            content: Some(chunk.clone()),
154            log_number: context.log_number,
155        };
156
157        match gm
158            .add_episode_from(episode, context.provenance.classify(chunk))
159            .await
160        {
161            Ok(_) => report.episodes_created += 1,
162            Err(e) => {
163                report.errors.push(format!("episode chunk {i}: {e}"));
164            }
165        }
166    }
167
168    // If LLM provided, run extraction on all chunks
169    if let Some(llm) = llm {
170        process_extraction(gm, &chunks, context, llm, &mut report).await?;
171    }
172
173    Ok(report)
174}
175
176/// Run LLM extraction on an archive text without creating episodes.
177///
178/// Use this when episodes already exist (e.g., backfill extraction on
179/// previously-ingested archives).
180pub async fn extract_from_archive(
181    gm: &GraphMemory,
182    archive_text: &str,
183    context: &IngestContext,
184    llm: &dyn LlmProvider,
185) -> Result<IngestionReport, GraphError> {
186    let mut report = IngestionReport::default();
187
188    let chunks = extract::chunk_conversation(archive_text, 500);
189    if chunks.is_empty() {
190        return Ok(report);
191    }
192
193    process_extraction(gm, &chunks, context, llm, &mut report).await?;
194
195    Ok(report)
196}
197
198/// Extract one chunk, tagged with its index.
199///
200/// A named async fn rather than an inline `async move` block: the inline form
201/// makes the resulting stream non-`Send` (the closure would have to implement
202/// `FnOnce` for any two lifetimes), and the serve daemon runs ingestion inside
203/// a spawned tokio task.
204async fn extract_indexed(
205    llm: &dyn LlmProvider,
206    chunk: &str,
207    session_id: &str,
208    log_number: Option<u32>,
209    index: usize,
210) -> (usize, Result<ExtractionResult, GraphError>) {
211    let result = extract::extract_from_chunk(llm, chunk, session_id, log_number).await;
212    (index, result)
213}
214
215/// Shared extraction logic — parallel extraction, sequential dedup.
216///
217/// Five phases:
218/// 1. Extract all chunks in parallel (up to LLM_CONCURRENCY)
219/// 2. Local pre-dedup: merge same-name entities from different chunks
220/// 3. Dedup sequentially against the DB (each call sees prior results)
221/// 4. Create relationships sequentially (fast, no LLM)
222/// 5. Record which entities the session touched (passive was-used signal)
223async fn process_extraction(
224    gm: &GraphMemory,
225    chunks: &[String],
226    context: &IngestContext,
227    llm: &dyn LlmProvider,
228    report: &mut IngestionReport,
229) -> Result<(), GraphError> {
230    let session_id = context.session_id.as_str();
231    let log_number = context.log_number;
232    // Phase 1: Extract all chunks in parallel.
233    // The per-chunk futures are built by the iterator, not by a stream
234    // combinator: a closure applied inside the stream would have to be
235    // higher-ranked over the item lifetime, which makes the whole stream
236    // non-`Send` and would bar ingestion from the serve daemon's tasks.
237    let pending: Vec<_> = chunks
238        .iter()
239        .enumerate()
240        .map(|(i, chunk)| extract_indexed(llm, chunk, session_id, log_number, i))
241        .collect();
242    let extraction_results: Vec<(usize, Result<ExtractionResult, GraphError>)> =
243        stream::iter(pending)
244            .buffer_unordered(LLM_CONCURRENCY)
245            .collect()
246            .await;
247
248    // Collect entities and relationships from successful extractions. A
249    // relationship keeps the class of the chunk it came out of: the evidence
250    // is only ever as independent as the text that produced it.
251    let mut all_entities: Vec<ExtractedEntity> = Vec::new();
252    let mut all_relationships: Vec<(Provenance, ExtractedRelationship)> = Vec::new();
253
254    for (i, result) in extraction_results {
255        match result {
256            Ok(extraction) => {
257                let provenance = context.provenance.classify(&chunks[i]);
258                all_entities.extend(extract::flatten_extraction(&extraction));
259                all_relationships.extend(
260                    extraction
261                        .relationships
262                        .into_iter()
263                        .map(|rel| (provenance, rel)),
264                );
265                // Estimate ~2500 tokens per extracted chunk (system prompt + chunk input + output)
266                report.estimated_tokens += 2500;
267            }
268            Err(e) => {
269                report.errors.push(format!("extraction chunk {i}: {e}"));
270            }
271        }
272    }
273
274    // Phase 2: Local pre-dedup — merge same-name entities before hitting the DB
275    let deduplicated = local_merge_entities(all_entities);
276
277    // Phase 3: Dedup sequentially — each resolve_entity sees the full DB state
278    let mut name_map: HashMap<String, String> = HashMap::new();
279
280    for candidate in &deduplicated {
281        // Estimate ~600 tokens per dedup call (vector search + LLM decision)
282        report.estimated_tokens += 600;
283        match dedup::resolve_entity(gm, llm, candidate, session_id).await {
284            Ok(ResolvedEntity::Created(entity)) => {
285                name_map.insert(candidate.name.clone(), entity.name.clone());
286                report.entity_ids.push(entity.id_string());
287                report.entities_created += 1;
288            }
289            Ok(ResolvedEntity::Merged(entity)) => {
290                name_map.insert(candidate.name.clone(), entity.name.clone());
291                report.entity_ids.push(entity.id_string());
292                report.entities_merged += 1;
293            }
294            Ok(ResolvedEntity::Skipped) => {
295                name_map.insert(candidate.name.clone(), candidate.name.clone());
296                report.entities_skipped += 1;
297            }
298            Err(e) => {
299                report
300                    .errors
301                    .push(format!("dedup '{}': {}", candidate.name, e));
302            }
303        }
304    }
305
306    // Phase 4: Create relationships or Bayesian-update existing ones
307    for (provenance, rel) in &all_relationships {
308        let from_name = name_map.get(&rel.source).unwrap_or(&rel.source);
309        let to_name = name_map.get(&rel.target).unwrap_or(&rel.target);
310
311        // Check if a relationship of the same type already exists
312        if let Some(existing) =
313            find_existing_relationship(gm, from_name, to_name, &rel.rel_type).await
314        {
315            // Re-extraction is corroborating evidence — worth what its source
316            // is worth. Self-authored corroboration also lands in the edge's
317            // coherence tally, where it stays visible instead of passing for
318            // independent support.
319            let mut evidence = existing.edge_evidence();
320            evidence.corroborate(*provenance, gm.provenance_weights());
321            if let Err(e) =
322                crud::reinforce_relationship(gm.db(), &existing.id_string(), evidence).await
323            {
324                report
325                    .errors
326                    .push(format!("confidence update {from_name} -> {to_name}: {e}"));
327            }
328            report.relationships_skipped += 1;
329            continue;
330        }
331
332        // Parse extraction context from LLM output, default to Inferred
333        let context: ExtractionContext = rel
334            .confidence
335            .as_deref()
336            .and_then(|s| s.parse().ok())
337            .unwrap_or(ExtractionContext::Inferred);
338
339        let new_rel = NewRelationship {
340            from_entity: from_name.clone(),
341            to_entity: to_name.clone(),
342            rel_type: rel.rel_type.clone(),
343            description: rel.description.clone(),
344            confidence: Some(context.prior() as f32),
345            source: Some(session_id.to_string()),
346        };
347
348        match gm.add_relationship(new_rel).await {
349            Ok(_) => report.relationships_created += 1,
350            Err(e) => {
351                report
352                    .errors
353                    .push(format!("relationship {from_name} -> {to_name}: {e}"));
354            }
355        }
356    }
357
358    // Phase 5: link the session to the entities it touched, so a later
359    // outcome knows what it applies to. Bookkeeping, never fatal: a failed
360    // link costs the feedback loop one session, not the ingestion.
361    if let Err(e) = utility::record_session_use(gm.db(), session_id, &report.entity_ids).await {
362        report
363            .errors
364            .push(format!("session use record for {session_id}: {e}"));
365    }
366
367    Ok(())
368}
369
370/// Merge extracted entities that share the same name (case-insensitive).
371///
372/// When multiple chunks extract the same entity, combine their data:
373/// - Keep the longest abstract_text
374/// - Concatenate overviews
375/// - Concatenate content
376/// - Deep-merge attributes (later wins on conflict)
377/// - First occurrence's entity_type wins
378fn local_merge_entities(entities: Vec<ExtractedEntity>) -> Vec<ExtractedEntity> {
379    let mut seen: HashMap<String, ExtractedEntity> = HashMap::new();
380    let mut order: Vec<String> = Vec::new();
381
382    for entity in entities {
383        let key = entity.name.to_lowercase();
384        if let Some(existing) = seen.get_mut(&key) {
385            // Keep longer abstract
386            if entity.abstract_text.len() > existing.abstract_text.len() {
387                existing.abstract_text = entity.abstract_text;
388            }
389            // Concatenate overviews
390            if let Some(new_overview) = entity.overview {
391                existing.overview = Some(match &existing.overview {
392                    Some(o) => format!("{o}\n\n{new_overview}"),
393                    None => new_overview,
394                });
395            }
396            // Concatenate content
397            if let Some(new_content) = entity.content {
398                existing.content = Some(match &existing.content {
399                    Some(c) => format!("{c}\n\n{new_content}"),
400                    None => new_content,
401                });
402            }
403            // Merge attributes
404            if let Some(new_attrs) = entity.attributes {
405                existing.attributes = Some(match &existing.attributes {
406                    Some(a) => merge_json(a, &new_attrs),
407                    None => new_attrs,
408                });
409            }
410        } else {
411            order.push(key.clone());
412            seen.insert(key, entity);
413        }
414    }
415
416    // Preserve insertion order
417    order.into_iter().filter_map(|k| seen.remove(&k)).collect()
418}
419
420use super::util::merge_json_objects as merge_json;
421
422/// Build a short abstract for an episode chunk.
423fn build_episode_abstract(chunk: &str) -> String {
424    let chars: String = chunk.chars().take(200).collect();
425    if chars.len() < chunk.len() {
426        format!("{}...", chars.trim())
427    } else {
428        chars.trim().to_string()
429    }
430}
431
432/// Find an existing relationship of the same type between two entities.
433/// Returns the full Relationship if found (for Bayesian update).
434async fn find_existing_relationship(
435    gm: &GraphMemory,
436    from_name: &str,
437    to_name: &str,
438    rel_type: &str,
439) -> Option<Relationship> {
440    let rels = gm
441        .get_relationships(from_name, Direction::Outgoing)
442        .await
443        .ok()?;
444    let to_entity = gm.get_entity(to_name).await.ok()??;
445    let to_id = to_entity.id_string();
446
447    rels.into_iter().find(|r| {
448        r.rel_type == rel_type && {
449            let out_id = match &r.to_id {
450                serde_json::Value::String(s) => s.clone(),
451                other => other.to_string(),
452            };
453            out_id == to_id
454        }
455    })
456}
457
458#[cfg(test)]
459mod tests {
460    use super::*;
461
462    #[test]
463    fn episode_abstract_truncates() {
464        let long = "x".repeat(500);
465        let abs = build_episode_abstract(&long);
466        assert!(abs.len() < 210);
467        assert!(abs.ends_with("..."));
468    }
469
470    #[test]
471    fn episode_abstract_short_unchanged() {
472        let short = "Hello world";
473        let abs = build_episode_abstract(short);
474        assert_eq!(abs, "Hello world");
475    }
476
477    #[test]
478    fn user_only_chunk_is_credited_to_the_human() {
479        let chunk = "### User\n\nI moved the repo to /opt/recall-echo.";
480        assert_eq!(infer_from_turn_roles(chunk), Provenance::User);
481    }
482
483    #[test]
484    fn assistant_turns_make_a_chunk_self_authored() {
485        let chunk = "### Assistant\n\nThe repo now lives at /opt/recall-echo.";
486        assert_eq!(infer_from_turn_roles(chunk), Provenance::SelfGenerated);
487    }
488
489    #[test]
490    fn mixed_chunk_is_self_authored() {
491        // The conservative half of the rule: a chunk the agent contributed to
492        // cannot be counted as independent testimony.
493        let chunk = "### User\n\nWhere does it live?\n\n---\n\n### Assistant\n\n/opt.";
494        assert_eq!(infer_from_turn_roles(chunk), Provenance::SelfGenerated);
495    }
496
497    #[test]
498    fn text_without_role_headings_is_self_authored() {
499        let chunk = "A pipeline document with no conversation structure at all.";
500        assert_eq!(infer_from_turn_roles(chunk), Provenance::SelfGenerated);
501    }
502
503    #[test]
504    fn heading_matching_is_exact() {
505        // "### Users of the system" is a topic, not a turn.
506        let chunk = "### Users of the system\n\nThey prefer NeoVim.";
507        assert_eq!(infer_from_turn_roles(chunk), Provenance::SelfGenerated);
508    }
509
510    #[test]
511    fn fixed_policy_overrides_turn_roles() {
512        let chunk = "### User\n\nA quote from a paper.";
513        let policy = ProvenancePolicy::Fixed(Provenance::External);
514        assert_eq!(policy.classify(chunk), Provenance::External);
515        assert_eq!(
516            ProvenancePolicy::FromTurnRoles.classify(chunk),
517            Provenance::User
518        );
519    }
520
521    #[test]
522    fn context_override_is_applied_only_when_present() {
523        let context = IngestContext::new("s1", Some(7));
524        assert_eq!(context.session_id(), "s1");
525        assert_eq!(context.log_number(), Some(7));
526
527        let inferring = context.clone().with_override(None);
528        assert_eq!(inferring.provenance, ProvenancePolicy::FromTurnRoles);
529
530        let forced = context.with_override(Some(Provenance::External));
531        assert_eq!(
532            forced.provenance,
533            ProvenancePolicy::Fixed(Provenance::External)
534        );
535    }
536}