Skip to main content

recall_echo/graph/
ingest.rs

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