use std::fmt::Write as _;
use super::finalize_simple;
use crate::coding::contains_cjk;
use crate::engine::{normalize_prompt, SymbolicAnswer};
use crate::event_log::EventLog;
use crate::language::detect as detect_language;
use crate::memory::MemoryEvent;
use crate::seed::{self, Slot, WordForm};
use crate::solver_helpers::{extract_introduced_name, last_user_turn, recall_name_from_history};
use crate::summarization::{
generate_chat_title, summarize_dialog, DialogTurn, SummarizationConfig, SummarizationMode,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum RecallScope {
Conversation,
OtherConversations,
}
impl RecallScope {
const fn as_str(self) -> &'static str {
match self {
Self::Conversation => "conversation",
Self::OtherConversations => "other_conversations",
}
}
}
#[derive(Debug)]
struct RecallQuery {
term: String,
scope: RecallScope,
}
#[derive(Debug)]
struct RecallMatch {
turn_index: usize,
role: &'static str,
content: String,
}
#[derive(Debug)]
struct MemoryRecallMatch {
event_index: usize,
role: String,
content: String,
conversation_id: String,
conversation_title: String,
sent_at: String,
}
pub fn try_conversation_memory(
prompt: &str,
normalized: &str,
log: &mut EventLog,
) -> Option<SymbolicAnswer> {
if let Some(answer) = try_recall_name(prompt, normalized, log) {
return Some(answer);
}
if let Some(answer) = try_recall_last_question(prompt, normalized, log) {
return Some(answer);
}
if let Some(answer) = try_conversation_recall(prompt, normalized, log) {
return Some(answer);
}
if let Some(answer) = try_summarize_conversation(prompt, normalized, log) {
return Some(answer);
}
None
}
#[must_use]
pub fn answer_memory_recall(
prompt: &str,
events: &[MemoryEvent],
current_conversation_id: Option<&str>,
) -> Option<SymbolicAnswer> {
let normalized = normalize_prompt(prompt);
let mut log = EventLog::new();
log.append("impulse", prompt.to_owned());
try_memory_recall(
prompt,
&normalized,
events,
current_conversation_id,
&mut log,
)
}
fn try_recall_name(prompt: &str, normalized: &str, log: &mut EventLog) -> Option<SymbolicAnswer> {
let asks_name = normalized.contains("what is my name")
|| normalized.contains("what's my name")
|| normalized.contains("do you know my name")
|| normalized.contains("who am i");
if !asks_name {
return None;
}
let name = recall_name_from_history(log, prompt).or_else(|| extract_introduced_name(prompt))?;
log.append("filter:user", format!("name={name}"));
let body = format!("Your name is {name}.");
Some(finalize_simple(
prompt,
log,
"recall_name",
"response:recall_name",
&body,
0.9,
))
}
fn try_recall_last_question(
prompt: &str,
normalized: &str,
log: &mut EventLog,
) -> Option<SymbolicAnswer> {
let asks = normalized.contains("what did i ask")
|| normalized.contains("what was my last question")
|| normalized.contains("what was my previous question")
|| normalized.contains("repeat my last message");
if !asks {
return None;
}
let previous = last_user_turn(log)?;
let body = format!("Your previous message was: \"{previous}\"");
log.append("filter:user", "previous_turn".to_owned());
Some(finalize_simple(
prompt,
log,
"recall_last_question",
"response:recall_last_question",
&body,
0.9,
))
}
fn try_conversation_recall(
prompt: &str,
normalized: &str,
log: &mut EventLog,
) -> Option<SymbolicAnswer> {
let query = recognize_recall_query(normalized)?;
let matches = recall_matches(log, &query.term);
log.append("filter:memory_query", query.term.clone());
log.append("filter:memory_scope", query.scope.as_str());
log.append("filter:memory_matches", matches.len().to_string());
for matched in &matches {
log.append(
"memory_match",
format!(
"turn={} role={} content={}",
matched.turn_index, matched.role, matched.content
),
);
}
let language = detect_language(prompt).slug();
let body = render_recall_report(&query, &matches, language);
Some(finalize_simple(
prompt,
log,
"conversation_recall",
"response:conversation_recall",
&body,
0.9,
))
}
fn try_memory_recall(
prompt: &str,
normalized: &str,
events: &[MemoryEvent],
current_conversation_id: Option<&str>,
log: &mut EventLog,
) -> Option<SymbolicAnswer> {
let query = recognize_recall_query(normalized)?;
let matches = memory_recall_matches(events, &query, current_conversation_id, prompt);
let conversation_count = memory_conversation_count(&matches);
log.append("filter:memory_query", query.term.clone());
log.append("filter:memory_scope", query.scope.as_str());
log.append("filter:memory_matches", matches.len().to_string());
log.append(
"filter:memory_conversations",
conversation_count.to_string(),
);
for matched in &matches {
log.append(
"memory_match",
format!(
"event={} conversation={} title={} role={} content={}",
matched.event_index,
matched.conversation_id,
matched.conversation_title,
matched.role,
matched.content
),
);
}
let language = detect_language(prompt).slug();
let body = render_memory_recall_report(&query, &matches, language);
Some(finalize_simple(
prompt,
log,
"conversation_recall",
"response:conversation_recall",
&body,
0.9,
))
}
fn recognize_recall_query(normalized: &str) -> Option<RecallQuery> {
recall_term_for_role(seed::ROLE_CONVERSATION_RECALL_QUERY, normalized)
.map(|term| RecallQuery {
term,
scope: RecallScope::Conversation,
})
.or_else(|| {
recall_term_for_role(seed::ROLE_CONVERSATION_RECALL_OTHER_QUERY, normalized).map(
|term| RecallQuery {
term,
scope: RecallScope::OtherConversations,
},
)
})
}
fn recall_term_for_role(role: &str, normalized: &str) -> Option<String> {
seed::lexicon()
.role_word_forms(role)
.iter()
.filter_map(|form| term_from_form(form, normalized))
.find(|term| !term.is_empty())
}
fn term_from_form(form: &WordForm, normalized: &str) -> Option<String> {
let raw = match form.slot() {
Slot::Prefix => normalized.strip_prefix(form.before_slot())?,
Slot::Suffix => normalized.strip_suffix(form.after_slot())?,
Slot::Circumfix => normalized
.strip_prefix(form.before_slot())?
.strip_suffix(form.after_slot())?,
Slot::Bare => return None,
};
clean_recall_term(raw)
}
fn clean_recall_term(raw: &str) -> Option<String> {
let term = raw
.trim()
.trim_matches(|ch: char| {
ch.is_whitespace()
|| matches!(
ch,
'`' | '"' | '\'' | ':' | '-' | '_' | '.' | ',' | '?' | '!' | '(' | ')'
)
})
.split_whitespace()
.collect::<Vec<_>>()
.join(" ");
(!term.is_empty()).then_some(term)
}
fn recall_matches(log: &EventLog, term: &str) -> Vec<RecallMatch> {
let needle = normalize_prompt(term);
if needle.is_empty() {
return Vec::new();
}
log.events()
.iter()
.enumerate()
.filter_map(|(index, event)| {
let role = match event.kind {
"prior_turn:user" => "user",
"prior_turn:assistant" => "assistant",
_ => return None,
};
let haystack = normalize_prompt(&event.payload);
haystack.contains(&needle).then(|| RecallMatch {
turn_index: index + 1,
role,
content: event.payload.clone(),
})
})
.collect()
}
fn memory_recall_matches(
events: &[MemoryEvent],
query: &RecallQuery,
current_conversation_id: Option<&str>,
trigger_text: &str,
) -> Vec<MemoryRecallMatch> {
let needle = normalize_prompt(&query.term);
if needle.is_empty() {
return Vec::new();
}
let trigger = normalize_prompt(trigger_text);
events
.iter()
.enumerate()
.filter_map(|(index, event)| {
if event.kind.as_deref().is_some_and(|kind| kind != "message") {
return None;
}
let role = event.role.as_deref()?;
if !role.eq_ignore_ascii_case("user") && !role.eq_ignore_ascii_case("assistant") {
return None;
}
let content = event.content.as_deref()?.trim();
if content.is_empty() {
return None;
}
let haystack = normalize_prompt(content);
if !haystack.contains(&needle) {
return None;
}
if !trigger.is_empty() && haystack == trigger {
return None;
}
let conversation_id = event.conversation_id.as_deref().unwrap_or("legacy");
if query.scope == RecallScope::OtherConversations
&& current_conversation_id.is_some_and(|current| current == conversation_id)
{
return None;
}
Some(MemoryRecallMatch {
event_index: index + 1,
role: role.to_ascii_lowercase(),
content: content.to_owned(),
conversation_id: conversation_id.to_owned(),
conversation_title: event
.conversation_title
.as_deref()
.unwrap_or_default()
.to_owned(),
sent_at: event.sent_at.as_deref().unwrap_or_default().to_owned(),
})
})
.collect()
}
fn memory_conversation_count(matches: &[MemoryRecallMatch]) -> usize {
let mut ids: Vec<&str> = Vec::new();
for matched in matches {
if !ids.contains(&matched.conversation_id.as_str()) {
ids.push(matched.conversation_id.as_str());
}
}
ids.len()
}
fn render_recall_report(query: &RecallQuery, matches: &[RecallMatch], language: &str) -> String {
if matches.is_empty() {
return match language {
"ru" => format!(
"Упоминаний \"{}\" в истории разговора не найдено.",
query.term
),
"zh" => format!("在对话历史中没有找到 \"{}\"。", query.term),
"hi" => format!("बातचीत के इतिहास में \"{}\" नहीं मिला.", query.term),
_ => format!(
"No mentions of \"{}\" found in the conversation history.",
query.term
),
};
}
let mut body = match language {
"ru" => format!(
"Найдено упоминаний \"{}\" в истории разговора: {}\n",
query.term,
matches.len()
),
"zh" => format!(
"在对话历史中找到 \"{}\" 的记录: {}\n",
query.term,
matches.len()
),
"hi" => format!(
"बातचीत के इतिहास में \"{}\" के उल्लेख मिले: {}\n",
query.term,
matches.len()
),
_ => format!(
"Found {} mention(s) of \"{}\" in the conversation history.\n",
matches.len(),
query.term
),
};
for matched in matches {
writeln!(
body,
"- turn {} {}: {}",
matched.turn_index, matched.role, matched.content
)
.expect("string write is infallible");
}
body.trim_end().to_owned()
}
fn render_memory_recall_report(
query: &RecallQuery,
matches: &[MemoryRecallMatch],
language: &str,
) -> String {
if matches.is_empty() {
return match language {
"ru" => format!("Упоминаний \"{}\" в памяти не найдено.", query.term),
"zh" => format!("在记忆中没有找到 \"{}\"。", query.term),
"hi" => format!("स्मृति में \"{}\" नहीं मिला.", query.term),
_ => format!("No mentions of \"{}\" found in memory.", query.term),
};
}
let conversation_count = memory_conversation_count(matches);
let mut body = match language {
"ru" => format!(
"Найдено упоминаний \"{}\" в памяти: {} (бесед: {}).\n",
query.term,
matches.len(),
conversation_count
),
"zh" => format!(
"在记忆中找到 \"{}\" 的记录: {} (对话: {})。\n",
query.term,
matches.len(),
conversation_count
),
"hi" => format!(
"स्मृति में \"{}\" के उल्लेख मिले: {} (बातचीत: {}).\n",
query.term,
matches.len(),
conversation_count
),
_ => format!(
"Found {} mention(s) of \"{}\" across {} conversation(s) in memory.\n",
matches.len(),
query.term,
conversation_count
),
};
let mut conversation_ids: Vec<&str> = Vec::new();
for matched in matches {
if !conversation_ids.contains(&matched.conversation_id.as_str()) {
conversation_ids.push(matched.conversation_id.as_str());
}
}
for conversation_id in conversation_ids {
let title = matches
.iter()
.find(|matched| {
matched.conversation_id == conversation_id && !matched.conversation_title.is_empty()
})
.map_or("", |matched| matched.conversation_title.as_str());
let label = if title.is_empty() || title == conversation_id {
conversation_id.to_owned()
} else {
format!("{title} ({conversation_id})")
};
writeln!(body, "- conversation {label}").expect("string write is infallible");
for matched in matches
.iter()
.filter(|matched| matched.conversation_id == conversation_id)
{
let stamp = if matched.sent_at.is_empty() {
String::new()
} else {
format!(" [{}]", matched.sent_at)
};
writeln!(body, " - {}{}: {}", matched.role, stamp, matched.content)
.expect("string write is infallible");
}
}
body.trim_end().to_owned()
}
fn asks_for_conversation_summary(normalized: &str) -> bool {
let cleaned = normalize_prompt(normalized);
let lexicon = seed::lexicon();
lexicon.mentions_role(seed::ROLE_CONVERSATION_SUMMARY_PHRASE, &cleaned)
|| lexicon.mentions_role(seed::ROLE_CONVERSATION_SUMMARY_COURTESY, &cleaned)
|| (lexicon.mentions_role(seed::ROLE_CONVERSATION_SUMMARY_DIRECTIVE, &cleaned)
&& lexicon.mentions_role(seed::ROLE_CONVERSATION_REFERENCE, &cleaned))
|| summary_directive_leads(&cleaned)
}
fn summary_directive_leads(cleaned: &str) -> bool {
seed::lexicon()
.words_for_role(seed::ROLE_CONVERSATION_SUMMARY_DIRECTIVE)
.iter()
.any(|word| {
if contains_cjk(word) {
cleaned.starts_with(word.as_str())
} else {
cleaned == word.as_str()
}
})
}
fn try_summarize_conversation(
prompt: &str,
normalized: &str,
log: &mut EventLog,
) -> Option<SymbolicAnswer> {
if !asks_for_conversation_summary(normalized) {
return None;
}
let turns: Vec<DialogTurn> = log
.events()
.iter()
.filter_map(|event| match event.kind {
"prior_turn:user" => Some(DialogTurn::user(event.payload.clone())),
"prior_turn:assistant" => Some(DialogTurn::assistant(event.payload.clone())),
_ => None,
})
.collect();
let user_turn_count = turns.iter().filter(|t| t.role == "user").count();
if user_turn_count == 0 {
return None;
}
let language = detect_language(prompt).slug();
let config = SummarizationConfig::default()
.with_mode(SummarizationMode::Standard)
.with_language(language);
let summary = summarize_dialog(&turns, &config);
let title = generate_chat_title(&turns, language);
let user_turns: Vec<&str> = turns
.iter()
.filter(|t| t.role == "user")
.map(|t| t.text.as_str())
.collect();
let mut body = match language {
"ru" => {
format!("Резюме разговора: {summary}\n\nЗаголовок: {title}\n\nРеплики пользователя:\n")
}
"zh" => format!("对话摘要:{summary}\n\n标题:{title}\n\n用户发言:\n"),
_ => format!("Conversation summary: {summary}\n\nTitle: {title}\n\nUser turns:\n"),
};
for (index, turn) in user_turns.iter().enumerate() {
writeln!(body, " {}. {turn}", index + 1).expect("string write is infallible");
}
log.append("filter:user", "conversation_summary".to_owned());
log.append("summarization:mode", "standard".to_owned());
log.append("summarization:language", language.to_owned());
log.append("chat_title", title);
Some(finalize_simple(
prompt,
log,
"summarize_conversation",
"response:summarize_conversation",
body.trim_end(),
0.9,
))
}