heartbit_core/knowledge/
mod.rs1pub mod chunker;
4pub mod in_memory;
5pub mod loader;
6pub mod tools;
7
8use std::future::Future;
9use std::pin::Pin;
10
11use serde::{Deserialize, Serialize};
12
13use crate::auth::TenantScope;
14use crate::error::Error;
15
16#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
18pub struct DocumentSource {
19 pub uri: String,
21 pub title: String,
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct Chunk {
28 pub id: String,
30 pub content: String,
32 pub source: DocumentSource,
34 pub chunk_index: usize,
36 #[serde(default, skip_serializing_if = "Option::is_none")]
43 pub tenant_id: Option<String>,
44}
45
46#[derive(Debug, Clone)]
48pub struct KnowledgeQuery {
49 pub text: String,
51 pub source_filter: Option<String>,
53 pub limit: usize,
55}
56
57#[derive(Debug, Clone)]
59pub struct SearchResult {
60 pub chunk: Chunk,
62 pub match_count: usize,
64}
65
66pub trait KnowledgeBase: Send + Sync {
76 fn index(
78 &self,
79 scope: &TenantScope,
80 chunk: Chunk,
81 ) -> Pin<Box<dyn Future<Output = Result<(), Error>> + Send + '_>>;
82
83 fn search(
85 &self,
86 scope: &TenantScope,
87 query: KnowledgeQuery,
88 ) -> Pin<Box<dyn Future<Output = Result<Vec<SearchResult>, Error>> + Send + '_>>;
89
90 fn chunk_count(
92 &self,
93 scope: &TenantScope,
94 ) -> Pin<Box<dyn Future<Output = Result<usize, Error>> + Send + '_>>;
95}
96
97#[cfg(test)]
98mod tests {
99 use super::*;
100
101 #[test]
102 fn document_source_equality() {
103 let a = DocumentSource {
104 uri: "docs/readme.md".into(),
105 title: "README".into(),
106 };
107 let b = DocumentSource {
108 uri: "docs/readme.md".into(),
109 title: "README".into(),
110 };
111 assert_eq!(a, b);
112 }
113
114 #[test]
115 fn chunk_serializes() {
116 let chunk = Chunk {
117 id: "abc-0".into(),
118 content: "Hello world".into(),
119 source: DocumentSource {
120 uri: "test.md".into(),
121 title: "Test".into(),
122 },
123 chunk_index: 0,
124 tenant_id: None,
125 };
126 let json = serde_json::to_string(&chunk).unwrap();
127 let parsed: Chunk = serde_json::from_str(&json).unwrap();
128 assert_eq!(parsed.id, "abc-0");
129 assert_eq!(parsed.content, "Hello world");
130 assert_eq!(parsed.source.uri, "test.md");
131 assert_eq!(parsed.chunk_index, 0);
132 }
133
134 #[test]
135 fn knowledge_query_with_filter() {
136 let q = KnowledgeQuery {
137 text: "rust async".into(),
138 source_filter: Some("docs/".into()),
139 limit: 5,
140 };
141 assert_eq!(q.text, "rust async");
142 assert_eq!(q.source_filter.as_deref(), Some("docs/"));
143 assert_eq!(q.limit, 5);
144 }
145
146 #[test]
147 fn knowledge_query_without_filter() {
148 let q = KnowledgeQuery {
149 text: "search".into(),
150 source_filter: None,
151 limit: 10,
152 };
153 assert!(q.source_filter.is_none());
154 }
155
156 #[test]
157 fn search_result_holds_chunk_and_count() {
158 let result = SearchResult {
159 chunk: Chunk {
160 id: "x-0".into(),
161 content: "test".into(),
162 source: DocumentSource {
163 uri: "f.md".into(),
164 title: "F".into(),
165 },
166 chunk_index: 0,
167 tenant_id: None,
168 },
169 match_count: 3,
170 };
171 assert_eq!(result.match_count, 3);
172 assert_eq!(result.chunk.id, "x-0");
173 }
174
175 #[test]
176 fn knowledge_base_is_object_safe() {
177 fn _accepts_dyn(_kb: &dyn KnowledgeBase) {}
178 }
179}