1use std::time::Duration;
13
14use serde::{Deserialize, Serialize};
15use tokio::time::timeout;
16use zeph_common::llm_response::extract_json_array_slice;
17use zeph_llm::any::AnyProvider;
18use zeph_llm::provider::{LlmProvider as _, Message, Role};
19
20use crate::error::MemoryError;
21use crate::store::SqliteStore;
22use crate::store::persona::PersonaFactRow;
23
24const EXTRACTION_SYSTEM_PROMPT: &str = "\
25You are a persona fact extractor. Given a list of user messages and any existing persona \
26facts for each category, extract factual claims the user makes about themselves: their \
27preferences, domain knowledge, working style, communication style, and background.
28
29Rules:
301. Only extract facts from first-person user statements (\"I prefer\", \"I work on\", \
31 \"my team\", \"I use\", etc.). Ignore assistant messages.
322. Do NOT extract facts from questions, greetings, or tool outputs.
333. For each extracted fact, decide if it is NEW (no existing fact contradicts it) or \
34 UPDATE (it contradicts or replaces an existing fact). For UPDATE, provide the \
35 `supersedes_id` of the older fact.
364. Confidence: 0.8-1.0 for explicit statements (\"I prefer X\"), 0.4-0.7 for inferences.
375. Categories: preference, domain_knowledge, working_style, communication, background.
386. Keep content concise (one sentence max). Normalize to English.
397. Return empty array if no facts are found.
40
41Output JSON array of objects:
42[
43 {
44 \"category\": \"preference|domain_knowledge|working_style|communication|background\",
45 \"content\": \"concise factual statement\",
46 \"confidence\": 0.0-1.0,
47 \"action\": \"new|update\",
48 \"supersedes_id\": null or integer id of the fact being replaced
49 }
50]";
51
52pub struct PersonaExtractionConfig {
54 pub enabled: bool,
55 pub min_messages: usize,
57 pub max_messages: usize,
59 pub extraction_timeout_secs: u64,
61}
62
63#[derive(Debug, Deserialize, Serialize)]
64struct ExtractedFact {
65 category: String,
66 content: String,
67 confidence: f64,
68 action: String,
69 supersedes_id: Option<i64>,
70}
71
72#[must_use]
75pub fn contains_self_referential_language(text: &str) -> bool {
76 let lower = text.to_lowercase();
79 let tokens = [" i ", " i'", " my ", " me ", " mine ", "i am ", "i'm "];
80 tokens.iter().any(|t| lower.contains(t)) || lower.starts_with("i ") || lower.starts_with("my ")
81}
82
83#[cfg_attr(
92 feature = "profiling",
93 tracing::instrument(name = "memory.persona_extract", skip_all, fields(fact_count = tracing::field::Empty))
94)]
95pub async fn extract_persona_facts(
96 store: &SqliteStore,
97 provider: &AnyProvider,
98 user_messages: &[&str],
99 config: &PersonaExtractionConfig,
100 conversation_id: Option<i64>,
101) -> Result<usize, MemoryError> {
102 if !config.enabled || user_messages.len() < config.min_messages {
103 return Ok(0);
104 }
105
106 let has_self_ref = user_messages
108 .iter()
109 .any(|m| contains_self_referential_language(m));
110 if !has_self_ref {
111 return Ok(0);
112 }
113
114 let messages_to_send: Vec<&str> = user_messages
115 .iter()
116 .rev()
117 .take(config.max_messages)
118 .copied()
119 .collect::<Vec<_>>()
120 .into_iter()
121 .rev()
122 .collect();
123
124 let existing_facts = store.load_persona_facts(0.0).await?;
126 let user_prompt = build_extraction_prompt(&messages_to_send, &existing_facts);
127
128 let llm_messages = [
129 Message::from_legacy(Role::System, EXTRACTION_SYSTEM_PROMPT),
130 Message::from_legacy(Role::User, user_prompt),
131 ];
132
133 let extraction_timeout = Duration::from_secs(config.extraction_timeout_secs);
134 let response = match timeout(extraction_timeout, provider.chat(&llm_messages)).await {
135 Ok(Ok(text)) => text,
136 Ok(Err(e)) => return Err(MemoryError::Llm(e)),
137 Err(_) => {
138 tracing::warn!(
139 "persona extraction timed out after {}s",
140 config.extraction_timeout_secs
141 );
142 return Ok(0);
143 }
144 };
145
146 let facts = parse_extraction_response(&response);
147 if facts.is_empty() {
148 return Ok(0);
149 }
150
151 let mut upserted = 0usize;
152 for fact in facts {
153 if fact.category.is_empty() || fact.content.is_empty() {
154 continue;
155 }
156 if !is_valid_category(&fact.category) {
157 tracing::debug!(
158 category = %fact.category,
159 "persona extraction: skipping unknown category"
160 );
161 continue;
162 }
163 match store
164 .upsert_persona_fact(
165 &fact.category,
166 &fact.content,
167 fact.confidence.clamp(0.0, 1.0),
168 conversation_id,
169 fact.supersedes_id,
170 )
171 .await
172 {
173 Ok(_) => upserted += 1,
174 Err(e) => {
175 tracing::warn!(error = %e, "persona extraction: failed to upsert fact");
176 }
177 }
178 }
179
180 tracing::debug!(upserted, "persona extraction complete");
181 #[cfg(feature = "profiling")]
182 tracing::Span::current().record("fact_count", upserted);
183 Ok(upserted)
184}
185
186fn is_valid_category(category: &str) -> bool {
187 matches!(
188 category,
189 "preference" | "domain_knowledge" | "working_style" | "communication" | "background"
190 )
191}
192
193fn build_extraction_prompt(messages: &[&str], existing_facts: &[PersonaFactRow]) -> String {
194 let mut prompt = String::from("User messages to analyze:\n");
195 for (i, msg) in messages.iter().enumerate() {
196 use std::fmt::Write as _;
197 let _ = writeln!(prompt, "[{}] {}", i + 1, msg);
198 }
199
200 if !existing_facts.is_empty() {
201 prompt.push_str("\nExisting persona facts (for contradiction detection):\n");
202 for fact in existing_facts {
203 use std::fmt::Write as _;
204 let _ = writeln!(
205 prompt,
206 " id={} category={} content=\"{}\"",
207 fact.id, fact.category, fact.content
208 );
209 }
210 }
211
212 prompt.push_str("\nExtract persona facts as JSON array.");
213 prompt
214}
215
216fn parse_extraction_response(response: &str) -> Vec<ExtractedFact> {
217 if let Ok(facts) = serde_json::from_str::<Vec<ExtractedFact>>(response) {
219 return facts;
220 }
221
222 if let Some(slice) = extract_json_array_slice(response)
224 && let Ok(facts) = serde_json::from_str::<Vec<ExtractedFact>>(slice)
225 {
226 return facts;
227 }
228
229 tracing::warn!(
230 "persona extraction: failed to parse LLM response (len={}): {:.200}",
231 response.len(),
232 response
233 );
234 Vec::new()
235}
236
237#[cfg(test)]
238mod tests {
239 use super::*;
240 use crate::store::SqliteStore;
241
242 async fn make_store() -> SqliteStore {
243 SqliteStore::with_pool_size(":memory:", 1)
244 .await
245 .expect("in-memory store")
246 }
247
248 #[test]
251 fn self_ref_detects_i_prefer() {
252 assert!(contains_self_referential_language("I prefer dark mode"));
253 }
254
255 #[test]
256 fn self_ref_detects_my_team() {
257 assert!(contains_self_referential_language("my team uses Rust"));
258 }
259
260 #[test]
261 fn self_ref_detects_sentence_starting_with_i() {
262 assert!(contains_self_referential_language("I work remotely"));
263 }
264
265 #[test]
266 fn self_ref_detects_inline_i() {
267 assert!(contains_self_referential_language(
268 "Sometimes I prefer async"
269 ));
270 }
271
272 #[test]
273 fn self_ref_detects_me_inline() {
274 assert!(contains_self_referential_language(
275 "That helps me understand"
276 ));
277 }
278
279 #[test]
280 fn self_ref_no_match_for_third_person() {
281 assert!(!contains_self_referential_language("The team uses Python"));
282 }
283
284 #[test]
285 fn self_ref_no_match_for_tool_output() {
286 assert!(!contains_self_referential_language("Error: file not found"));
287 }
288
289 #[test]
290 fn self_ref_no_match_for_empty_string() {
291 assert!(!contains_self_referential_language(""));
292 }
293
294 #[tokio::test]
297 async fn extraction_gate_skips_when_no_self_ref() {
298 let store = make_store().await;
299 let cfg = PersonaExtractionConfig {
305 enabled: true,
306 min_messages: 1,
307 max_messages: 10,
308 extraction_timeout_secs: 5,
309 };
310 let cfg_disabled = PersonaExtractionConfig {
315 enabled: false,
316 min_messages: 1,
317 max_messages: 10,
318 extraction_timeout_secs: 5,
319 };
320 let cfg_min = PersonaExtractionConfig {
324 enabled: true,
325 min_messages: 5,
326 max_messages: 10,
327 extraction_timeout_secs: 5,
328 };
329 let messages: Vec<&str> = vec![];
334 assert!(messages.len() < cfg_min.min_messages);
335 let _ = (store, cfg, cfg_disabled, cfg_min); }
337
338 #[test]
341 fn parse_direct_json_array() {
342 let json = r#"[{"category":"preference","content":"I prefer dark mode","confidence":0.9,"action":"new","supersedes_id":null}]"#;
343 let facts = parse_extraction_response(json);
344 assert_eq!(facts.len(), 1);
345 assert_eq!(facts[0].category, "preference");
346 assert_eq!(facts[0].content, "I prefer dark mode");
347 assert!((facts[0].confidence - 0.9).abs() < 1e-9);
348 assert_eq!(facts[0].action, "new");
349 assert!(facts[0].supersedes_id.is_none());
350 }
351
352 #[test]
353 fn parse_json_embedded_in_prose() {
354 let response = "Sure! Here are the facts:\n[{\"category\":\"domain_knowledge\",\"content\":\"Uses Rust\",\"confidence\":0.8,\"action\":\"new\",\"supersedes_id\":null}]\nThat's all.";
355 let facts = parse_extraction_response(response);
356 assert_eq!(facts.len(), 1);
357 assert_eq!(facts[0].category, "domain_knowledge");
358 }
359
360 #[test]
361 fn parse_empty_array() {
362 let facts = parse_extraction_response("[]");
363 assert!(facts.is_empty());
364 }
365
366 #[test]
367 fn parse_invalid_json_returns_empty() {
368 let facts = parse_extraction_response("not json at all");
369 assert!(facts.is_empty());
370 }
371
372 #[test]
373 fn parse_supersedes_id_populated() {
374 let json = r#"[{"category":"preference","content":"I prefer dark mode","confidence":0.9,"action":"update","supersedes_id":7}]"#;
375 let facts = parse_extraction_response(json);
376 assert_eq!(facts[0].supersedes_id, Some(7));
377 assert_eq!(facts[0].action, "update");
378 }
379
380 #[tokio::test]
383 async fn contradiction_second_fact_supersedes_first() {
384 let store = make_store().await;
385 let old_id = store
386 .upsert_persona_fact("preference", "I prefer light mode", 0.8, None, None)
387 .await
388 .expect("old fact");
389
390 store
391 .upsert_persona_fact("preference", "I prefer dark mode", 0.9, None, Some(old_id))
392 .await
393 .expect("new fact");
394
395 let facts = store.load_persona_facts(0.0).await.expect("load");
396 assert_eq!(facts.len(), 1, "superseded fact should be excluded");
397 assert_eq!(facts[0].content, "I prefer dark mode");
398 }
399}