use base_d::{DictionaryRegistry, HashAlgorithm, encode, hash};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct KnowledgeEntry {
pub id: String,
pub category_id: String,
pub title: String,
#[serde(default)]
pub body: Option<String>,
#[serde(default)]
pub summary: Option<String>,
#[serde(default)]
pub applicability: Vec<String>,
#[serde(default)]
pub source_project_id: Option<String>,
#[serde(default)]
pub source_agent_id: Option<String>,
#[serde(default)]
pub file_path: Option<String>,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default)]
pub created_at: Option<String>,
#[serde(default)]
pub updated_at: Option<String>,
#[serde(default)]
pub content_hash: Option<String>,
#[serde(default)]
pub source_type_id: Option<String>,
#[serde(default)]
pub entry_type_id: Option<String>,
#[serde(default)]
pub session_id: Option<String>,
#[serde(default)]
pub ephemeral: bool,
#[serde(default)]
pub content_type_id: Option<String>,
#[serde(default)]
pub owner: Option<String>,
#[serde(default = "default_visibility")]
pub visibility: String,
#[serde(default)]
pub resonance: i32,
#[serde(default)]
pub resonance_type: Option<String>,
#[serde(default)]
pub last_activated: Option<String>,
#[serde(default)]
pub activation_count: i32,
#[serde(default = "default_decay_rate")]
pub decay_rate: f64,
#[serde(default)]
pub anchors: Vec<String>,
#[serde(default)]
pub wake_phrases: Vec<String>,
#[serde(default)]
pub wake_order: Option<i32>,
#[serde(default)]
pub wake_phrase: Option<String>,
#[serde(default)]
pub embedding: Option<Vec<f32>>, #[serde(default)]
pub embedding_model: Option<String>, #[serde(default)]
pub embedded_at: Option<String>,
#[serde(default = "default_format")]
pub format: String,
#[serde(default)]
pub effective_resonance: Option<f64>,
}
fn default_format() -> String {
"markdown".to_string()
}
fn default_visibility() -> String {
"public".to_string()
}
fn default_decay_rate() -> f64 {
0.0
}
impl KnowledgeEntry {
pub fn active_wake_phrases(&self) -> Vec<&str> {
if !self.wake_phrases.is_empty() {
self.wake_phrases.iter().map(|s| s.as_str()).collect()
} else {
self.wake_phrase.as_deref().into_iter().collect()
}
}
pub fn has_any_wake_phrase(&self) -> bool {
!self.wake_phrases.is_empty() || self.wake_phrase.as_ref().is_some_and(|s| !s.is_empty())
}
pub fn embedding_text(&self) -> String {
let mut parts = vec![self.title.clone()];
if let Some(summary) = &self.summary {
parts.push(summary.clone());
} else if let Some(body) = &self.body {
parts.push(body.chars().take(2000).collect());
}
if !self.tags.is_empty() {
parts.push(format!("Tags: {}", self.tags.join(", ")));
}
parts.join("\n\n")
}
pub fn normalize_content(content: &str) -> String {
content
.trim()
.to_lowercase()
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
}
pub fn get_summary_state(&self) -> Option<String> {
self.summary
.as_ref()
.and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok())
.and_then(|v| v.get("state").and_then(|s| s.as_str()).map(String::from))
}
pub fn generate_id(path: &str, title: &str) -> String {
let input = format!("{}:{}", path, title);
let hex = Self::blake3_hex(input.as_bytes());
format!("kn-{}", &hex[..8])
}
pub fn compute_hash(content: &str) -> String {
Self::blake3_hex(content.as_bytes())
}
fn blake3_hex(data: &[u8]) -> String {
let hash_bytes = hash(data, HashAlgorithm::Blake3);
let registry = DictionaryRegistry::load_default().expect("base-d dictionaries");
let dict = registry.dictionary("base16").expect("base16 dictionary");
encode(&hash_bytes, &dict).to_lowercase()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_generate_id() {
let id = KnowledgeEntry::generate_id("pattern/test.md", "Test Pattern");
assert!(id.starts_with("kn-"));
assert_eq!(id.len(), 11); }
#[test]
fn test_normalize_content() {
assert_eq!(
KnowledgeEntry::normalize_content(" hello world "),
"hello world"
);
assert_eq!(
KnowledgeEntry::normalize_content("Hello World"),
"hello world"
);
assert_eq!(
KnowledgeEntry::normalize_content("hello\n world\n test"),
"hello world test"
);
assert_eq!(
KnowledgeEntry::normalize_content("hello\tworld"),
"hello world"
);
}
#[test]
fn test_embedding_text() {
let entry = KnowledgeEntry {
id: "kn-test".to_string(),
title: "Test Entry".to_string(),
body: Some("This is the body content.".to_string()),
summary: None,
tags: vec!["rust".to_string(), "test".to_string()],
category_id: "technique".to_string(),
applicability: vec![],
source_project_id: None,
source_agent_id: None,
file_path: None,
created_at: None,
updated_at: None,
content_hash: None,
source_type_id: None,
entry_type_id: None,
session_id: None,
ephemeral: false,
content_type_id: None,
owner: None,
visibility: "public".to_string(),
resonance: 0,
resonance_type: None,
last_activated: None,
activation_count: 0,
decay_rate: 0.0,
anchors: vec![],
wake_phrases: vec![],
wake_order: None,
wake_phrase: None,
embedding: None,
embedding_model: None,
embedded_at: None,
format: "markdown".to_string(),
effective_resonance: None,
};
let text = entry.embedding_text();
assert!(text.contains("Test Entry"));
assert!(text.contains("This is the body content."));
assert!(text.contains("Tags: rust, test"));
}
#[test]
fn test_embedding_text_with_summary() {
let entry = KnowledgeEntry {
id: "kn-test".to_string(),
title: "Test Entry".to_string(),
body: Some("Long body that should be ignored when summary exists.".to_string()),
summary: Some("Short summary".to_string()),
tags: vec![],
category_id: "technique".to_string(),
applicability: vec![],
source_project_id: None,
source_agent_id: None,
file_path: None,
created_at: None,
updated_at: None,
content_hash: None,
source_type_id: None,
entry_type_id: None,
session_id: None,
ephemeral: false,
content_type_id: None,
owner: None,
visibility: "public".to_string(),
resonance: 0,
resonance_type: None,
last_activated: None,
activation_count: 0,
decay_rate: 0.0,
anchors: vec![],
wake_phrases: vec![],
wake_order: None,
wake_phrase: None,
embedding: None,
embedding_model: None,
embedded_at: None,
format: "markdown".to_string(),
effective_resonance: None,
};
let text = entry.embedding_text();
assert!(text.contains("Test Entry"));
assert!(text.contains("Short summary"));
assert!(!text.contains("Long body"));
}
}