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 ¬e.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 ¬e.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}