use async_trait::async_trait;
use lc_core::json_parse::parse_llm_json;
use lc_core::language_models::BaseChatModel;
use lc_memory::{MemoryError, MemoryExtractor, MemoryItem};
use lc_schema::Message;
use std::sync::Arc;
const DEFAULT_IMPORTANCE: f64 = 0.5;
const MAX_EXTRACTIONS_PER_TURN: usize = 10;
const KEY_MAX_CHARS: usize = 60;
const SYSTEM_PROMPT: &str = "You are a memory extraction engine for a personal AI assistant. \
From the conversation turn, extract only durable facts worth remembering across future sessions: \
user preferences, identities, project context, goals, constraints, explicitly stated corrections. \
Do NOT extract ephemeral task chatter, greetings, or information already obvious in the current reply. \
Respond with a single JSON array and nothing else. Each element must be an object: \
{\"key\": \"short_stable_snake_case_id\", \"text\": \"one self-contained fact\", \"importance\": 0.0-1.0}. \
Higher importance means stable and broadly useful. If nothing is worth remembering, output [].";
pub struct LlmMemoryExtractor<M: BaseChatModel + Send + Sync> {
model: Arc<M>,
}
impl<M: BaseChatModel + Send + Sync> LlmMemoryExtractor<M> {
pub fn new(model: Arc<M>) -> Self {
Self { model }
}
pub fn from_model(model: M) -> Self {
Self {
model: Arc::new(model),
}
}
fn user_prompt(namespace: &str, user_input: &str, assistant_output: &str) -> String {
format!(
"Memory namespace: {namespace}\n\nUser: {user_input}\n\nAssistant: {assistant_output}\n\n\
JSON array of durable facts:"
)
}
fn parse_items(raw: &str) -> Vec<MemoryItem> {
let values: Vec<serde_json::Value> = match parse_llm_json::<serde_json::Value>(raw) {
Ok(serde_json::Value::Array(v)) => v,
Ok(serde_json::Value::Object(map)) => map
.get("memories")
.or_else(|| map.get("items"))
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default(),
Ok(_) => Vec::new(),
Err(_) => Vec::new(),
};
let mut items = Vec::new();
for value in values.into_iter().take(MAX_EXTRACTIONS_PER_TURN) {
let Some(map) = value.as_object() else {
continue;
};
let Some(text) = map.get("text").and_then(|v| v.as_str()) else {
continue;
};
if text.trim().is_empty() {
continue;
}
let importance = map
.get("importance")
.and_then(|v| v.as_f64())
.unwrap_or(DEFAULT_IMPORTANCE);
let key = match map.get("key").and_then(|v| v.as_str()) {
Some(k) if !k.trim().is_empty() => k.trim().to_string(),
_ => derive_key(text),
};
items.push(MemoryItem::new(key, text.trim().to_string()).with_importance(importance));
}
items
}
}
#[async_trait]
impl<M> MemoryExtractor for LlmMemoryExtractor<M>
where
M: BaseChatModel + Send + Sync,
{
async fn extract(
&self,
namespace: &str,
user_input: &str,
assistant_output: &str,
) -> Result<Vec<MemoryItem>, MemoryError> {
if assistant_output.trim().is_empty() {
return Ok(Vec::new());
}
let messages = vec![Message::human(Self::user_prompt(
namespace,
user_input,
assistant_output,
))];
let result = self
.model
.chat_with_system(SYSTEM_PROMPT.to_string(), messages)
.await
.map_err(|e| {
MemoryError::Other(format!("memory extraction model call failed: {e:?}"))
})?;
Ok(Self::parse_items(&result.content))
}
}
fn derive_key(text: &str) -> String {
let mut key = String::new();
let mut prev_sep = true;
for ch in text.chars() {
if ch.is_alphanumeric() {
key.extend(ch.to_lowercase());
prev_sep = false;
} else if !prev_sep {
key.push('_');
prev_sep = true;
}
if key.chars().count() >= KEY_MAX_CHARS {
break;
}
}
let key = key.trim_end_matches('_').to_string();
if key.is_empty() {
"memory".to_string()
} else {
key
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_bare_array_fenced_with_prose() {
let raw = "Here are the facts:\n```json\n[\
{\"key\": \"lang\", \"text\": \"User prefers Rust.\", \"importance\": 0.9},\
{\"text\": \"Works on the agent framework.\"}\
]\n```";
let items = LlmMemoryExtractor::<FakeModel>::parse_items(raw);
assert_eq!(items.len(), 2);
assert_eq!(items[0].key, "lang");
assert!((items[0].importance - 0.9).abs() < 1e-9);
assert!(items[1].key.starts_with("works_on_the_agent"));
assert!((items[1].importance - 0.5).abs() < 1e-9);
}
#[test]
fn parses_envelope_and_skips_bad_entries() {
let raw = r#"{"memories":[
{"key":"ok","text":"Team uses trunk-based development.","importance":0.7},
{"key":"no_text"},
"garbage",
{"key":"empty","text":" "}
]}"#;
let items = LlmMemoryExtractor::<FakeModel>::parse_items(raw);
assert_eq!(items.len(), 1);
assert_eq!(items[0].key, "ok");
}
#[test]
fn non_json_reply_yields_no_items_not_error() {
assert!(
LlmMemoryExtractor::<FakeModel>::parse_items("I could not find any facts.").is_empty()
);
assert!(LlmMemoryExtractor::<FakeModel>::parse_items("[]").is_empty());
}
#[test]
fn derived_key_is_stable_and_bounded() {
let a = derive_key("User's favorite IDE is (NeoVim)!");
let b = derive_key("user's favorite IDE is (NeoVim)!!");
assert_eq!(a, b);
assert!(a.chars().count() <= KEY_MAX_CHARS);
assert_eq!(derive_key("!!! ???"), "memory");
}
#[tokio::test]
async fn extract_calls_model_and_converts_reply() {
let model = FakeModel::new(json_array());
let extractor = LlmMemoryExtractor::from_model(model);
let items = extractor
.extract("user-1", "I work on langchainrust", "Noted.")
.await
.unwrap();
assert_eq!(items.len(), 1);
assert_eq!(items[0].text, "User works on langchainrust.");
}
#[tokio::test]
async fn empty_output_short_circuits_without_model_call() {
let model = FakeModel::new(json_array());
let extractor = LlmMemoryExtractor::from_model(model);
let items = extractor.extract("ns", "hi", " ").await.unwrap();
assert!(items.is_empty());
}
fn json_array() -> String {
r#"[{"key":"project","text":"User works on langchainrust.","importance":0.8}]"#.into()
}
#[derive(Clone)]
struct FakeModel {
reply: String,
}
impl FakeModel {
fn new(reply: String) -> Self {
Self { reply }
}
}
#[async_trait]
impl lc_core::runnables::Runnable<Vec<Message>, lc_core::language_models::LLMResult> for FakeModel {
type Error = lc_core::LcelError;
async fn invoke(
&self,
_messages: Vec<Message>,
_config: Option<lc_core::runnables::RunnableConfig>,
) -> Result<lc_core::language_models::LLMResult, Self::Error> {
Ok(lc_core::language_models::LLMResult {
content: self.reply.clone(),
..Default::default()
})
}
}
impl
lc_core::language_models::BaseLanguageModel<
Vec<Message>,
lc_core::language_models::LLMResult,
> for FakeModel
{
fn model_name(&self) -> &str {
"fake-extractor"
}
fn get_num_tokens(&self, _text: &str) -> usize {
0
}
fn with_temperature(self, _temp: f32) -> Self {
self
}
fn with_max_tokens(self, _max: usize) -> Self {
self
}
}
#[async_trait]
impl BaseChatModel for FakeModel {
async fn chat(
&self,
_messages: Vec<Message>,
_config: Option<lc_core::runnables::RunnableConfig>,
) -> Result<lc_core::language_models::LLMResult, lc_core::LcelError> {
Ok(lc_core::language_models::LLMResult {
content: self.reply.clone(),
..Default::default()
})
}
async fn stream_chat(
&self,
_messages: Vec<Message>,
_config: Option<lc_core::runnables::RunnableConfig>,
) -> Result<
std::pin::Pin<
Box<
dyn futures_util::Stream<
Item = Result<
lc_core::language_models::StreamChunk,
lc_core::LcelError,
>,
> + Send,
>,
>,
lc_core::LcelError,
> {
unimplemented!("extractor never streams")
}
}
}