Skip to main content

recall_echo/
graph_bridge.rs

1//! Bridge between recall-echo and recall-graph.
2//!
3//! Provides graph ingestion for archived conversations.
4//! When pulse-null feature is enabled, also bridges LmProvider → LlmProvider.
5
6/// Ingest a conversation archive into the knowledge graph.
7///
8/// Non-blocking: logs warnings on failure but never fails the caller.
9/// Returns the ingestion report on success.
10pub async fn ingest_into_graph(
11    memory_dir: &std::path::Path,
12    archive_content: &str,
13    session_id: &str,
14    log_number: Option<u32>,
15) -> Result<crate::graph::types::IngestionReport, crate::error::RecallError> {
16    let graph_dir = memory_dir.join("graph");
17    if !graph_dir.exists() {
18        return Err(crate::error::RecallError::NotInitialized(
19            "graph/ not initialized \u{2014} run `graph init` first".into(),
20        ));
21    }
22
23    // Hot path (SessionEnd hook): goes through the serve daemon so a
24    // concurrent session never collides on the embedded store lock.
25    // No LLM provider in standalone mode — episodes only, no entity extraction.
26    // Provenance is left to per-chunk turn-role inference: this is a
27    // conversation archive, the one place where authorship is visible.
28    let request = crate::serve::Request::IngestArchive(crate::serve::IngestArchiveArgs {
29        content: archive_content.to_string(),
30        session_id: session_id.to_string(),
31        log_number,
32        provenance: None,
33    });
34    let report: crate::graph::types::IngestionReport =
35        serde_json::from_value(crate::serve_client::execute(memory_dir, &request).await?)?;
36
37    eprintln!(
38        "recall-echo: graph ingested \u{2014} {} episodes, {} entities created, {} merged, {} skipped, {} relationships",
39        report.episodes_created,
40        report.entities_created,
41        report.entities_merged,
42        report.entities_skipped,
43        report.relationships_created,
44    );
45
46    if !report.errors.is_empty() {
47        eprintln!(
48            "recall-echo: graph ingestion had {} warnings",
49            report.errors.len()
50        );
51    }
52
53    Ok(report)
54}
55
56/// Sync the pipeline documents into the knowledge graph.
57///
58/// Pipeline sync needs no LLM provider, so it runs as an ordinary daemon
59/// request. That matters on the SessionEnd hook path: ingest and sync then
60/// share one warm daemon instead of the sync stopping the daemon the ingest
61/// just started and reloading the embedding model in-process.
62pub async fn sync_pipeline_into_graph(
63    memory_dir: &std::path::Path,
64    docs: crate::graph::types::PipelineDocuments,
65) -> Result<crate::graph::types::PipelineSyncReport, crate::error::RecallError> {
66    let request = crate::serve::Request::SyncPipeline(crate::serve::SyncPipelineArgs { docs });
67    Ok(serde_json::from_value(
68        crate::serve_client::execute(memory_dir, &request).await?,
69    )?)
70}
71
72/// Ingest with an LLM provider for entity extraction.
73///
74/// When pulse-null feature is enabled, this bridges the LmProvider
75/// to recall-graph's LlmProvider for full entity/relationship extraction.
76#[cfg(feature = "pulse-null")]
77pub async fn ingest_into_graph_with_llm(
78    memory_dir: &std::path::Path,
79    archive_content: &str,
80    session_id: &str,
81    log_number: Option<u32>,
82    provider: Option<&dyn pulse_system_types::llm::LmProvider>,
83) -> Result<crate::graph::types::IngestionReport, crate::error::RecallError> {
84    let graph_dir = memory_dir.join("graph");
85    if !graph_dir.exists() {
86        return Err(crate::error::RecallError::NotInitialized(
87            "graph/ not initialized \u{2014} run `graph init` first".into(),
88        ));
89    }
90
91    // LLM-backed extraction cannot cross the socket (the provider lives in
92    // this process), so this path takes the store exclusively instead.
93    let context = crate::graph::IngestContext::new(session_id, log_number);
94    let report = crate::serve_client::exclusive(memory_dir, |gm| async move {
95        let bridge = provider.map(GraphLlmBridge::new);
96        let llm_ref: Option<&dyn crate::graph::llm::LlmProvider> = bridge
97            .as_ref()
98            .map(|b| b as &dyn crate::graph::llm::LlmProvider);
99
100        Ok(gm
101            .ingest_archive(archive_content, &context, llm_ref)
102            .await?)
103    })
104    .await?;
105
106    eprintln!(
107        "recall-echo: graph ingested \u{2014} {} episodes, {} entities created, {} merged, {} skipped, {} relationships",
108        report.episodes_created,
109        report.entities_created,
110        report.entities_merged,
111        report.entities_skipped,
112        report.relationships_created,
113    );
114
115    if !report.errors.is_empty() {
116        eprintln!(
117            "recall-echo: graph ingestion had {} warnings",
118            report.errors.len()
119        );
120    }
121
122    Ok(report)
123}
124
125/// Adapter that wraps an `pulse_system_types::LmProvider` to implement
126/// `crate::graph::LlmProvider`.
127#[cfg(feature = "pulse-null")]
128pub struct GraphLlmBridge<'a> {
129    provider: &'a dyn pulse_system_types::llm::LmProvider,
130}
131
132#[cfg(feature = "pulse-null")]
133impl<'a> GraphLlmBridge<'a> {
134    pub fn new(provider: &'a dyn pulse_system_types::llm::LmProvider) -> Self {
135        Self { provider }
136    }
137}
138
139#[cfg(feature = "pulse-null")]
140#[async_trait::async_trait]
141impl crate::graph::llm::LlmProvider for GraphLlmBridge<'_> {
142    async fn complete(
143        &self,
144        system_prompt: &str,
145        user_message: &str,
146        max_tokens: u32,
147    ) -> Result<String, crate::graph::error::GraphError> {
148        use pulse_system_types::llm::{Message, MessageContent, Role};
149
150        let messages = vec![Message {
151            role: Role::User,
152            content: MessageContent::Text(user_message.to_string()),
153            source: None,
154        }];
155
156        let response = self
157            .provider
158            .invoke(system_prompt, &messages, max_tokens, None)
159            .await
160            .map_err(|e| crate::graph::error::GraphError::Llm(e.to_string()))?;
161
162        Ok(response.text())
163    }
164}