use crate::entities::message::{Message, MessageRole};
use crate::shared::i18n::Locale;
use crate::shared::title::sanitize_title;
pub const TITLE_CONTEXT_BUDGET: usize = 4000;
pub fn title_system_message(loc: &Locale) -> String {
loc.t("prompt.title.system").to_string()
}
pub fn build_conversation_digest(messages: &[Message], loc: &Locale) -> Option<String> {
let lines: Vec<String> = messages
.iter()
.filter(|m| matches!(m.role, MessageRole::User | MessageRole::Assistant))
.filter(|m| !m.text.trim().is_empty())
.map(|m| {
let who = match m.role {
MessageRole::User => loc.t("digest.role.user"),
_ => loc.t("digest.role.assistant"),
};
format!("{who}: {}", m.text.trim())
})
.collect();
if lines.is_empty() {
return None;
}
Some(truncate_middle(&lines.join("\n"), TITLE_CONTEXT_BUDGET))
}
fn truncate_middle(text: &str, budget: usize) -> String {
let chars: Vec<char> = text.chars().collect();
if chars.len() <= budget {
return text.to_string();
}
let head = budget * 2 / 3;
let tail = budget - head;
let head_str: String = chars[..head].iter().collect();
let tail_str: String = chars[chars.len() - tail..].iter().collect();
format!("{head_str}\n…\n{tail_str}")
}
pub fn has_assistant_reply(messages: &[Message]) -> bool {
let Some(first_user) = messages.iter().position(|m| m.role == MessageRole::User) else {
return false;
};
messages[first_user..]
.iter()
.any(|m| m.role == MessageRole::Assistant && !m.text.trim().is_empty())
}
pub fn is_first_user_message(messages: &[Message]) -> bool {
messages
.iter()
.filter(|m| m.role == MessageRole::User)
.count()
== 1
}
pub fn clean_generated_title(raw: &str) -> Option<String> {
let first = raw.lines().map(str::trim).find(|l| !l.is_empty())?;
let trimmed = first.trim_matches(|c: char| {
matches!(c, '"' | '\'' | '«' | '»' | '*' | '`') || c.is_whitespace()
});
sanitize_title(trimmed)
}
#[cfg(test)]
mod tests {
use super::*;
fn ru() -> &'static Locale {
crate::shared::i18n::locale(crate::shared::i18n::Lang::Ru)
}
#[test]
fn digest_labels_roles_and_skips_system_and_tools() {
let msgs = vec![
Message::new(MessageRole::System, "ты ассистент"),
Message::user("как дела?"),
Message::assistant("хорошо"),
Message::new(MessageRole::Tool, "tool output"),
];
let digest = build_conversation_digest(&msgs, ru()).unwrap();
assert_eq!(digest, "Пользователь: как дела?\nАссистент: хорошо");
}
#[test]
fn digest_roles_localized_for_all_langs() {
let msgs = vec![Message::user("hi"), Message::assistant("yo")];
for &lang in crate::shared::i18n::Lang::ALL {
let l = crate::shared::i18n::locale(lang);
let d = build_conversation_digest(&msgs, l).unwrap();
assert!(d.contains(l.t("digest.role.user")), "{lang:?}: {d}");
assert!(d.contains(l.t("digest.role.assistant")), "{lang:?}: {d}");
}
}
#[test]
fn digest_is_none_without_meaningful_messages() {
let msgs = vec![
Message::new(MessageRole::System, "sys"),
Message::user(" "),
];
assert!(build_conversation_digest(&msgs, ru()).is_none());
}
#[test]
fn digest_truncates_long_conversation_keeping_head_and_tail() {
let long = "слово ".repeat(2000); let msgs = vec![Message::user(long)];
let digest = build_conversation_digest(&msgs, ru()).unwrap();
assert!(digest.chars().count() <= TITLE_CONTEXT_BUDGET + 16);
assert!(digest.contains('…'), "the middle-truncation marker");
assert!(digest.starts_with("Пользователь:"));
}
#[test]
fn trigger_predicates_see_through_greetings_and_empty_turns() {
let greeting = vec![Message::assistant("привет!")];
assert!(!has_assistant_reply(&greeting));
assert!(!is_first_user_message(&greeting));
let mut msgs = greeting.clone();
msgs.push(Message::user("вопрос"));
assert!(!has_assistant_reply(&msgs));
assert!(is_first_user_message(&msgs));
msgs.push(Message::assistant(" "));
assert!(!has_assistant_reply(&msgs));
msgs.push(Message::new(MessageRole::System, "sys"));
msgs.push(Message::new(MessageRole::Tool, "tool output"));
assert!(!has_assistant_reply(&msgs));
msgs.push(Message::assistant("ответ"));
assert!(has_assistant_reply(&msgs));
msgs.push(Message::user("ещё"));
assert!(!is_first_user_message(&msgs));
}
#[test]
fn clean_title_strips_quotes_and_takes_first_line() {
assert_eq!(
clean_generated_title("«Планы на выходные»").as_deref(),
Some("Планы на выходные")
);
assert_eq!(
clean_generated_title("\"Рецепт борща\"\nещё текст").as_deref(),
Some("Рецепт борща")
);
assert_eq!(clean_generated_title(" \n ").as_deref(), None);
}
}