Skip to main content

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