use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::shared::config::{NoteOrder, SelfModelSettings};
use crate::shared::i18n::Locale;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SelfModelParams {
pub max_narrative: usize,
pub narrative_in_prompt: usize,
pub prompt_cap: usize,
pub max_closed_goals: usize,
pub summary_target_chars: usize,
}
impl Default for SelfModelParams {
fn default() -> Self {
Self::from_settings(&SelfModelSettings::default())
}
}
impl SelfModelParams {
pub fn from_settings(s: &SelfModelSettings) -> Self {
let max_narrative = s.max_narrative.max(1);
Self {
max_narrative,
narrative_in_prompt: s.narrative_in_prompt.min(max_narrative),
prompt_cap: s.prompt_cap.max(100),
max_closed_goals: s.max_closed_goals.max(1),
summary_target_chars: s.summary_target_chars.max(200),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SelfModel {
pub profile_id: Uuid,
#[serde(default)]
pub version: u64,
#[serde(default)]
pub summary: String,
#[serde(default)]
pub goals: Vec<Goal>,
#[serde(default)]
pub user_model: UserModel,
#[serde(default)]
pub narrative: Vec<NarrativeSegment>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct NarrativeSegment {
pub id: Uuid,
pub text: String,
pub created_at: DateTime<Utc>,
}
pub fn order_narrative(narrative: &[NarrativeSegment], order: NoteOrder) -> Vec<&NarrativeSegment> {
let mut out: Vec<&NarrativeSegment> = narrative.iter().collect();
match order {
NoteOrder::NewestFirst => out.sort_by_key(|s| std::cmp::Reverse(s.created_at)),
NoteOrder::OldestFirst => out.sort_by_key(|s| s.created_at),
}
out
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Goal {
pub id: Uuid,
pub description: String,
pub status: GoalStatus,
pub created_at: DateTime<Utc>,
#[serde(default)]
pub closed_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum GoalStatus {
Active,
Completed,
Abandoned,
}
const CLOSED_GOALS_SHOWN: usize = 5;
const SHARE_SUMMARY: usize = 40;
const SHARE_GOALS: usize = 20;
const SHARE_USER: usize = 20;
const SHARE_OBSERVATIONS: usize = 20;
const MORE_MARKER_RESERVE: usize = 12;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GoalMatch {
One(Uuid),
None,
Ambiguous,
}
fn short_hex(id: &Uuid) -> String {
id.simple().to_string()[..6].to_string()
}
pub fn short_id(id: &Uuid) -> String {
short_hex(id)
}
fn assign_trimmed(slot: &mut String, value: &str) -> bool {
let value = value.trim().to_string();
if *slot == value {
return false;
}
*slot = value;
true
}
fn assign_list(slot: &mut Vec<String>, value: Vec<String>) -> bool {
if *slot == value {
return false;
}
*slot = value;
true
}
fn stamp_closed(g: &mut Goal) {
match g.status {
GoalStatus::Active => g.closed_at = None,
_ => {
if g.closed_at.is_none() {
g.closed_at = Some(Utc::now());
}
}
}
}
fn age_label(at: DateTime<Utc>, now: DateTime<Utc>, loc: &Locale) -> String {
let days = (now - at).num_days();
match days {
d if d <= 0 => loc.t("selfmodel.age.today").to_string(),
1 => loc.t("selfmodel.age.yesterday").to_string(),
2..=6 => loc.tf("selfmodel.age.days", &[("n", &days.to_string())]),
7..=30 => loc.tf("selfmodel.age.weeks", &[("n", &(days / 7).to_string())]),
31..=364 => loc.tf("selfmodel.age.months", &[("n", &(days / 30).to_string())]),
_ => loc.tf("selfmodel.age.years", &[("n", &(days / 365).to_string())]),
}
}
fn resolve_handle(handle: &str, ids: &[Uuid]) -> GoalMatch {
let h = handle.trim().trim_start_matches('#').to_lowercase();
if h.is_empty() {
return GoalMatch::None;
}
if let Ok(u) = Uuid::parse_str(&h) {
return if ids.contains(&u) {
GoalMatch::One(u)
} else {
GoalMatch::None
};
}
let mut found: Option<Uuid> = None;
for id in ids {
if id.simple().to_string().starts_with(&h) {
if found.is_some() {
return GoalMatch::Ambiguous;
}
found = Some(*id);
}
}
found.map_or(GoalMatch::None, GoalMatch::One)
}
#[derive(Debug, Clone, PartialEq)]
pub enum SelfModelEdit {
SetSummary(String),
AddGoal(String),
SetGoalText { id: Uuid, text: String },
CycleGoalStatus(Uuid),
DeleteGoal(Uuid),
SetTraits(Vec<String>),
SetInterests(Vec<String>),
SetRelationship(String),
DeleteInsight(Uuid),
Clear,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct UserModel {
#[serde(default)]
pub perceived_traits: Vec<String>,
#[serde(default)]
pub current_interests: Vec<String>,
#[serde(default)]
pub relationship_dynamic: String,
}
impl SelfModel {
pub fn new(profile_id: Uuid) -> Self {
Self {
profile_id,
version: 0,
summary: String::new(),
goals: Vec::new(),
user_model: UserModel::default(),
narrative: Vec::new(),
updated_at: Utc::now(),
}
}
pub fn is_empty(&self) -> bool {
self.summary.trim().is_empty()
&& self.active_goals().next().is_none()
&& self.user_model.is_empty()
}
pub fn active_goals(&self) -> impl Iterator<Item = &Goal> {
self.goals.iter().filter(|g| g.status == GoalStatus::Active)
}
pub fn add_goal(&mut self, description: impl Into<String>) {
let description = description.into().trim().to_string();
if description.is_empty() {
return;
}
self.goals.push(Goal {
id: Uuid::new_v4(),
description,
status: GoalStatus::Active,
created_at: Utc::now(),
closed_at: None,
});
}
pub fn set_goal_status(&mut self, id: Uuid, status: GoalStatus) -> bool {
if let Some(g) = self.goals.iter_mut().find(|g| g.id == id) {
g.status = status;
stamp_closed(g);
true
} else {
false
}
}
pub fn cycle_goal_status(&mut self, id: Uuid) -> bool {
if let Some(g) = self.goals.iter_mut().find(|g| g.id == id) {
g.status = match g.status {
GoalStatus::Active => GoalStatus::Completed,
GoalStatus::Completed => GoalStatus::Abandoned,
GoalStatus::Abandoned => GoalStatus::Active,
};
stamp_closed(g);
true
} else {
false
}
}
pub fn apply_edit(&mut self, edit: SelfModelEdit) -> bool {
match edit {
SelfModelEdit::SetSummary(s) => assign_trimmed(&mut self.summary, &s),
SelfModelEdit::AddGoal(desc) => {
let before = self.goals.len();
self.add_goal(desc);
self.goals.len() != before
}
SelfModelEdit::SetGoalText { id, text } => self.set_goal_text(id, &text),
SelfModelEdit::CycleGoalStatus(id) => self.cycle_goal_status(id),
SelfModelEdit::DeleteGoal(id) => {
let before = self.goals.len();
self.goals.retain(|g| g.id != id);
self.goals.len() != before
}
SelfModelEdit::SetTraits(v) => assign_list(&mut self.user_model.perceived_traits, v),
SelfModelEdit::SetInterests(v) => {
assign_list(&mut self.user_model.current_interests, v)
}
SelfModelEdit::SetRelationship(s) => {
assign_trimmed(&mut self.user_model.relationship_dynamic, &s)
}
SelfModelEdit::DeleteInsight(id) => {
let before = self.narrative.len();
self.narrative.retain(|n| n.id != id);
self.narrative.len() != before
}
SelfModelEdit::Clear => self.clear_all(),
}
}
fn set_goal_text(&mut self, id: Uuid, text: &str) -> bool {
let text = text.trim().to_string();
if let Some(g) = self.goals.iter_mut().find(|g| g.id == id) {
if text.is_empty() || g.description == text {
return false;
}
g.description = text;
true
} else {
false
}
}
fn clear_all(&mut self) -> bool {
if self.is_empty() && self.summary.is_empty() && self.goals.is_empty() {
return false;
}
self.summary.clear();
self.goals.clear();
self.user_model = UserModel::default();
self.narrative.clear();
true
}
pub fn fold_closed_goals(&mut self, keep: usize, loc: &Locale) -> Vec<String> {
let freshness = |g: &Goal| g.closed_at.unwrap_or(g.created_at);
let mut closed: Vec<(Uuid, DateTime<Utc>)> = self
.goals
.iter()
.filter(|g| g.status != GoalStatus::Active)
.map(|g| (g.id, freshness(g)))
.collect();
if closed.len() <= keep {
return Vec::new();
}
closed.sort_by_key(|(_, at)| std::cmp::Reverse(*at)); let fold_ids: std::collections::HashSet<Uuid> =
closed[keep..].iter().map(|(id, _)| *id).collect();
let scars: Vec<String> = self
.goals
.iter()
.filter(|g| fold_ids.contains(&g.id))
.map(|g| {
let verb = loc.t(match g.status {
GoalStatus::Completed => "selfmodel.status.completed",
GoalStatus::Abandoned => "selfmodel.status.abandoned",
GoalStatus::Active => "selfmodel.status.active",
});
loc.tf(
"selfmodel.goal_archive",
&[("verb", verb), ("text", g.description.trim())],
)
})
.collect();
self.goals.retain(|g| !fold_ids.contains(&g.id));
scars
}
pub fn summary_fill_hint(&self, target: usize, loc: &Locale) -> Option<String> {
let n = self.summary.chars().count();
(n > target).then(|| {
loc.tf(
"selfmodel.summary_hint",
&[("n", &n.to_string()), ("target", &target.to_string())],
)
})
}
pub fn render_for_prompt(
&self,
max_chars: usize,
narrative_in_prompt: usize,
now: DateTime<Utc>,
recent: &[NarrativeSegment],
loc: &Locale,
) -> Option<String> {
if self.is_empty() && recent.is_empty() {
return None;
}
let mut out = format!("{}\n", loc.t("selfmodel.render.header"));
let share = |pct: usize| max_chars * pct / 100;
let mut carry = 0usize;
let budget = share(SHARE_SUMMARY) + carry;
carry = budget;
if !self.summary.trim().is_empty() {
let text = truncate_chars_word(self.summary.trim(), budget);
carry = budget.saturating_sub(text.chars().count());
out.push_str(loc.t("selfmodel.render.about"));
out.push_str(&text);
out.push('\n');
}
let budget = share(SHARE_GOALS) + carry;
carry = budget;
let active: Vec<&Goal> = self.active_goals().collect();
if !active.is_empty() {
let items: Vec<String> = active
.iter()
.map(|g| {
loc.tf(
"selfmodel.item.goal_prompt",
&[
("desc", g.description.trim()),
("age", &age_label(g.created_at, now, loc)),
],
)
})
.collect();
let (text, used) = fit_lines(&items, budget, loc);
carry = budget.saturating_sub(used);
out.push_str(loc.t("selfmodel.render.goals_active"));
out.push('\n');
out.push_str(&text);
}
let budget = share(SHARE_USER) + carry;
carry = budget;
if !self.user_model.is_empty() {
let used = render_user_model(&mut out, &self.user_model, budget, loc);
carry = budget.saturating_sub(used);
}
if !recent.is_empty() && narrative_in_prompt > 0 {
let budget = share(SHARE_OBSERVATIONS) + carry;
let items: Vec<String> = recent
.iter()
.take(narrative_in_prompt)
.map(|seg| {
loc.tf(
"selfmodel.item.obs_prompt",
&[
("age", &age_label(seg.created_at, now, loc)),
("text", seg.text.trim()),
],
)
})
.collect();
let (text, _) = fit_lines(&items, budget, loc);
out.push_str(loc.t("selfmodel.render.observations_recent"));
out.push('\n');
out.push_str(&text);
}
Some(truncate_chars(out.trim_end(), max_chars))
}
pub fn match_goal(&self, handle: &str) -> GoalMatch {
let ids: Vec<Uuid> = self.goals.iter().map(|g| g.id).collect();
resolve_handle(handle, &ids)
}
pub fn render_full(
&self,
now: DateTime<Utc>,
recent: &[NarrativeSegment],
loc: &Locale,
) -> String {
let header = loc.t("selfmodel.render.header");
let mut out = format!("{header}\n");
if !self.summary.trim().is_empty() {
out.push_str(loc.t("selfmodel.render.about"));
out.push_str(self.summary.trim());
out.push('\n');
}
let active: Vec<&Goal> = self.active_goals().collect();
let closed: Vec<&Goal> = self
.goals
.iter()
.filter(|g| g.status != GoalStatus::Active)
.collect();
if !active.is_empty() || !closed.is_empty() {
out.push_str(loc.t("selfmodel.render.goals_ref"));
out.push('\n');
let active_st = loc.t("selfmodel.status.active");
for g in &active {
out.push_str(&loc.tf(
"selfmodel.item.goal_full",
&[
("id", &short_hex(&g.id)),
("status", active_st),
("age", &age_label(g.created_at, now, loc)),
("text", g.description.trim()),
],
));
out.push('\n');
}
for g in closed.iter().rev().take(CLOSED_GOALS_SHOWN) {
let st = loc.t(match g.status {
GoalStatus::Completed => "selfmodel.status.completed",
GoalStatus::Abandoned => "selfmodel.status.stale",
GoalStatus::Active => "selfmodel.status.active",
});
out.push_str(&loc.tf(
"selfmodel.item.goal_full",
&[
("id", &short_hex(&g.id)),
("status", st),
(
"age",
&age_label(g.closed_at.unwrap_or(g.created_at), now, loc),
),
("text", g.description.trim()),
],
));
out.push('\n');
}
}
render_user_model(&mut out, &self.user_model, usize::MAX, loc);
if !recent.is_empty() {
out.push_str(&loc.tf(
"selfmodel.render.observations",
&[("n", &recent.len().to_string())],
));
out.push('\n');
for seg in recent {
out.push_str(&loc.tf(
"selfmodel.item.obs_full",
&[
("id", &seg.id.to_string()),
("age", &age_label(seg.created_at, now, loc)),
("text", seg.text.trim()),
],
));
out.push('\n');
}
}
let body = out.trim_end();
if body == header {
return loc.t("selfmodel.render.empty").to_string();
}
body.to_string()
}
}
fn render_user_model(out: &mut String, u: &UserModel, budget: usize, loc: &Locale) -> usize {
if u.is_empty() {
return 0;
}
let mut used = 0usize;
let mut body = String::new();
let mut left = budget;
if !u.perceived_traits.is_empty() {
let (text, n) = fit_items(&u.perceived_traits, left.div_ceil(2), loc);
if !text.is_empty() {
body.push_str(loc.t("selfmodel.render.user.traits"));
body.push_str(&text);
body.push(';');
used += n;
left = left.saturating_sub(n);
}
}
if !u.current_interests.is_empty() {
let (text, n) = fit_items(&u.current_interests, left.div_ceil(2), loc);
if !text.is_empty() {
body.push_str(loc.t("selfmodel.render.user.interests"));
body.push_str(&text);
body.push(';');
used += n;
left = left.saturating_sub(n);
}
}
let dynamic = u.relationship_dynamic.trim();
if !dynamic.is_empty() && left > 0 {
let text = truncate_chars_word(dynamic, left);
body.push_str(loc.t("selfmodel.render.user.relationship"));
body.push_str(&text);
used += text.chars().count();
}
if body.is_empty() {
return 0;
}
out.push_str(loc.t("selfmodel.render.user"));
out.push_str(&body);
out.push('\n');
used
}
fn fit_items(items: &[String], budget: usize, loc: &Locale) -> (String, usize) {
fit_parts(items, ", ", budget, loc)
}
fn fit_lines(items: &[String], budget: usize, loc: &Locale) -> (String, usize) {
let (text, used) = fit_parts(items, "\n", budget, loc);
if text.is_empty() {
(text, used)
} else {
(format!("{text}\n"), used)
}
}
fn fit_parts(items: &[String], sep: &str, budget: usize, loc: &Locale) -> (String, usize) {
let mut out = String::new();
let mut used = 0usize;
let mut taken = 0usize;
for item in items {
let item = item.trim();
let extra = item.chars().count() + if taken == 0 { 0 } else { sep.chars().count() };
let marker = if taken + 1 == items.len() {
0
} else {
MORE_MARKER_RESERVE
};
if used + extra + marker > budget {
break;
}
if taken > 0 {
out.push_str(sep);
}
out.push_str(item);
used += extra;
taken += 1;
}
if taken == 0 {
let head = truncate_chars_word(items[0].trim(), budget);
used = head.chars().count();
out = head;
taken = 1;
}
if taken < items.len() {
let more = loc.tf(
"selfmodel.render.more",
&[("n", &(items.len() - taken).to_string())],
);
used += more.chars().count();
out.push_str(&more);
}
(out, used)
}
impl UserModel {
pub fn is_empty(&self) -> bool {
self.perceived_traits.is_empty()
&& self.current_interests.is_empty()
&& self.relationship_dynamic.trim().is_empty()
}
pub fn render_for_impersonation(&self, max_chars: usize, loc: &Locale) -> Option<String> {
if self.is_empty() {
return None;
}
let mut out = loc.t("selfmodel.render.impersonation.intro").to_string();
if !self.perceived_traits.is_empty() {
out.push_str(loc.t("selfmodel.render.impersonation.traits"));
out.push_str(&self.perceived_traits.join(", "));
out.push(';');
}
if !self.current_interests.is_empty() {
out.push_str(loc.t("selfmodel.render.impersonation.interests"));
out.push_str(&self.current_interests.join(", "));
out.push(';');
}
if !self.relationship_dynamic.trim().is_empty() {
out.push_str(loc.t("selfmodel.render.impersonation.relationship"));
out.push_str(self.relationship_dynamic.trim());
}
Some(truncate_chars(out.trim_end_matches([';', ' ']), max_chars))
}
pub fn add_traits(&mut self, items: Vec<String>) -> bool {
merge_into(&mut self.perceived_traits, items)
}
pub fn remove_traits(&mut self, items: &[String]) -> bool {
remove_from(&mut self.perceived_traits, items)
}
pub fn add_interests(&mut self, items: Vec<String>) -> bool {
merge_into(&mut self.current_interests, items)
}
pub fn remove_interests(&mut self, items: &[String]) -> bool {
remove_from(&mut self.current_interests, items)
}
}
fn merge_into(list: &mut Vec<String>, items: Vec<String>) -> bool {
let mut changed = false;
for it in items {
let it = it.trim().to_string();
if it.is_empty() {
continue;
}
let lc = it.to_lowercase();
if !list.iter().any(|x| x.to_lowercase() == lc) {
list.push(it);
changed = true;
}
}
changed
}
fn remove_from(list: &mut Vec<String>, items: &[String]) -> bool {
let targets: Vec<String> = items
.iter()
.map(|s| s.trim().to_lowercase())
.filter(|s| !s.is_empty())
.collect();
if targets.is_empty() {
return false;
}
let before = list.len();
list.retain(|x| !targets.contains(&x.to_lowercase()));
list.len() != before
}
fn truncate_chars(s: &str, max_chars: usize) -> String {
if s.chars().count() <= max_chars {
return s.to_string();
}
let take = max_chars.saturating_sub(1);
let mut out: String = s.chars().take(take).collect();
out.push('…');
out
}
fn truncate_chars_word(s: &str, max_chars: usize) -> String {
if s.chars().count() <= max_chars {
return s.to_string();
}
let take = max_chars.saturating_sub(1);
let head: String = s.chars().take(take).collect();
let base = match head.rfind(char::is_whitespace) {
Some(idx) => head[..idx].trim_end(),
None => head.as_str(),
};
let base = if base.is_empty() { head.as_str() } else { base };
format!("{base}…")
}
#[cfg(test)]
mod tests {
use super::*;
fn p() -> SelfModelParams {
SelfModelParams::default()
}
fn now() -> DateTime<Utc> {
Utc::now()
}
fn loc() -> &'static Locale {
crate::shared::i18n::locale(crate::shared::i18n::Lang::Ru)
}
fn seg(text: &str) -> NarrativeSegment {
NarrativeSegment {
id: Uuid::new_v4(),
text: text.into(),
created_at: Utc::now(),
}
}
#[test]
fn empty_model_renders_none() {
let m = SelfModel::new(Uuid::new_v4());
assert!(m.is_empty());
assert!(
m.render_for_prompt(p().prompt_cap, p().narrative_in_prompt, now(), &[], loc())
.is_none()
);
}
#[test]
fn render_includes_recent_observations() {
let m = SelfModel::new(Uuid::new_v4());
let recent = [seg("заметил напряжение между «кратко» и «полно»")];
let r = m
.render_for_prompt(
p().prompt_cap,
p().narrative_in_prompt,
now(),
&recent,
loc(),
)
.unwrap();
assert!(r.contains("Недавние наблюдения:"));
assert!(r.contains("напряжение"));
}
#[test]
fn render_for_prompt_takes_freshest_n() {
let params = SelfModelParams::from_settings(&SelfModelSettings {
narrative_in_prompt: 2,
prompt_cap: 1000,
..SelfModelSettings::default()
});
let m = SelfModel::new(Uuid::new_v4());
let recent: Vec<NarrativeSegment> =
(0..10).rev().map(|i| seg(&format!("инсайт {i}"))).collect();
let r = m
.render_for_prompt(
params.prompt_cap,
params.narrative_in_prompt,
now(),
&recent,
loc(),
)
.unwrap();
assert_eq!(r.matches("инсайт ").count(), 2);
assert!(r.contains("инсайт 9"));
assert!(r.contains("инсайт 8"));
}
#[test]
fn add_and_complete_goals() {
let mut m = SelfModel::new(Uuid::new_v4());
m.add_goal("помочь с рефакторингом");
m.add_goal(" "); assert_eq!(m.goals.len(), 1);
assert_eq!(m.active_goals().count(), 1);
let id = m.goals[0].id;
assert!(m.set_goal_status(id, GoalStatus::Completed));
assert_eq!(m.active_goals().count(), 0);
assert!(!m.set_goal_status(Uuid::new_v4(), GoalStatus::Abandoned));
}
fn bloated_model() -> SelfModel {
let mut m = SelfModel::new(Uuid::new_v4());
m.summary = "описание ".repeat(150); for i in 0..5 {
m.add_goal(format!("цель номер {i} с довольно длинной формулировкой"));
}
m.user_model.perceived_traits = (0..16)
.map(|i| format!("черта номер {i} с пояснением на сотню символов примерно вот так"))
.collect();
m.user_model.current_interests = (0..16).map(|i| format!("интерес номер {i}")).collect();
m.user_model.relationship_dynamic = "рабочие, доверительные".into();
m
}
#[test]
fn every_section_survives_a_bloated_model() {
let m = bloated_model();
let recent = vec![seg("наблюдение про кэш")];
let r = m.render_for_prompt(4000, 3, now(), &recent, loc()).unwrap();
assert!(r.contains("О себе:"), "{r}");
assert!(r.contains("цель номер 0"), "goals present: {r}");
assert!(r.contains("черта номер 0"), "interlocutor present: {r}");
assert!(
r.contains("наблюдение про кэш"),
"observations present: {r}"
);
assert!(r.chars().count() <= 4000);
}
#[test]
fn every_section_survives_a_small_budget() {
let m = bloated_model();
let recent = vec![seg("наблюдение про кэш")];
let r = m.render_for_prompt(1200, 3, now(), &recent, loc()).unwrap();
assert!(r.contains("О себе:"), "{r}");
assert!(r.contains("цель номер 0"), "{r}");
assert!(r.contains("черта номер 0"), "{r}");
assert!(r.contains("наблюдение про кэш"), "{r}");
}
#[test]
fn unused_room_flows_to_the_next_section() {
let mut short = bloated_model();
short.summary = "коротко".into();
let long = bloated_model();
let goals_of = |m: &SelfModel| {
let r = m.render_for_prompt(1200, 0, now(), &[], loc()).unwrap();
(0..5)
.filter(|i| r.contains(&format!("цель номер {i}")))
.count()
};
assert!(
goals_of(&short) > goals_of(&long),
"a short description leaves the goals more room: {} vs {}",
goals_of(&short),
goals_of(&long)
);
}
#[test]
fn lists_drop_whole_items_and_report_the_count() {
let m = bloated_model();
let r = m.render_for_prompt(1200, 0, now(), &[], loc()).unwrap();
let traits = r
.split("черты: ")
.nth(1)
.expect("traits section")
.split(';')
.next()
.unwrap();
assert!(traits.contains("ещё "), "dropped count reported: {traits}");
let listed = traits.split('…').next().unwrap();
for part in listed.split(", ") {
let part = part.trim();
if part.is_empty() {
continue;
}
assert!(
m.user_model.perceived_traits.iter().any(|t| t == part),
"a partial item leaked into the list: {part:?}"
);
}
}
#[test]
fn an_oversized_single_item_degrades_to_a_cut() {
let mut m = SelfModel::new(Uuid::new_v4());
m.user_model.perceived_traits = vec!["очень длинная черта ".repeat(50)];
let r = m.render_for_prompt(400, 0, now(), &[], loc()).unwrap();
assert!(r.contains("черты: очень длинная"), "{r}");
assert!(r.chars().count() <= 400);
}
#[test]
fn render_includes_sections() {
let mut m = SelfModel::new(Uuid::new_v4());
m.summary = "ценю честность".into();
m.add_goal("разобраться в коде");
m.user_model.perceived_traits = vec!["любопытный".into()];
m.user_model.current_interests = vec!["Rust".into()];
m.user_model.relationship_dynamic = "доверительные".into();
let r = m
.render_for_prompt(p().prompt_cap, p().narrative_in_prompt, now(), &[], loc())
.unwrap();
assert!(r.contains("О себе: ценю честность"));
assert!(r.contains("разобраться в коде"));
assert!(r.contains("черты: любопытный"));
assert!(r.contains("интересы: Rust"));
assert!(r.contains("отношения: доверительные"));
}
fn dated_seg(mins: i64) -> NarrativeSegment {
NarrativeSegment {
id: Uuid::new_v4(),
text: format!("obs {mins}"),
created_at: chrono::TimeZone::timestamp_opt(&Utc, mins * 60, 0).unwrap(),
}
}
#[test]
fn narrative_is_ordered_by_its_own_date_both_ways() {
let input = vec![dated_seg(20), dated_seg(5), dated_seg(30), dated_seg(10)];
let texts = |order| {
order_narrative(&input, order)
.into_iter()
.map(|s| s.text.clone())
.collect::<Vec<_>>()
};
assert_eq!(
texts(NoteOrder::NewestFirst),
["obs 30", "obs 20", "obs 10", "obs 5"]
);
assert_eq!(
texts(NoteOrder::OldestFirst),
["obs 5", "obs 10", "obs 20", "obs 30"]
);
assert_eq!(texts(NoteOrder::default()), texts(NoteOrder::NewestFirst));
}
#[test]
fn same_instant_segments_keep_the_incoming_order() {
let (a, b) = (dated_seg(7), dated_seg(7));
let input = vec![
NarrativeSegment {
text: "first".into(),
..a
},
NarrativeSegment {
text: "second".into(),
..b
},
];
for order in [NoteOrder::NewestFirst, NoteOrder::OldestFirst] {
let texts: Vec<&str> = order_narrative(&input, order)
.into_iter()
.map(|s| s.text.as_str())
.collect();
assert_eq!(texts, ["first", "second"], "{order:?}");
}
}
#[test]
fn params_sanitize_inconsistent_settings() {
let params = SelfModelParams::from_settings(&SelfModelSettings {
max_narrative: 0,
narrative_in_prompt: 99,
prompt_cap: 1,
..SelfModelSettings::default()
});
assert_eq!(params.max_narrative, 1);
assert_eq!(params.narrative_in_prompt, 1);
assert_eq!(params.prompt_cap, 100);
}
#[test]
fn summary_target_sanitized_to_floor() {
let params = SelfModelParams::from_settings(&SelfModelSettings {
summary_target_chars: 10,
..SelfModelSettings::default()
});
assert_eq!(params.summary_target_chars, 200);
}
#[test]
fn summary_fill_hint_only_over_target() {
let mut m = SelfModel::new(Uuid::new_v4());
m.summary = "к".repeat(50);
assert!(m.summary_fill_hint(100, loc()).is_none());
m.summary = "к".repeat(150);
let hint = m.summary_fill_hint(100, loc()).unwrap();
assert!(hint.contains("150"));
assert!(hint.contains("100"));
assert!(hint.contains("add_insight"));
}
#[test]
fn completed_goals_not_rendered() {
let mut m = SelfModel::new(Uuid::new_v4());
m.add_goal("старая цель");
let id = m.goals[0].id;
m.set_goal_status(id, GoalStatus::Completed);
assert!(m.is_empty());
assert!(
m.render_for_prompt(p().prompt_cap, p().narrative_in_prompt, now(), &[], loc())
.is_none()
);
}
#[test]
fn apply_edit_covers_operations() {
let mut m = SelfModel::new(Uuid::new_v4());
assert!(m.apply_edit(SelfModelEdit::SetSummary("я краток".into())));
assert_eq!(m.summary, "я краток");
assert!(!m.apply_edit(SelfModelEdit::SetSummary("я краток".into())));
assert!(m.apply_edit(SelfModelEdit::AddGoal("помочь".into())));
let gid = m.goals[0].id;
assert!(m.apply_edit(SelfModelEdit::SetGoalText {
id: gid,
text: "помочь лучше".into()
}));
assert_eq!(m.goals[0].description, "помочь лучше");
assert!(m.apply_edit(SelfModelEdit::CycleGoalStatus(gid)));
assert_eq!(m.goals[0].status, GoalStatus::Completed);
assert!(m.apply_edit(SelfModelEdit::DeleteGoal(gid)));
assert!(m.goals.is_empty());
assert!(m.apply_edit(SelfModelEdit::SetTraits(vec!["скептик".into()])));
assert!(m.apply_edit(SelfModelEdit::SetInterests(vec!["Rust".into()])));
assert!(m.apply_edit(SelfModelEdit::SetRelationship("рабочие".into())));
assert_eq!(m.user_model.perceived_traits, vec!["скептик".to_string()]);
m.narrative.push(seg("наблюдение"));
let iid = m.narrative[0].id;
assert!(m.apply_edit(SelfModelEdit::DeleteInsight(iid)));
assert!(m.narrative.is_empty());
assert!(m.apply_edit(SelfModelEdit::Clear));
assert!(m.is_empty());
assert!(!m.apply_edit(SelfModelEdit::Clear));
assert!(!m.apply_edit(SelfModelEdit::DeleteGoal(Uuid::new_v4())));
}
#[test]
fn bloated_summary_does_not_starve_sections() {
let mut m = SelfModel::new(Uuid::new_v4());
m.summary = "слово ".repeat(400); m.add_goal("активная цель");
m.user_model.perceived_traits = vec!["внимательный".into()];
let recent = [seg("свежее наблюдение о стиле")];
let r = m.render_for_prompt(1200, 3, now(), &recent, loc()).unwrap();
assert!(r.contains("Активные цели:"), "goals crowded out: {r}");
assert!(
r.contains("О собеседнике:"),
"interlocutor crowded out: {r}"
);
assert!(
r.contains("Недавние наблюдения:"),
"observations crowded out: {r}"
);
assert!(r.chars().count() <= 1200);
assert!(r.contains("О себе: "));
}
#[test]
fn small_summary_not_truncated() {
let mut m = SelfModel::new(Uuid::new_v4());
m.summary = "ценю ясность и краткость".into();
let r = m
.render_for_prompt(1200, p().narrative_in_prompt, now(), &[], loc())
.unwrap();
assert!(r.contains("О себе: ценю ясность и краткость"));
assert!(!r.contains('…'));
}
#[test]
fn truncate_word_does_not_split_word() {
let s = "первое второе третье четвёртое пятое";
let out = truncate_chars_word(s, 20);
assert!(out.ends_with('…'));
assert!(out.chars().count() <= 20);
let body = out.trim_end_matches('…');
assert!(s.starts_with(body.trim_end()));
assert!(!body.trim_end().is_empty());
let long = "я".repeat(50);
let out = truncate_chars_word(&long, 10);
assert_eq!(out.chars().count(), 10);
assert!(out.ends_with('…'));
}
#[test]
fn render_truncates_to_cap() {
let mut m = SelfModel::new(Uuid::new_v4());
m.summary = "я".repeat(500);
let r = m
.render_for_prompt(50, p().narrative_in_prompt, now(), &[], loc())
.unwrap();
assert!(r.chars().count() <= 50, "{r:?}");
assert!(r.ends_with('…'), "the description was truncated: {r:?}");
}
#[test]
fn render_full_shows_goal_ids_and_full_observation_ids() {
let mut m = SelfModel::new(Uuid::new_v4());
m.summary = "я".repeat(500);
m.add_goal("активная цель");
m.add_goal("завершённая цель");
let done = m.goals[1].id;
m.set_goal_status(done, GoalStatus::Completed);
let recent: Vec<NarrativeSegment> =
(0..12).rev().map(|i| seg(&format!("инсайт {i}"))).collect();
let full = m.render_full(now(), &recent, loc());
assert!(!full.ends_with('…'), "a full read isn't truncated");
let short = short_hex(&m.goals[0].id);
assert!(full.contains(&format!("#{short} (активна · сегодня) активная цель")));
assert!(full.contains("(выполнена · сегодня) завершённая цель"));
assert_eq!(full.matches("инсайт ").count(), 12);
assert!(full.contains("Наблюдения (12"));
assert!(full.contains(&format!("(id={})", recent[0].id)));
assert!(full.contains(&"я".repeat(500)));
}
#[test]
fn render_full_on_empty_marks_empty() {
let m = SelfModel::new(Uuid::new_v4());
assert_eq!(m.render_full(now(), &[], loc()), "(модель себя пока пуста)");
}
#[test]
fn match_goal_by_prefix_full_and_ambiguous() {
let mut m = SelfModel::new(Uuid::new_v4());
m.add_goal("первая");
let id = m.goals[0].id;
assert_eq!(m.match_goal(&id.to_string()), GoalMatch::One(id));
let short = short_hex(&id);
assert_eq!(m.match_goal(&format!("#{short}")), GoalMatch::One(id));
assert_eq!(m.match_goal(&short.to_uppercase()), GoalMatch::One(id));
assert_eq!(m.match_goal("zzzzzz"), GoalMatch::None);
assert_eq!(m.match_goal(""), GoalMatch::None);
m.add_goal("вторая");
assert_eq!(m.match_goal("#"), GoalMatch::None); }
#[test]
fn user_model_merge_add_remove() {
let mut u = UserModel::default();
assert!(u.add_traits(vec!["добрый".into(), "Добрый".into(), " ".into()]));
assert_eq!(u.perceived_traits, vec!["добрый".to_string()]);
assert!(u.add_traits(vec!["прямолинейный".into()]));
assert_eq!(u.perceived_traits.len(), 2);
assert!(!u.add_traits(vec!["добрый".into()]));
assert!(u.remove_traits(&["ДОБРЫЙ".into()]));
assert_eq!(u.perceived_traits, vec!["прямолинейный".to_string()]);
assert!(!u.remove_traits(&["нет такого".into()]));
}
#[test]
fn user_model_render_for_impersonation() {
assert!(
UserModel::default()
.render_for_impersonation(500, loc())
.is_none()
);
let u = UserModel {
perceived_traits: vec!["скептик".into(), "любопытный".into()],
current_interests: vec!["Rust".into()],
relationship_dynamic: "доверительные, на равных".into(),
};
let r = u.render_for_impersonation(500, loc()).unwrap();
assert!(r.contains("за которого ты пишешь"));
assert!(r.contains("черты — скептик, любопытный"));
assert!(r.contains("интересы — Rust"));
assert!(r.contains("отношения с собеседником — доверительные, на равных"));
}
#[test]
fn render_for_impersonation_localized_for_all_langs() {
let u = UserModel {
perceived_traits: vec!["skeptic".into()],
current_interests: vec!["Rust".into()],
relationship_dynamic: "trusting, as equals".into(),
};
for &lang in crate::shared::i18n::Lang::ALL {
let l = crate::shared::i18n::locale(lang);
let r = u.render_for_impersonation(500, l).unwrap();
assert!(!r.contains('{') && !r.contains('}'), "{lang:?}: {r}");
if lang == crate::shared::i18n::Lang::En {
assert!(
!r.chars().any(|c| ('\u{0400}'..='\u{04FF}').contains(&c)),
"Cyrillic leaked into en: {r}"
);
}
}
}
#[test]
fn render_localized_for_all_langs() {
for &lang in crate::shared::i18n::Lang::ALL {
let l = crate::shared::i18n::locale(lang);
let mut m = SelfModel::new(Uuid::new_v4());
m.summary = "s".into();
m.add_goal("g");
m.user_model.perceived_traits = vec!["t".into()];
let recent = [seg("obs")];
let r = m
.render_for_prompt(p().prompt_cap, p().narrative_in_prompt, now(), &recent, l)
.unwrap();
assert!(r.contains(l.t("selfmodel.render.header")), "{lang:?}: {r}");
assert!(r.contains(l.t("selfmodel.render.goals_active")), "{lang:?}");
assert!(r.contains(l.t("selfmodel.render.user")), "{lang:?}");
assert!(
r.contains(l.t("selfmodel.render.observations_recent")),
"{lang:?}"
);
assert!(!r.contains('{'), "{lang:?}: leftover placeholder: {r}");
let full = m.render_full(now(), &recent, l);
assert!(full.contains(l.t("selfmodel.render.goals_ref")), "{lang:?}");
assert!(!full.contains('{'), "{lang:?}: placeholder in full: {full}");
}
}
#[test]
fn age_label_localized_for_all_langs() {
use chrono::Duration;
let base = Utc::now();
for &lang in crate::shared::i18n::Lang::ALL {
let l = crate::shared::i18n::locale(lang);
for d in [0i64, 1, 3, 10, 40, 400] {
let s = age_label(base - Duration::days(d), base, l);
assert!(!s.contains('{'), "{lang:?} d={d}: {s}");
assert!(!s.is_empty());
}
}
}
#[test]
fn age_label_buckets() {
use chrono::Duration;
let base = Utc::now();
let ago = |d: i64| base - Duration::days(d);
assert_eq!(age_label(base, base, loc()), "сегодня");
assert_eq!(age_label(ago(1), base, loc()), "вчера");
assert_eq!(age_label(ago(3), base, loc()), "3 дн.");
assert_eq!(age_label(ago(6), base, loc()), "6 дн.");
assert_eq!(age_label(ago(7), base, loc()), "1 нед.");
assert_eq!(age_label(ago(20), base, loc()), "2 нед.");
assert_eq!(age_label(ago(31), base, loc()), "1 мес.");
assert_eq!(age_label(ago(200), base, loc()), "6 мес.");
assert_eq!(age_label(ago(365), base, loc()), "1 г.");
assert_eq!(age_label(ago(800), base, loc()), "2 г.");
assert_eq!(age_label(base + Duration::hours(5), base, loc()), "сегодня");
}
#[test]
fn set_goal_status_stamps_and_clears_closed_at() {
let mut m = SelfModel::new(Uuid::new_v4());
m.add_goal("цель");
let id = m.goals[0].id;
assert!(m.goals[0].closed_at.is_none()); m.set_goal_status(id, GoalStatus::Completed);
let closed = m.goals[0].closed_at;
assert!(closed.is_some()); m.set_goal_status(id, GoalStatus::Abandoned);
assert_eq!(m.goals[0].closed_at, closed);
m.set_goal_status(id, GoalStatus::Active);
assert!(m.goals[0].closed_at.is_none());
}
#[test]
fn fold_closed_goals_returns_scars_beyond_keep() {
use chrono::Duration;
let mut m = SelfModel::new(Uuid::new_v4());
for i in 0..5 {
m.add_goal(format!("закрытая {i}"));
}
m.add_goal("активная");
for i in 0..5 {
let id = m.goals[i].id;
m.set_goal_status(id, GoalStatus::Completed);
m.goals[i].closed_at = Some(Utc::now() - Duration::days((5 - i) as i64));
}
let scars = m.fold_closed_goals(2, loc());
assert_eq!(scars.len(), 3);
assert_eq!(m.goals.len(), 3);
assert_eq!(m.active_goals().count(), 1);
assert!(scars.iter().all(|s| s.starts_with("[архив цели]")));
assert!(scars.iter().any(|s| s.contains("закрытая 0")));
assert!(m.fold_closed_goals(2, loc()).is_empty());
}
}