1use 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 #[serde(default = "default_semantic")]
89 pub edge_type: String,
90 #[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 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 #[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 #[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 assert_eq!(result.edges[0].edge_type, "semantic");
270 }
271
272 #[test]
273 fn extracted_edge_type_defaults_to_semantic_when_missing() {
274 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}