use anyhow::Result;
use chrono::Utc;
use crate::entities::profile::ToolId;
use crate::entities::self_model::{
GoalMatch, GoalStatus, NarrativeSegment, SelfModel, SelfModelParams,
};
use crate::shared::api::EmbedRole;
use crate::shared::i18n::Locale;
use super::{Tool, ToolContext, ToolOutcome, notes};
pub const GET_SELF_MODEL_ID: &str = "get_self_model";
pub const REFLECT_ID: &str = "reflect";
pub const UPDATE_SELF_MODEL_ID: &str = "update_self_model";
pub const UPDATE_USER_MODEL_ID: &str = "update_user_model";
pub const ADD_INSIGHT_ID: &str = "add_insight";
pub const ALL_IDS: &[&str] = &[
GET_SELF_MODEL_ID,
REFLECT_ID,
UPDATE_SELF_MODEL_ID,
UPDATE_USER_MODEL_ID,
ADD_INSIGHT_ID,
];
pub fn is_self_model_tool(name: &str) -> bool {
ALL_IDS.contains(&name)
}
pub fn policy_core(loc: &Locale) -> &str {
loc.t("selfmodel.policy_core")
}
pub fn maintenance_protocol(loc: &Locale) -> String {
loc.tf(
"selfmodel.maintenance_wrapper",
&[("core", policy_core(loc))],
)
}
fn load(ctx: &ToolContext) -> Result<SelfModel> {
Ok(ctx
.storage
.db()
.self_model_get(ctx.profile_id)?
.unwrap_or_else(|| SelfModel::new(ctx.profile_id)))
}
fn recent_segments(ctx: &ToolContext) -> Vec<NarrativeSegment> {
notes::self_notes_recent(
&ctx.storage,
ctx.profile_id,
ctx.self_model_params.max_narrative,
)
.into_iter()
.map(|n| NarrativeSegment {
id: n.id,
text: n.content,
created_at: n.created_at,
})
.collect()
}
fn render_self_read(ctx: &ToolContext, m: &SelfModel) -> String {
let recent = recent_segments(ctx);
let ids: Vec<uuid::Uuid> = recent.iter().map(|s| s.id).collect();
let mut out = m.render_full(Utc::now(), &recent, ctx.loc);
if let Some(block) = notes::self_related_block(ctx, &ids) {
out.push_str(&block);
}
if let Some(block) = notes::cited_sources_block(ctx, &ids) {
out.push_str(&block);
}
if let Some(hint) = m.summary_fill_hint(ctx.self_model_params.summary_target_chars, ctx.loc) {
out.push_str("\n\n");
out.push_str(&hint);
}
out
}
fn str_array(args: &serde_json::Value, key: &str) -> Vec<String> {
args.get(key)
.and_then(|v| v.as_array())
.map(|a| {
a.iter()
.filter_map(|x| x.as_str().map(|s| s.trim().to_string()))
.filter(|s| !s.is_empty())
.collect()
})
.unwrap_or_default()
}
const TRAIT_SIMILARITY: f32 = 0.72;
async fn near_duplicate_traits(
ctx: &ToolContext,
added: &[String],
existing_before: &[String],
) -> Vec<(String, String)> {
if added.is_empty() || existing_before.is_empty() {
return Vec::new();
}
let texts: Vec<String> = added.iter().chain(existing_before).cloned().collect();
let Ok(vecs) = ctx.embedder.embed(texts, EmbedRole::Passage).await else {
return Vec::new();
};
if vecs.len() != added.len() + existing_before.len() {
return Vec::new();
}
let (added_vecs, existing_vecs) = vecs.split_at(added.len());
let threshold = ctx.storage.db().similarity_scale().map(TRAIT_SIMILARITY);
let mut out = Vec::new();
for (i, a) in added.iter().enumerate() {
let mut best: Option<(f32, usize)> = None;
for (j, _) in existing_before.iter().enumerate() {
let s = notes::cosine(&added_vecs[i], &existing_vecs[j]);
if s >= threshold && best.map(|(bs, _)| s > bs).unwrap_or(true) {
best = Some((s, j));
}
}
if let Some((_, j)) = best {
out.push((a.clone(), existing_before[j].clone()));
}
}
out
}
pub struct GetSelfModel;
#[async_trait::async_trait]
impl Tool for GetSelfModel {
fn id(&self) -> ToolId {
GET_SELF_MODEL_ID.into()
}
fn concurrent(&self) -> bool {
true
}
fn group(&self) -> crate::features::tools::meta::ToolGroup {
crate::features::tools::meta::ToolGroup::SelfModel
}
fn ui_label(&self) -> &'static str {
"show self-model"
}
fn enabled_by_default(&self) -> bool {
false
}
fn description(&self, loc: &crate::shared::i18n::Locale) -> String {
loc.t("tool.get_self_model.desc").into()
}
fn parameters(&self, _loc: &crate::shared::i18n::Locale) -> serde_json::Value {
serde_json::json!({ "type": "object", "properties": {} })
}
async fn invoke(&self, ctx: &ToolContext, _args: serde_json::Value) -> Result<ToolOutcome> {
let m = load(ctx)?;
Ok(ToolOutcome::text(render_self_read(ctx, &m)))
}
}
pub struct Reflect;
#[async_trait::async_trait]
impl Tool for Reflect {
fn id(&self) -> ToolId {
REFLECT_ID.into()
}
fn group(&self) -> crate::features::tools::meta::ToolGroup {
crate::features::tools::meta::ToolGroup::SelfModel
}
fn ui_label(&self) -> &'static str {
"self-reflection"
}
fn enabled_by_default(&self) -> bool {
false
}
fn description(&self, loc: &crate::shared::i18n::Locale) -> String {
loc.t("tool.reflect.desc").into()
}
fn parameters(&self, _loc: &crate::shared::i18n::Locale) -> serde_json::Value {
serde_json::json!({ "type": "object", "properties": {} })
}
async fn invoke(&self, ctx: &ToolContext, _args: serde_json::Value) -> Result<ToolOutcome> {
let m = load(ctx)?;
let mut overview =
notes::build_self_consolidation_overview(&ctx.storage, ctx.profile_id, ctx.loc)
.unwrap_or_default();
if let Some(section) = notes::summary_observation_overlaps(
&ctx.storage,
ctx.embedder.as_ref(),
ctx.profile_id,
ctx.loc,
)
.await
{
if overview.is_empty() {
overview = section;
} else {
overview.push_str("\n\n");
overview.push_str(§ion);
}
}
let overview = if overview.is_empty() {
String::new()
} else {
format!("\n\n{overview}")
};
let out = format!(
"{}\n{}{overview}\n\n{}",
ctx.loc.t("tool.reflect.rubric.header"),
render_self_read(ctx, &m),
ctx.loc.t("tool.reflect.rubric.questions"),
);
Ok(ToolOutcome::text(out))
}
}
pub struct AddInsight;
#[async_trait::async_trait]
impl Tool for AddInsight {
fn id(&self) -> ToolId {
ADD_INSIGHT_ID.into()
}
fn group(&self) -> crate::features::tools::meta::ToolGroup {
crate::features::tools::meta::ToolGroup::SelfModel
}
fn ui_label(&self) -> &'static str {
"add observation"
}
fn enabled_by_default(&self) -> bool {
false
}
fn description(&self, loc: &crate::shared::i18n::Locale) -> String {
loc.t("tool.add_insight.desc").into()
}
fn parameters(&self, loc: &crate::shared::i18n::Locale) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"text": {"type": "string", "description": loc.t("tool.add_insight.param.text")}
},
"required": ["text"]
})
}
async fn invoke(&self, ctx: &ToolContext, args: serde_json::Value) -> Result<ToolOutcome> {
let text = args
.get("text")
.and_then(|v| v.as_str())
.unwrap_or_default()
.trim()
.to_string();
if text.is_empty() {
anyhow::bail!(ctx.loc.t("tool.add_insight.err.text_empty"));
}
let id =
notes::create_note(ctx, text.clone(), vec![notes::SELF_NOTE_TAG.to_string()]).await?;
let mut msg = ctx.loc.tf(
"tool.add_insight.result.recorded",
&[("id", &id.to_string())],
);
let similar = notes::self_note_similar(ctx, &text, id).await;
if !similar.is_empty() {
msg.push('\n');
msg.push_str(ctx.loc.t("tool.add_insight.gate.similar"));
for n in similar {
msg.push_str(&format!("\n- (id={}) {}", n.id, n.content));
}
}
Ok(ToolOutcome::text(msg).wrote())
}
}
pub struct UpdateSelfModel;
#[async_trait::async_trait]
impl Tool for UpdateSelfModel {
fn id(&self) -> ToolId {
UPDATE_SELF_MODEL_ID.into()
}
fn group(&self) -> crate::features::tools::meta::ToolGroup {
crate::features::tools::meta::ToolGroup::SelfModel
}
fn ui_label(&self) -> &'static str {
"update self-model"
}
fn enabled_by_default(&self) -> bool {
false
}
fn description(&self, loc: &crate::shared::i18n::Locale) -> String {
loc.t("tool.update_self_model.desc").into()
}
fn parameters(&self, loc: &crate::shared::i18n::Locale) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"summary": {"type": "string", "description": loc.t("tool.update_self_model.param.summary")},
"add_goals": {"type": "array", "items": {"type": "string"}, "description": loc.t("tool.update_self_model.param.add_goals")},
"complete_goals": {"type": "array", "items": {"type": "string"}, "description": loc.t("tool.update_self_model.param.complete_goals")},
"abandon_goals": {"type": "array", "items": {"type": "string"}, "description": loc.t("tool.update_self_model.param.abandon_goals")}
}
})
}
async fn invoke(&self, ctx: &ToolContext, args: serde_json::Value) -> Result<ToolOutcome> {
let mut deltas = SelfModelEditDeltas::default();
let params = ctx.self_model_params;
let loc = ctx.loc; let (model, changed) = ctx.storage.db().self_model_update(ctx.profile_id, |m| {
apply_self_model_edit(m, &args, params, loc, &mut deltas)
})?;
for scar in &deltas.scars {
let _ =
notes::create_note(ctx, scar.clone(), vec![notes::SELF_NOTE_TAG.to_string()]).await;
}
if !changed {
let mut msg = ctx.loc.t("selfmodel.result.nothing").to_string();
if !deltas.unresolved.is_empty() {
msg.push('\n');
msg.push_str(&ctx.loc.tf(
"tool.update_self_model.goals_not_found",
&[("list", &deltas.unresolved.join(", "))],
));
}
return Ok(ToolOutcome::text(msg));
}
Ok(ToolOutcome::text(update_self_model_echo(ctx.loc, &model, params, &deltas)).wrote())
}
}
#[derive(Default)]
struct SelfModelEditDeltas {
unresolved: Vec<String>,
scars: Vec<String>,
summary_changed: bool,
added_goals: Vec<(String, uuid::Uuid)>,
completed: Vec<uuid::Uuid>,
abandoned: Vec<uuid::Uuid>,
}
fn apply_self_model_edit(
m: &mut SelfModel,
args: &serde_json::Value,
params: SelfModelParams,
loc: &Locale,
d: &mut SelfModelEditDeltas,
) -> bool {
let mut changed = false;
if let Some(s) = args.get("summary").and_then(|v| v.as_str()) {
let s = s.trim().to_string();
if m.summary != s {
m.summary = s;
changed = true;
d.summary_changed = true;
}
}
for g in str_array(args, "add_goals") {
let before = m.goals.len();
m.add_goal(g);
if m.goals.len() != before {
let goal = m.goals.last().expect("just added");
d.added_goals.push((goal.description.clone(), goal.id));
changed = true;
}
}
changed |= close_goals(
m,
args,
"complete_goals",
GoalStatus::Completed,
loc,
&mut d.completed,
&mut d.unresolved,
);
changed |= close_goals(
m,
args,
"abandon_goals",
GoalStatus::Abandoned,
loc,
&mut d.abandoned,
&mut d.unresolved,
);
d.scars = m.fold_closed_goals(params.max_closed_goals, loc);
if !d.scars.is_empty() {
changed = true;
}
changed
}
fn close_goals(
m: &mut SelfModel,
args: &serde_json::Value,
key: &str,
status: GoalStatus,
loc: &Locale,
closed: &mut Vec<uuid::Uuid>,
unresolved: &mut Vec<String>,
) -> bool {
let mut changed = false;
for h in str_array(args, key) {
match m.match_goal(&h) {
GoalMatch::One(id) => {
if m.set_goal_status(id, status) {
closed.push(id);
changed = true;
}
}
GoalMatch::None => unresolved.push(h),
GoalMatch::Ambiguous => {
unresolved.push(loc.tf("tool.update_self_model.ambiguous", &[("h", &h)]))
}
}
}
changed
}
fn update_self_model_echo(
loc: &Locale,
model: &SelfModel,
params: SelfModelParams,
d: &SelfModelEditDeltas,
) -> String {
use crate::entities::self_model::short_id;
let mut msg = loc.t("tool.update_self_model.result.updated").to_string();
if d.summary_changed {
msg.push('\n');
msg.push_str(&loc.tf(
"tool.update_self_model.size",
&[
("n", &model.summary.chars().count().to_string()),
("target", ¶ms.summary_target_chars.to_string()),
],
));
}
if !d.added_goals.is_empty() {
let list: Vec<String> = d
.added_goals
.iter()
.map(|(desc, id)| format!("#{} {desc}", short_id(id)))
.collect();
msg.push('\n');
msg.push_str(&loc.tf(
"tool.update_self_model.added_goals",
&[("list", &list.join("; "))],
));
}
if !d.completed.is_empty() {
let ids: Vec<String> = d
.completed
.iter()
.map(|id| format!("#{}", short_id(id)))
.collect();
msg.push('\n');
msg.push_str(&loc.tf(
"tool.update_self_model.completed",
&[("list", &ids.join(", "))],
));
}
if !d.abandoned.is_empty() {
let ids: Vec<String> = d
.abandoned
.iter()
.map(|id| format!("#{}", short_id(id)))
.collect();
msg.push('\n');
msg.push_str(&loc.tf(
"tool.update_self_model.abandoned",
&[("list", &ids.join(", "))],
));
}
if !d.scars.is_empty() {
msg.push('\n');
msg.push_str(&loc.tf(
"tool.update_self_model.folded",
&[("n", &d.scars.len().to_string())],
));
}
if !d.unresolved.is_empty() {
msg.push('\n');
msg.push_str(&loc.tf(
"tool.update_self_model.goals_not_found_paren",
&[("list", &d.unresolved.join(", "))],
));
}
msg
}
pub struct UpdateUserModel;
#[async_trait::async_trait]
impl Tool for UpdateUserModel {
fn id(&self) -> ToolId {
UPDATE_USER_MODEL_ID.into()
}
fn group(&self) -> crate::features::tools::meta::ToolGroup {
crate::features::tools::meta::ToolGroup::SelfModel
}
fn ui_label(&self) -> &'static str {
"update user model"
}
fn enabled_by_default(&self) -> bool {
false
}
fn description(&self, loc: &crate::shared::i18n::Locale) -> String {
loc.t("tool.update_user_model.desc").into()
}
fn parameters(&self, loc: &crate::shared::i18n::Locale) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"add_traits": {"type": "array", "items": {"type": "string"}, "description": loc.t("tool.update_user_model.param.add_traits")},
"remove_traits": {"type": "array", "items": {"type": "string"}, "description": loc.t("tool.update_user_model.param.remove_traits")},
"add_interests": {"type": "array", "items": {"type": "string"}, "description": loc.t("tool.update_user_model.param.add_interests")},
"remove_interests": {"type": "array", "items": {"type": "string"}, "description": loc.t("tool.update_user_model.param.remove_interests")},
"relationship_dynamic": {"type": "string", "description": loc.t("tool.update_user_model.param.relationship_dynamic")},
"note": {"type": "string", "description": loc.t("tool.update_user_model.param.note")}
}
})
}
async fn invoke(&self, ctx: &ToolContext, args: serde_json::Value) -> Result<ToolOutcome> {
let requested_traits = str_array(&args, "add_traits");
let mut deltas = UserModelEditDeltas::default();
let (model, changed) = ctx.storage.db().self_model_update(ctx.profile_id, |m| {
apply_user_model_edit(m, &args, &requested_traits, &mut deltas)
})?;
if let Some(scar) = &deltas.note_scar {
let _ =
notes::create_note(ctx, scar.clone(), vec![notes::SELF_NOTE_TAG.to_string()]).await;
}
if !changed && !deltas.has_note {
return Ok(ToolOutcome::text(ctx.loc.t("selfmodel.result.nothing")));
}
let added_traits = newly_added_traits(&requested_traits, &deltas.existing_before_traits);
let dup_pairs =
near_duplicate_traits(ctx, &added_traits, &deltas.existing_before_traits).await;
Ok(ToolOutcome::text(update_user_model_echo(ctx.loc, &model, &deltas, &dup_pairs)).wrote())
}
}
#[derive(Default)]
struct UserModelEditDeltas {
removed_traits: bool,
removed_interests: bool,
replaced_dynamic: bool,
has_note: bool,
note_scar: Option<String>,
existing_before_traits: Vec<String>,
}
fn apply_user_model_edit(
m: &mut SelfModel,
args: &serde_json::Value,
requested_traits: &[String],
d: &mut UserModelEditDeltas,
) -> bool {
let mut changed = false;
d.existing_before_traits = m.user_model.perceived_traits.clone();
changed |= m.user_model.add_traits(requested_traits.to_vec());
d.removed_traits = m
.user_model
.remove_traits(&str_array(args, "remove_traits"));
changed |= d.removed_traits;
changed |= m.user_model.add_interests(str_array(args, "add_interests"));
d.removed_interests = m
.user_model
.remove_interests(&str_array(args, "remove_interests"));
changed |= d.removed_interests;
if let Some(s) = args.get("relationship_dynamic").and_then(|v| v.as_str()) {
let s = s.trim().to_string();
if m.user_model.relationship_dynamic != s {
d.replaced_dynamic = !m.user_model.relationship_dynamic.trim().is_empty();
m.user_model.relationship_dynamic = s;
changed = true;
}
}
d.note_scar = args
.get("note")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string);
d.has_note = d.note_scar.is_some();
changed
}
fn newly_added_traits(requested: &[String], existing: &[String]) -> Vec<String> {
let mut added: Vec<String> = Vec::new();
for t in requested {
let lc = t.to_lowercase();
let known = existing.iter().any(|x| x.to_lowercase() == lc)
|| added.iter().any(|x| x.to_lowercase() == lc);
if !known {
added.push(t.clone());
}
}
added
}
fn update_user_model_echo(
loc: &Locale,
model: &SelfModel,
d: &UserModelEditDeltas,
dup_pairs: &[(String, String)],
) -> String {
let mut msg = loc.t("tool.update_user_model.result.updated").to_string();
let u = &model.user_model;
if !u.perceived_traits.is_empty() {
msg.push('\n');
msg.push_str(&loc.tf(
"tool.update_user_model.traits",
&[("list", &u.perceived_traits.join(", "))],
));
}
if !u.current_interests.is_empty() {
msg.push('\n');
msg.push_str(&loc.tf(
"tool.update_user_model.interests",
&[("list", &u.current_interests.join(", "))],
));
}
if !u.relationship_dynamic.trim().is_empty() {
msg.push('\n');
msg.push_str(&loc.tf(
"tool.update_user_model.relationship",
&[("dyn", u.relationship_dynamic.trim())],
));
}
if let Some(scar) = &d.note_scar {
msg.push('\n');
msg.push_str(&loc.tf("tool.update_user_model.scar_saved", &[("scar", scar)]));
}
if !dup_pairs.is_empty() {
msg.push('\n');
msg.push_str(loc.t("tool.update_user_model.gate.related"));
for (added, existing) in dup_pairs {
msg.push_str(&format!("\n- «{added}» ≈ «{existing}»"));
}
}
if (d.removed_traits || d.removed_interests || d.replaced_dynamic) && !d.has_note {
msg.push('\n');
msg.push_str(loc.t("tool.update_user_model.nudge_note"));
}
msg
}
#[cfg(test)]
mod tests {
use super::super::testkit::ctx_with_storage;
use super::*;
use uuid::Uuid;
fn self_notes(
storage: &crate::shared::storage::Storage,
profile: Uuid,
) -> Vec<crate::entities::note::Note> {
storage
.db()
.note_list(profile, None, &[notes::SELF_NOTE_TAG.to_string()], None)
.unwrap()
}
#[test]
fn self_model_tool_descriptions_are_localized() {
use crate::shared::i18n::{Lang, locale};
let (r, e) = (locale(Lang::Ru), locale(Lang::En));
let no_cyr = |s: &str| {
!s.chars()
.any(|c| ('а'..='я').contains(&c) || ('А'..='Я').contains(&c))
};
let pairs: Vec<(String, String)> = vec![
(GetSelfModel.description(r), GetSelfModel.description(e)),
(Reflect.description(r), Reflect.description(e)),
(AddInsight.description(r), AddInsight.description(e)),
(
UpdateSelfModel.description(r),
UpdateSelfModel.description(e),
),
(
UpdateUserModel.description(r),
UpdateUserModel.description(e),
),
];
for (ru_d, en_d) in pairs {
assert_ne!(ru_d, en_d, "description not localized: {ru_d}");
assert!(no_cyr(&en_d), "Cyrillic in en description: {en_d}");
}
}
#[tokio::test]
async fn add_insight_result_localized_for_all_langs() {
use crate::shared::i18n::{Lang, locale};
for &lang in Lang::ALL {
let (_d, _s, mut ctx) = ctx_with_storage(Uuid::new_v4());
ctx.loc = locale(lang);
let out = AddInsight
.invoke(&ctx, serde_json::json!({"text": "hello observation"}))
.await
.unwrap();
let prefix = locale(lang)
.t("tool.add_insight.result.recorded")
.split("{id}")
.next()
.unwrap();
assert!(out.result.starts_with(prefix), "{lang:?}: {}", out.result);
}
}
#[test]
fn is_self_model_tool_recognizes_group() {
assert!(is_self_model_tool(UPDATE_SELF_MODEL_ID));
assert!(is_self_model_tool(ADD_INSIGHT_ID));
assert!(is_self_model_tool(GET_SELF_MODEL_ID));
assert!(!is_self_model_tool("note_save"));
assert!(!is_self_model_tool("web_search"));
}
fn ru() -> &'static Locale {
crate::shared::i18n::locale(crate::shared::i18n::Lang::Ru)
}
#[test]
fn maintenance_protocol_wraps_policy_core() {
let p = maintenance_protocol(ru());
assert!(p.contains("Ты сам ведёшь эту «модель себя»"));
assert!(p.contains(policy_core(ru())));
assert!(p.contains("угодливости"));
}
#[test]
fn maintenance_protocol_localized_for_all_langs() {
for &lang in crate::shared::i18n::Lang::ALL {
let l = crate::shared::i18n::locale(lang);
let p = maintenance_protocol(l);
assert!(
p.contains(policy_core(l)),
"{lang:?}: policy_core not embedded"
);
assert!(
!p.contains("{core}"),
"{lang:?}: placeholder not substituted"
);
}
}
#[test]
fn policy_core_routes_events_to_insights() {
let core = policy_core(ru());
assert!(core.contains("снимок"));
assert!(core.contains("СОКРАЩАЙ"));
assert!(core.contains("ДАЖЕ ЕСЛИ"));
assert!(core.contains("прочти его целиком через get_self_model"));
}
#[test]
fn policy_core_nudges_interest_aging() {
let core = policy_core(ru());
assert!(core.contains("remove_interests"));
assert!(core.contains("устаревал"));
assert!(core.contains("давно не"));
}
#[test]
fn update_self_model_description_routes_events_to_insights() {
let d =
UpdateSelfModel.description(crate::shared::i18n::locale(crate::shared::i18n::Lang::Ru));
assert!(d.contains("снимок"));
assert!(d.contains("add_insight"));
}
#[tokio::test]
async fn get_on_empty_reports_empty() {
let (_d, _s, ctx) = ctx_with_storage(Uuid::new_v4());
let out = GetSelfModel
.invoke(&ctx, serde_json::json!({}))
.await
.unwrap();
assert!(out.result.contains("пуста"));
assert!(out.effects.is_empty());
}
#[tokio::test]
async fn update_summary_and_goal_persists() {
let profile = Uuid::new_v4();
let (_d, storage, ctx) = ctx_with_storage(profile);
let out = UpdateSelfModel
.invoke(
&ctx,
serde_json::json!({"summary": "ценю ясность", "add_goals": ["помочь с проектом"]}),
)
.await
.unwrap();
assert!(out.result.contains("обновлена"));
let stored = storage.db().self_model_get(profile).unwrap().unwrap();
assert_eq!(stored.summary, "ценю ясность");
assert_eq!(stored.active_goals().count(), 1);
let got = GetSelfModel
.invoke(&ctx, serde_json::json!({}))
.await
.unwrap();
assert!(got.result.contains("ценю ясность"));
assert!(got.result.contains("помочь с проектом"));
}
#[tokio::test]
async fn update_summary_echo_shows_size() {
let (_d, _s, ctx) = ctx_with_storage(Uuid::new_v4());
let out = UpdateSelfModel
.invoke(&ctx, serde_json::json!({"summary": "ценю ясность"}))
.await
.unwrap();
assert!(out.result.contains("Описание:"));
assert!(out.result.contains("симв."));
let out = UpdateSelfModel
.invoke(&ctx, serde_json::json!({"add_goals": ["цель"]}))
.await
.unwrap();
assert!(!out.result.contains("Описание:"));
}
#[tokio::test]
async fn update_self_model_echo_is_delta_not_full() {
let (_d, _s, ctx) = ctx_with_storage(Uuid::new_v4());
let out = UpdateSelfModel
.invoke(
&ctx,
serde_json::json!({
"summary": "УНИКАЛЬНЫЙ_МАРКЕР_ОПИСАНИЯ",
"add_goals": ["новая цель"]
}),
)
.await
.unwrap();
assert!(out.result.contains("Добавлены цели:"));
assert!(out.result.contains("новая цель"));
assert!(out.result.contains('#'));
assert!(out.result.contains("Описание:")); assert!(!out.result.contains("УНИКАЛЬНЫЙ_МАРКЕР_ОПИСАНИЯ"));
}
#[tokio::test]
async fn update_self_model_echo_shows_closed_goal_id() {
let profile = Uuid::new_v4();
let (_d, storage, ctx) = ctx_with_storage(profile);
UpdateSelfModel
.invoke(&ctx, serde_json::json!({"add_goals": ["цель"]}))
.await
.unwrap();
let id = storage.db().self_model_get(profile).unwrap().unwrap().goals[0].id;
let short = id.simple().to_string()[..6].to_string();
let out = UpdateSelfModel
.invoke(
&ctx,
serde_json::json!({"complete_goals": [format!("#{short}")]}),
)
.await
.unwrap();
assert!(out.result.contains("Закрыты выполненными:"));
assert!(out.result.contains(&format!("#{short}")));
}
#[tokio::test]
async fn update_user_model_echo_shows_final_lists() {
let (_d, _s, ctx) = ctx_with_storage(Uuid::new_v4());
let out = UpdateUserModel
.invoke(
&ctx,
serde_json::json!({
"add_traits": ["скептик"],
"add_interests": ["Rust"],
"relationship_dynamic": "рабочие"
}),
)
.await
.unwrap();
assert!(out.result.contains("Черты теперь: скептик"));
assert!(out.result.contains("Интересы теперь: Rust"));
assert!(out.result.contains("Отношения: рабочие"));
}
#[tokio::test]
async fn get_self_model_surfaces_summary_fill_hint_over_target() {
use crate::entities::self_model::SelfModelParams;
use crate::shared::config::SelfModelSettings;
let profile = Uuid::new_v4();
let (_d, _s, mut ctx) = ctx_with_storage(profile);
ctx.self_model_params = SelfModelParams::from_settings(&SelfModelSettings {
summary_target_chars: 5,
..SelfModelSettings::default()
});
UpdateSelfModel
.invoke(&ctx, serde_json::json!({"summary": "я".repeat(250)}))
.await
.unwrap();
let out = GetSelfModel
.invoke(&ctx, serde_json::json!({}))
.await
.unwrap();
assert!(out.result.contains("Описание себя разрослось"));
}
#[tokio::test]
async fn complete_goal_by_id() {
let profile = Uuid::new_v4();
let (_d, storage, ctx) = ctx_with_storage(profile);
UpdateSelfModel
.invoke(&ctx, serde_json::json!({"add_goals": ["старая цель"]}))
.await
.unwrap();
let id = storage.db().self_model_get(profile).unwrap().unwrap().goals[0].id;
UpdateSelfModel
.invoke(
&ctx,
serde_json::json!({"complete_goals": [id.to_string()]}),
)
.await
.unwrap();
let stored = storage.db().self_model_get(profile).unwrap().unwrap();
assert_eq!(stored.active_goals().count(), 0);
}
#[tokio::test]
async fn update_user_model_persists() {
let profile = Uuid::new_v4();
let (_d, storage, ctx) = ctx_with_storage(profile);
UpdateUserModel
.invoke(
&ctx,
serde_json::json!({
"add_traits": ["скептичный", "глубокий"],
"relationship_dynamic": "рабочие"
}),
)
.await
.unwrap();
let stored = storage.db().self_model_get(profile).unwrap().unwrap();
assert_eq!(stored.user_model.perceived_traits.len(), 2);
assert_eq!(stored.user_model.relationship_dynamic, "рабочие");
}
#[tokio::test]
async fn update_user_model_merges_not_overwrites() {
let profile = Uuid::new_v4();
let (_d, storage, ctx) = ctx_with_storage(profile);
UpdateUserModel
.invoke(&ctx, serde_json::json!({"add_traits": ["добрый"]}))
.await
.unwrap();
UpdateUserModel
.invoke(&ctx, serde_json::json!({"add_traits": ["прямолинейный"]}))
.await
.unwrap();
let stored = storage.db().self_model_get(profile).unwrap().unwrap();
assert_eq!(stored.user_model.perceived_traits.len(), 2);
assert!(
stored
.user_model
.perceived_traits
.contains(&"добрый".to_string())
);
UpdateUserModel
.invoke(&ctx, serde_json::json!({"remove_traits": ["добрый"]}))
.await
.unwrap();
let stored = storage.db().self_model_get(profile).unwrap().unwrap();
assert_eq!(
stored.user_model.perceived_traits,
vec!["прямолинейный".to_string()]
);
}
#[tokio::test]
async fn complete_goal_by_short_id() {
let profile = Uuid::new_v4();
let (_d, storage, ctx) = ctx_with_storage(profile);
UpdateSelfModel
.invoke(&ctx, serde_json::json!({"add_goals": ["цель"]}))
.await
.unwrap();
let id = storage.db().self_model_get(profile).unwrap().unwrap().goals[0].id;
let short = id.simple().to_string()[..6].to_string();
UpdateSelfModel
.invoke(
&ctx,
serde_json::json!({"complete_goals": [format!("#{short}")]}),
)
.await
.unwrap();
let stored = storage.db().self_model_get(profile).unwrap().unwrap();
assert_eq!(stored.active_goals().count(), 0);
let out = UpdateSelfModel
.invoke(&ctx, serde_json::json!({"complete_goals": ["#zzzzzz"]}))
.await
.unwrap();
assert!(out.result.contains("Не найдены цели"));
}
#[tokio::test]
async fn update_with_nothing_is_noop() {
let (_d, storage, ctx) = ctx_with_storage(Uuid::new_v4());
let out = UpdateSelfModel
.invoke(&ctx, serde_json::json!({}))
.await
.unwrap();
assert!(out.result.contains("Нечего обновлять"));
assert!(
storage
.db()
.self_model_get(ctx.profile_id)
.unwrap()
.is_none()
);
}
#[tokio::test]
async fn reflect_returns_current_and_rubric() {
let (_d, _s, ctx) = ctx_with_storage(Uuid::new_v4());
let out = Reflect.invoke(&ctx, serde_json::json!({})).await.unwrap();
assert!(out.result.contains("Вопросы для размышления"));
assert!(out.result.contains("Не разрослось ли описание себя"));
assert!(out.effects.is_empty());
assert!(!out.result.contains("Обзор наблюдений"));
}
#[tokio::test]
async fn reflect_includes_self_consolidation_overview() {
let profile = Uuid::new_v4();
let (_d, _s, ctx) = ctx_with_storage(profile);
AddInsight
.invoke(&ctx, serde_json::json!({"text": "aaaa bbbb"}))
.await
.unwrap();
AddInsight
.invoke(&ctx, serde_json::json!({"text": "aaab"}))
.await
.unwrap();
let out = Reflect.invoke(&ctx, serde_json::json!({})).await.unwrap();
assert!(out.result.contains("Обзор наблюдений"));
assert!(out.result.contains("Похожие пары"));
}
#[tokio::test]
async fn add_insight_creates_self_note_and_shows() {
let profile = Uuid::new_v4();
let (_d, storage, ctx) = ctx_with_storage(profile);
let out = AddInsight
.invoke(
&ctx,
serde_json::json!({"text": "напряжение между краткостью и полнотой"}),
)
.await
.unwrap();
assert!(out.result.contains("Наблюдение записано"));
assert_eq!(self_notes(&storage, profile).len(), 1);
assert!(storage.db().self_model_get(profile).unwrap().is_none());
let got = GetSelfModel
.invoke(&ctx, serde_json::json!({}))
.await
.unwrap();
assert!(got.result.contains("Наблюдения"));
assert!(got.result.contains("напряжение"));
assert!(
AddInsight
.invoke(&ctx, serde_json::json!({"text": " "}))
.await
.is_err()
);
assert_eq!(self_notes(&storage, profile).len(), 1);
}
#[tokio::test]
async fn add_insight_gate_surfaces_similar_observation() {
let profile = Uuid::new_v4();
let (_d, _s, ctx) = ctx_with_storage(profile);
AddInsight
.invoke(&ctx, serde_json::json!({"text": "aaaa bbbb"}))
.await
.unwrap();
let out = AddInsight
.invoke(&ctx, serde_json::json!({"text": "aaab"}))
.await
.unwrap();
assert!(out.result.contains("Похожие наблюдения"));
assert!(out.result.contains("aaaa bbbb"));
assert!(out.result.contains("note_revise"));
}
#[tokio::test]
async fn get_self_model_surfaces_linked_observations() {
let profile = Uuid::new_v4();
let (_d, storage, ctx) = ctx_with_storage(profile);
AddInsight
.invoke(&ctx, serde_json::json!({"text": "ценю краткость"}))
.await
.unwrap();
AddInsight
.invoke(
&ctx,
serde_json::json!({"text": "иногда бываю многословен"}),
)
.await
.unwrap();
let ns = self_notes(&storage, profile);
storage
.db()
.note_link_insert(profile, ns[0].id, ns[1].id, "contradicts")
.unwrap();
let out = GetSelfModel
.invoke(&ctx, serde_json::json!({}))
.await
.unwrap();
assert!(out.result.contains("Связи наблюдений"));
assert!(out.result.contains("contradicts"));
}
#[tokio::test]
async fn add_trait_gate_surfaces_near_duplicate() {
let profile = Uuid::new_v4();
let (_d, _s, ctx) = ctx_with_storage(profile);
let out = UpdateUserModel
.invoke(&ctx, serde_json::json!({"add_traits": ["aaaa bbbb"]}))
.await
.unwrap();
assert!(!out.result.contains("Родственные черты"));
let out = UpdateUserModel
.invoke(&ctx, serde_json::json!({"add_traits": ["aaab"]}))
.await
.unwrap();
assert!(out.result.contains("Родственные черты"));
assert!(out.result.contains("aaab"));
assert!(out.result.contains("aaaa bbbb"));
assert!(out.result.contains("remove_traits"));
}
#[tokio::test]
async fn add_trait_gate_follows_the_calibrated_scale() {
let profile = Uuid::new_v4();
let (_d, storage, ctx) = ctx_with_storage(profile);
storage
.db()
.set_embed_calibration(&crate::shared::embed_calibration::Calibration {
unrelated: 0.7897,
paraphrase: 0.9456,
})
.unwrap();
UpdateUserModel
.invoke(&ctx, serde_json::json!({"add_traits": ["aaaa bbbb"]}))
.await
.unwrap();
let out = UpdateUserModel
.invoke(&ctx, serde_json::json!({"add_traits": ["aaab"]}))
.await
.unwrap();
assert!(!out.result.contains("Родственные черты"), "{}", out.result);
let out = UpdateUserModel
.invoke(&ctx, serde_json::json!({"add_traits": ["aaaab"]}))
.await
.unwrap();
assert!(out.result.contains("Родственные черты"), "{}", out.result);
assert!(out.result.contains("aaab"), "{}", out.result);
}
#[tokio::test]
async fn add_trait_gate_silent_for_dissimilar() {
let profile = Uuid::new_v4();
let (_d, storage, ctx) = ctx_with_storage(profile);
UpdateUserModel
.invoke(&ctx, serde_json::json!({"add_traits": ["aaaa bbbb"]}))
.await
.unwrap();
let out = UpdateUserModel
.invoke(&ctx, serde_json::json!({"add_traits": ["wwww"]}))
.await
.unwrap();
assert!(!out.result.contains("Родственные черты"));
let stored = storage.db().self_model_get(profile).unwrap().unwrap();
assert_eq!(stored.user_model.perceived_traits.len(), 2);
}
#[tokio::test]
async fn removing_trait_with_note_leaves_scar_as_self_note() {
let profile = Uuid::new_v4();
let (_d, storage, ctx) = ctx_with_storage(profile);
UpdateUserModel
.invoke(
&ctx,
serde_json::json!({"add_traits": ["компетентный одиночка"]}),
)
.await
.unwrap();
UpdateUserModel
.invoke(
&ctx,
serde_json::json!({
"remove_traits": ["компетентный одиночка"],
"add_traits": ["в команде раскрывается при доверии"],
"note": "Пересмотрел: раньше видел одиночкой, но в команде при доверии он силён"
}),
)
.await
.unwrap();
let stored = storage.db().self_model_get(profile).unwrap().unwrap();
assert_eq!(
stored.user_model.perceived_traits,
vec!["в команде раскрывается при доверии".to_string()]
);
assert!(stored.narrative.is_empty());
let n = self_notes(&storage, profile);
assert_eq!(n.len(), 1);
assert!(n[0].content.contains("Пересмотрел"));
}
#[tokio::test]
async fn removing_trait_without_note_nudges_for_scar() {
let (_d, _s, ctx) = ctx_with_storage(Uuid::new_v4());
UpdateUserModel
.invoke(&ctx, serde_json::json!({"add_traits": ["скептик"]}))
.await
.unwrap();
let out = UpdateUserModel
.invoke(&ctx, serde_json::json!({"remove_traits": ["скептик"]}))
.await
.unwrap();
assert!(out.result.contains("без пояснения"));
assert!(out.result.contains("note"));
}
#[tokio::test]
async fn replacing_nonempty_dynamic_without_note_nudges() {
let (_d, _s, ctx) = ctx_with_storage(Uuid::new_v4());
let out = UpdateUserModel
.invoke(
&ctx,
serde_json::json!({"relationship_dynamic": "доверительные"}),
)
.await
.unwrap();
assert!(!out.result.contains("без пояснения"));
let out = UpdateUserModel
.invoke(
&ctx,
serde_json::json!({"relationship_dynamic": "натянутые"}),
)
.await
.unwrap();
assert!(out.result.contains("без пояснения"));
let out = UpdateUserModel
.invoke(
&ctx,
serde_json::json!({
"relationship_dynamic": "снова тёплые",
"note": "помирились после спора"
}),
)
.await
.unwrap();
assert!(!out.result.contains("без пояснения"));
assert!(out.result.contains("помирились после спора"));
}
#[tokio::test]
async fn update_self_model_folds_old_closed_goals_into_notes() {
use crate::entities::self_model::SelfModelParams;
use crate::shared::config::SelfModelSettings;
let profile = Uuid::new_v4();
let (_d, storage, mut ctx) = ctx_with_storage(profile);
ctx.self_model_params = SelfModelParams::from_settings(&SelfModelSettings {
max_closed_goals: 2,
..SelfModelSettings::default()
});
UpdateSelfModel
.invoke(
&ctx,
serde_json::json!({"add_goals": ["ц0", "ц1", "ц2", "ц3", "ц4"]}),
)
.await
.unwrap();
let ids: Vec<String> = storage
.db()
.self_model_get(profile)
.unwrap()
.unwrap()
.goals
.iter()
.map(|g| g.id.to_string())
.collect();
UpdateSelfModel
.invoke(&ctx, serde_json::json!({"complete_goals": ids}))
.await
.unwrap();
let stored = storage.db().self_model_get(profile).unwrap().unwrap();
assert_eq!(stored.goals.len(), 2); assert!(stored.narrative.is_empty());
let archived = self_notes(&storage, profile)
.into_iter()
.filter(|n| n.content.starts_with("[архив цели]"))
.count();
assert_eq!(archived, 3);
}
#[tokio::test]
async fn writers_report_a_write_and_the_reader_does_not() {
let (_d, _s, ctx) = ctx_with_storage(Uuid::new_v4());
let read = GetSelfModel
.invoke(&ctx, serde_json::json!({}))
.await
.unwrap();
assert!(!read.wrote, "a read");
let added = AddInsight
.invoke(&ctx, serde_json::json!({"text": "hello observation"}))
.await
.unwrap();
assert!(added.wrote, "an observation was written");
let changed = UpdateSelfModel
.invoke(&ctx, serde_json::json!({"summary": "ценю ясность"}))
.await
.unwrap();
assert!(changed.wrote, "the summary was written");
let nothing = UpdateSelfModel
.invoke(&ctx, serde_json::json!({}))
.await
.unwrap();
assert!(!nothing.wrote, "nothing to change: {}", nothing.result);
}
}