episodic 0.2.3

Reusable Observational Memory core models and pure transforms.
Documentation
use std::collections::BTreeSet;

use crate::model::{
    OmObservationEntryV2, OmObservationOriginKind, OmObservationPriority, OmReflectionResponseV2,
};

fn normalize_text(value: &str) -> Option<String> {
    let trimmed = value.trim();
    if trimmed.is_empty() {
        None
    } else {
        Some(trimmed.to_string())
    }
}

fn normalize_ids(ids: &[String]) -> Vec<String> {
    let mut normalized = ids
        .iter()
        .filter_map(|id| normalize_text(id))
        .collect::<Vec<_>>();
    normalized.sort();
    normalized.dedup();
    normalized
}

#[must_use]
pub fn apply_reflection_response_v2(
    entries: &[OmObservationEntryV2],
    response: &OmReflectionResponseV2,
    reflection_entry_id: &str,
    scope_key: &str,
    thread_id: &str,
    created_at_rfc3339: &str,
) -> Vec<OmObservationEntryV2> {
    let Some(reflection_id) = normalize_text(reflection_entry_id) else {
        return entries.to_vec();
    };
    let Some(normalized_scope_key) = normalize_text(scope_key) else {
        return entries.to_vec();
    };
    let Some(normalized_thread_id) = normalize_text(thread_id) else {
        return entries.to_vec();
    };
    let Some(normalized_created_at) = normalize_text(created_at_rfc3339) else {
        return entries.to_vec();
    };
    let Some(reflection_text) = normalize_text(&response.reflection_text) else {
        return entries.to_vec();
    };

    let covered_ids = normalize_ids(&response.covers_entry_ids)
        .into_iter()
        .collect::<BTreeSet<_>>();
    if covered_ids.is_empty() {
        return entries.to_vec();
    }

    let mut next = entries.to_vec();
    let mut reflection_source_ids = BTreeSet::<String>::new();
    let mut matched_covered_entry = false;

    for entry in &mut next {
        if !covered_ids.contains(&entry.entry_id) {
            continue;
        }
        matched_covered_entry = true;
        entry.superseded_by = Some(reflection_id.clone());
        for source_id in &entry.source_message_ids {
            if let Some(normalized) = normalize_text(source_id) {
                reflection_source_ids.insert(normalized);
            }
        }
    }
    if !matched_covered_entry {
        return entries.to_vec();
    }

    if let Some(existing) = next
        .iter_mut()
        .find(|entry| entry.entry_id == reflection_id)
    {
        existing.scope_key = normalized_scope_key;
        existing.thread_id = normalized_thread_id;
        existing.priority = OmObservationPriority::Medium;
        existing.text = reflection_text;
        existing.origin_kind = OmObservationOriginKind::Reflection;
        existing.source_message_ids = reflection_source_ids.into_iter().collect::<Vec<_>>();
        existing.created_at_rfc3339 = normalized_created_at;
        existing.superseded_by = None;
        return next;
    }

    next.push(OmObservationEntryV2 {
        entry_id: reflection_id,
        scope_key: normalized_scope_key,
        thread_id: normalized_thread_id,
        priority: OmObservationPriority::Medium,
        text: reflection_text,
        source_message_ids: reflection_source_ids.into_iter().collect::<Vec<_>>(),
        origin_kind: OmObservationOriginKind::Reflection,
        created_at_rfc3339: normalized_created_at,
        superseded_by: None,
    });
    next
}