Skip to main content

zeph_memory/graph/
extractor.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use schemars::JsonSchema;
5use serde::{Deserialize, Serialize};
6use zeph_llm::LlmError;
7use zeph_llm::any::AnyProvider;
8use zeph_llm::provider::{Message, Role};
9
10use crate::error::MemoryError;
11
12const SYSTEM_PROMPT: &str = "\
13You are an entity and relationship extractor. Given a conversation message and \
14its recent context, extract structured knowledge as JSON.
15
16Rules:
171. Extract only entities that appear in natural conversational text — user statements, \
18preferences, opinions, or factual claims made by a person.
192. Do NOT extract entities from: tool outputs, command results, file contents, \
20configuration files, JSON/TOML/YAML data, code snippets, or error messages. \
21If the message is structured data or raw command output, return empty arrays.
223. Do NOT extract structural data: config keys, file paths, tool names, TOML/JSON keys, \
23programming keywords, or single-letter identifiers.
244. Entity types must be one of: person, project, tool, language, organization, concept. \
25\"tool\" covers frameworks, software tools, and libraries. \
26\"language\" covers programming and natural languages. \
27\"concept\" covers abstract ideas, methodologies, and practices.
285. Only extract entities with clear semantic meaning about people, projects, or domain knowledge.
296. Entity names must be at least 3 characters long. Reject single characters, two-letter \
30tokens (e.g. standalone \"go\", \"cd\"), URLs, and bare file paths.
317. Relations should be short verb phrases: \"prefers\", \"uses\", \"works_on\", \"knows\", \
32\"created\", \"depends_on\", \"replaces\", \"configured_with\".
338. The \"fact\" field is a human-readable sentence summarizing the relationship.
349. If a message contains a temporal change (e.g., \"switched from X to Y\"), include a \
35temporal_hint like \"replaced X\" or \"since January 2026\".
3610. Each edge must include an \"edge_type\" field classifying the relationship:
37  - \"semantic\": conceptual relationships (uses, prefers, knows, works_on, depends_on, created)
38  - \"temporal\": time-ordered events (preceded_by, followed_by, happened_during, started_before)
39  - \"causal\": cause-effect chains (caused, triggered, resulted_in, led_to, prevented)
40  - \"entity\": identity/structural relationships (is_a, part_of, instance_of, alias_of, replaces)
41  Default to \"semantic\" if the relationship type is uncertain.
4211. Each edge must include a \"confidence\" field: a float in [0.0, 1.0] reflecting how \
43certain you are that this relationship was explicitly stated or strongly implied by the text. \
44Use 1.0 only for direct verbatim statements. Use 0.5–0.8 for clear implications. \
45Use 0.3–0.5 for weak inferences. Omit or use null if you cannot assign a meaningful score.
4611. Do not extract entities from greetings, filler, or meta-conversation (\"hi\", \"thanks\", \"ok\").
4712. Do not extract personal identifiable information as entity names: email addresses, \
48phone numbers, physical addresses, SSNs, or API keys. Use generic references instead.
4913. Always output entity names and relation verbs in English. Translate if needed.
5014. Return empty arrays if no entities or relationships are present.
51
52Output JSON schema:
53{
54  \"entities\": [
55    {\"name\": \"string\", \"type\": \"person|project|tool|language|organization|concept\", \"summary\": \"optional string\"}
56  ],
57  \"edges\": [
58    {\"source\": \"entity name\", \"target\": \"entity name\", \"relation\": \"verb phrase\", \"fact\": \"human-readable sentence\", \"temporal_hint\": \"optional string\", \"edge_type\": \"semantic|temporal|causal|entity\", \"confidence\": 0.0}
59  ]
60}";
61
62#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
63pub struct ExtractionResult {
64    pub entities: Vec<ExtractedEntity>,
65    pub edges: Vec<ExtractedEdge>,
66}
67
68#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
69pub struct ExtractedEntity {
70    pub name: String,
71    #[serde(rename = "type")]
72    pub entity_type: String,
73    pub summary: Option<String>,
74}
75
76fn default_semantic() -> String {
77    "semantic".to_owned()
78}
79
80#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
81pub struct ExtractedEdge {
82    pub source: String,
83    pub target: String,
84    pub relation: String,
85    pub fact: String,
86    pub temporal_hint: Option<String>,
87    /// MAGMA edge type classification. Defaults to "semantic" when omitted by the LLM.
88    #[serde(default = "default_semantic")]
89    pub edge_type: String,
90    /// Extractor confidence in the relationship, in `[0.0, 1.0]`.
91    ///
92    /// Assigned by the LLM during extraction. `None` means the LLM omitted the field;
93    /// callers should treat `None` as `1.0` (direct statement, commit immediately).
94    /// Values below `BeliefMemConfig::promote_threshold` route the edge to
95    /// `BeliefStore` for evidence accumulation instead of immediate commit.
96    #[serde(default)]
97    pub confidence: Option<f32>,
98}
99
100pub struct GraphExtractor {
101    provider: AnyProvider,
102    max_entities: usize,
103    max_edges: usize,
104    llm_timeout_secs: u64,
105    /// Override system prompt. `None` uses the built-in [`SYSTEM_PROMPT`].
106    system_prompt: Option<&'static str>,
107}
108
109impl GraphExtractor {
110    #[must_use]
111    pub fn new(
112        provider: AnyProvider,
113        max_entities: usize,
114        max_edges: usize,
115        llm_timeout_secs: u64,
116    ) -> Self {
117        Self {
118            provider,
119            max_entities,
120            max_edges,
121            llm_timeout_secs,
122            system_prompt: None,
123        }
124    }
125
126    /// Override the system prompt used during extraction.
127    ///
128    /// The default (when `None`) is the built-in conversational `SYSTEM_PROMPT`.
129    /// Pass `Some(prompt)` to select an alternative prompt such as
130    /// [`crate::graph::ingest::prompt::TECH_DOC_SYSTEM_PROMPT`].
131    ///
132    /// The type is `&'static str` — prompts must be compile-time constants.
133    /// This is a known MVP limitation (spec-067 critic C7); runtime-configurable
134    /// prompts will require a breaking change to this signature.
135    #[must_use]
136    pub fn with_system_prompt(mut self, prompt: &'static str) -> Self {
137        self.system_prompt = Some(prompt);
138        self
139    }
140
141    /// Extract entities and relations from a message with surrounding context.
142    ///
143    /// Returns `None` if the message is empty, extraction fails, or the LLM returns
144    /// unparseable output. Callers should treat `None` as a graceful degradation.
145    ///
146    /// # Errors
147    ///
148    /// Returns an error only for transport-level failures (network, auth).
149    /// JSON parse failures are logged and return `Ok(None)`.
150    #[tracing::instrument(name = "memory.graph.extract", skip_all, level = "debug", err)]
151    pub async fn extract(
152        &self,
153        message: &str,
154        context_messages: &[&str],
155    ) -> Result<Option<ExtractionResult>, MemoryError> {
156        if message.trim().is_empty() {
157            return Ok(None);
158        }
159
160        let user_prompt = build_user_prompt(message, context_messages);
161        let prompt = self.system_prompt.unwrap_or(SYSTEM_PROMPT);
162        let messages = [
163            Message::from_legacy(Role::System, prompt),
164            Message::from_legacy(Role::User, user_prompt),
165        ];
166
167        match tokio::time::timeout(
168            std::time::Duration::from_secs(self.llm_timeout_secs),
169            self.provider
170                .chat_typed_erased::<ExtractionResult>(&messages),
171        )
172        .await
173        {
174            Err(_elapsed) => {
175                let t = self.llm_timeout_secs;
176                tracing::warn!("graph_extractor: extract LLM call timed out after {t}s");
177                return Ok(None);
178            }
179            Ok(Ok(mut result)) => {
180                result.entities.truncate(self.max_entities);
181                result.edges.truncate(self.max_edges);
182                return Ok(Some(result));
183            }
184            Ok(Err(LlmError::StructuredParse(msg))) => {
185                tracing::warn!(
186                    "graph extraction: LLM returned unparseable output (len={}): {:.200}",
187                    msg.len(),
188                    msg
189                );
190                return Ok(None);
191            }
192            Ok(Err(other)) => return Err(MemoryError::Llm(other)),
193        }
194    }
195}
196
197fn build_user_prompt(message: &str, context_messages: &[&str]) -> String {
198    if context_messages.is_empty() {
199        format!("Current message:\n{message}\n\nExtract entities and relationships as JSON.")
200    } else {
201        let n = context_messages.len();
202        let context = context_messages.join("\n");
203        format!(
204            "Context (last {n} messages):\n{context}\n\nCurrent message:\n{message}\n\nExtract entities and relationships as JSON."
205        )
206    }
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212    use std::assert_matches;
213
214    fn make_entity(name: &str, entity_type: &str, summary: Option<&str>) -> ExtractedEntity {
215        ExtractedEntity {
216            name: name.into(),
217            entity_type: entity_type.into(),
218            summary: summary.map(Into::into),
219        }
220    }
221
222    fn make_edge(
223        source: &str,
224        target: &str,
225        relation: &str,
226        fact: &str,
227        temporal_hint: Option<&str>,
228    ) -> ExtractedEdge {
229        ExtractedEdge {
230            source: source.into(),
231            target: target.into(),
232            relation: relation.into(),
233            fact: fact.into(),
234            temporal_hint: temporal_hint.map(Into::into),
235            edge_type: "semantic".into(),
236            confidence: None,
237        }
238    }
239
240    #[test]
241    fn extraction_result_deserialize_valid_json() {
242        let json = r#"{"entities":[{"name":"Rust","type":"language","summary":"A systems language"}],"edges":[]}"#;
243        let result: ExtractionResult = serde_json::from_str(json).unwrap();
244        assert_eq!(result.entities.len(), 1);
245        assert_eq!(result.entities[0].name, "Rust");
246        assert_eq!(result.entities[0].entity_type, "language");
247        assert_eq!(
248            result.entities[0].summary.as_deref(),
249            Some("A systems language")
250        );
251        assert!(result.edges.is_empty());
252    }
253
254    #[test]
255    fn extraction_result_deserialize_empty_arrays() {
256        let json = r#"{"entities":[],"edges":[]}"#;
257        let result: ExtractionResult = serde_json::from_str(json).unwrap();
258        assert!(result.entities.is_empty());
259        assert!(result.edges.is_empty());
260    }
261
262    #[test]
263    fn extraction_result_deserialize_missing_optional_fields() {
264        let json = r#"{"entities":[{"name":"Alice","type":"person","summary":null}],"edges":[{"source":"Alice","target":"Rust","relation":"uses","fact":"Alice uses Rust","temporal_hint":null}]}"#;
265        let result: ExtractionResult = serde_json::from_str(json).unwrap();
266        assert_eq!(result.entities[0].summary, None);
267        assert_eq!(result.edges[0].temporal_hint, None);
268        // edge_type defaults to "semantic" when omitted
269        assert_eq!(result.edges[0].edge_type, "semantic");
270    }
271
272    #[test]
273    fn extracted_edge_type_defaults_to_semantic_when_missing() {
274        // When LLM omits edge_type, serde(default) must provide "semantic".
275        let json = r#"{"source":"A","target":"B","relation":"uses","fact":"A uses B"}"#;
276        let edge: ExtractedEdge = serde_json::from_str(json).unwrap();
277        assert_eq!(edge.edge_type, "semantic");
278    }
279
280    #[test]
281    fn extracted_edge_type_parses_all_variants() {
282        for et in &["semantic", "temporal", "causal", "entity"] {
283            let json = format!(
284                r#"{{"source":"A","target":"B","relation":"r","fact":"f","edge_type":"{et}"}}"#
285            );
286            let edge: ExtractedEdge = serde_json::from_str(&json).unwrap();
287            assert_eq!(&edge.edge_type, et);
288        }
289    }
290
291    #[test]
292    fn extraction_result_with_edge_types_roundtrip() {
293        let original = ExtractionResult {
294            entities: vec![],
295            edges: vec![
296                ExtractedEdge {
297                    source: "A".into(),
298                    target: "B".into(),
299                    relation: "caused".into(),
300                    fact: "A caused B".into(),
301                    temporal_hint: None,
302                    edge_type: "causal".into(),
303                    confidence: Some(0.9),
304                },
305                ExtractedEdge {
306                    source: "B".into(),
307                    target: "C".into(),
308                    relation: "preceded_by".into(),
309                    fact: "B preceded_by C".into(),
310                    temporal_hint: None,
311                    edge_type: "temporal".into(),
312                    confidence: None,
313                },
314            ],
315        };
316        let json = serde_json::to_string(&original).unwrap();
317        let restored: ExtractionResult = serde_json::from_str(&json).unwrap();
318        assert_eq!(original, restored);
319        assert_eq!(restored.edges[0].edge_type, "causal");
320        assert_eq!(restored.edges[1].edge_type, "temporal");
321    }
322
323    #[test]
324    fn extracted_entity_type_field_rename() {
325        let json = r#"{"name":"cargo","type":"tool","summary":null}"#;
326        let entity: ExtractedEntity = serde_json::from_str(json).unwrap();
327        assert_eq!(entity.entity_type, "tool");
328
329        let serialized = serde_json::to_string(&entity).unwrap();
330        assert!(serialized.contains("\"type\""));
331        assert!(!serialized.contains("\"entity_type\""));
332    }
333
334    #[test]
335    fn extraction_result_roundtrip() {
336        let original = ExtractionResult {
337            entities: vec![make_entity("Rust", "language", Some("A systems language"))],
338            edges: vec![make_edge("Alice", "Rust", "uses", "Alice uses Rust", None)],
339        };
340        let json = serde_json::to_string(&original).unwrap();
341        let restored: ExtractionResult = serde_json::from_str(&json).unwrap();
342        assert_eq!(original, restored);
343    }
344
345    #[test]
346    fn extraction_result_json_schema() {
347        let schema = schemars::schema_for!(ExtractionResult);
348        let value = serde_json::to_value(&schema).unwrap();
349        let schema_obj = value.as_object().unwrap();
350        assert!(
351            schema_obj.contains_key("title") || schema_obj.contains_key("properties"),
352            "schema should have top-level keys"
353        );
354        let json_str = serde_json::to_string(&schema).unwrap();
355        assert!(
356            json_str.contains("entities"),
357            "schema should contain 'entities'"
358        );
359        assert!(json_str.contains("edges"), "schema should contain 'edges'");
360    }
361
362    #[test]
363    fn build_user_prompt_with_context() {
364        let prompt = build_user_prompt("Hello Rust", &["prev message 1", "prev message 2"]);
365        assert!(prompt.contains("Context (last 2 messages):"));
366        assert!(prompt.contains("prev message 1\nprev message 2"));
367        assert!(prompt.contains("Current message:\nHello Rust"));
368        assert!(prompt.contains("Extract entities and relationships as JSON."));
369    }
370
371    #[test]
372    fn build_user_prompt_without_context() {
373        let prompt = build_user_prompt("Hello Rust", &[]);
374        assert!(!prompt.contains("Context"));
375        assert!(prompt.contains("Current message:\nHello Rust"));
376        assert!(prompt.contains("Extract entities and relationships as JSON."));
377    }
378
379    mod mock_tests {
380        use super::*;
381        use zeph_llm::mock::MockProvider;
382
383        fn make_entities_json(count: usize) -> String {
384            let entities: Vec<String> = (0..count)
385                .map(|i| format!(r#"{{"name":"entity{i}","type":"concept","summary":null}}"#))
386                .collect();
387            format!(r#"{{"entities":[{}],"edges":[]}}"#, entities.join(","))
388        }
389
390        fn make_edges_json(count: usize) -> String {
391            let edges: Vec<String> = (0..count)
392                .map(|i| {
393                    format!(
394                        r#"{{"source":"A","target":"B{i}","relation":"uses","fact":"A uses B{i}","temporal_hint":null}}"#
395                    )
396                })
397                .collect();
398            format!(r#"{{"entities":[],"edges":[{}]}}"#, edges.join(","))
399        }
400
401        #[tokio::test]
402        async fn extract_truncates_to_max_entities() {
403            let json = make_entities_json(20);
404            let mock = MockProvider::with_responses(vec![json]);
405            let extractor = GraphExtractor::new(zeph_llm::any::AnyProvider::Mock(mock), 5, 100, 30);
406            let result = extractor.extract("test message", &[]).await.unwrap();
407            let result = result.unwrap();
408            assert_eq!(result.entities.len(), 5);
409        }
410
411        #[tokio::test]
412        async fn extract_truncates_to_max_edges() {
413            let json = make_edges_json(15);
414            let mock = MockProvider::with_responses(vec![json]);
415            let extractor = GraphExtractor::new(zeph_llm::any::AnyProvider::Mock(mock), 100, 3, 30);
416            let result = extractor.extract("test message", &[]).await.unwrap();
417            let result = result.unwrap();
418            assert_eq!(result.edges.len(), 3);
419        }
420
421        #[tokio::test]
422        async fn extract_returns_none_on_parse_failure() {
423            let mock = MockProvider::with_responses(vec!["not valid json at all".into()]);
424            let extractor = GraphExtractor::new(zeph_llm::any::AnyProvider::Mock(mock), 10, 10, 30);
425            let result = extractor.extract("test message", &[]).await.unwrap();
426            assert!(result.is_none());
427        }
428
429        #[tokio::test]
430        async fn extract_returns_err_on_transport_failure() {
431            let mock = MockProvider::default()
432                .with_errors(vec![zeph_llm::LlmError::Other("connection refused".into())]);
433            let extractor = GraphExtractor::new(zeph_llm::any::AnyProvider::Mock(mock), 10, 10, 30);
434            let result = extractor.extract("test message", &[]).await;
435            assert!(result.is_err());
436            assert_matches!(result.unwrap_err(), MemoryError::Llm(_));
437        }
438
439        #[test]
440        fn graph_extractor_stores_custom_llm_timeout() {
441            let extractor = GraphExtractor::new(
442                zeph_llm::any::AnyProvider::Mock(MockProvider::default()),
443                10,
444                5,
445                42,
446            );
447            assert_eq!(extractor.llm_timeout_secs, 42);
448        }
449
450        #[tokio::test]
451        async fn extract_returns_none_on_empty_message() {
452            let mock = MockProvider::with_responses(vec!["should not be called".into()]);
453            let extractor = GraphExtractor::new(zeph_llm::any::AnyProvider::Mock(mock), 10, 10, 30);
454
455            let result_empty = extractor.extract("", &[]).await.unwrap();
456            assert!(result_empty.is_none());
457
458            let result_whitespace = extractor.extract("   \t\n  ", &[]).await.unwrap();
459            assert!(result_whitespace.is_none());
460        }
461    }
462}