Skip to main content

khive_runtime/curation/
embedding_text.rs

1use super::{Entity, Note, NoteFtsScalars, SubstrateKind, TextDocument};
2
3// ---------------------------------------------------------------------------
4// FTS document construction
5// ---------------------------------------------------------------------------
6
7/// Build the canonical text embedded for an entity on create, update, merge,
8/// and repair paths.
9pub fn entity_embedding_text(entity: &Entity) -> String {
10    match &entity.description {
11        Some(description) if !description.is_empty() => {
12            format!("{} {description}", entity.name)
13        }
14        _ => entity.name.clone(),
15    }
16}
17
18/// Build the canonical text embedded for a note when no explicit bounded
19/// embedding prefix was supplied at creation time.
20pub fn note_embedding_text(note: &Note) -> String {
21    note_embedding_text_ref(note).to_owned()
22}
23
24/// Borrow the canonical note embedding text for runtime paths that do not
25/// require ownership.
26pub(crate) fn note_embedding_text_ref(note: &Note) -> &str {
27    &note.content
28}
29
30/// Build the `TextDocument` for an entity. This is the single source of truth for
31/// entity FTS document shape; all write paths (create, update, merge, reindex, backfill)
32/// must go through this function so search parity is guaranteed.
33///
34/// Body rule: when the entity has a non-empty description, prepend the name
35/// (`"<name> <description>"`). Otherwise the body is just the name. This
36/// matches the FTS index contract: `title` and `body` are the ranked columns;
37/// `tags`, `metadata`, and `namespace` are UNINDEXED.
38///
39/// `updated_at` is taken from the entity's own timestamp so that backfill and
40/// reindex runs record the entity's actual mutation time rather than the
41/// reindex execution time.
42pub fn entity_fts_document(entity: &Entity) -> TextDocument {
43    let updated_at =
44        chrono::DateTime::from_timestamp_micros(entity.updated_at).unwrap_or_else(chrono::Utc::now);
45    TextDocument {
46        subject_id: entity.id,
47        kind: SubstrateKind::Entity,
48        record_kind: Some(entity.kind.clone()),
49        title: Some(entity.name.clone()),
50        body: entity_embedding_text(entity),
51        tags: entity.tags.clone(),
52        namespace: entity.namespace.clone(),
53        metadata: entity.properties.clone(),
54        updated_at,
55    }
56}
57
58/// Build the `TextDocument` for a note. This is the single source of truth for
59/// note FTS document shape; all write paths (create, update, reindex) must go
60/// through this function so recall parity is guaranteed. Changes here apply to
61/// every caller automatically.
62///
63/// Body rule: when the note has a `name`, prepend it to the content
64/// (`"<name> <content>"`). This matches the FTS index contract: title and body
65/// both contribute to ranking, and the name is the most salient signal.
66///
67/// `updated_at` is taken from the note's own timestamp (not `Utc::now()`) so
68/// that backfill and reindex runs record the note's actual mutation time rather
69/// than the reindex execution time.
70pub fn note_fts_document(note: &Note) -> TextDocument {
71    let body = match &note.name {
72        Some(n) => format!("{n} {}", note.content),
73        None => note.content.clone(),
74    };
75    let updated_at =
76        chrono::DateTime::from_timestamp_micros(note.updated_at).unwrap_or_else(chrono::Utc::now);
77    TextDocument {
78        subject_id: note.id,
79        kind: SubstrateKind::Note,
80        record_kind: Some(note.kind.clone()),
81        title: note.name.clone(),
82        body,
83        tags: vec![],
84        namespace: note.namespace.clone(),
85        metadata: note.properties.clone(),
86        updated_at,
87    }
88}
89
90/// Derive [`NoteFtsScalars`] from a [`Note`].
91///
92/// All values match the encoding that [`Fts5TextSearch::upsert_document`]
93/// applies when given the output of [`note_fts_document`].
94pub(crate) fn note_fts_scalars(note: &Note) -> NoteFtsScalars {
95    let doc = note_fts_document(note);
96    NoteFtsScalars {
97        record_kind: doc.record_kind.unwrap_or_default(),
98        title: doc.title.unwrap_or_default(),
99        body: doc.body,
100        tags: "[]".to_string(),
101        metadata: doc
102            .metadata
103            .as_ref()
104            .map(|v| serde_json::to_string(v).unwrap_or_default()),
105        updated_at_micros: doc.updated_at.timestamp_micros(),
106    }
107}