@knolo/cli 3.2.2 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +49 -3
- package/bin/knolo.mjs +476 -7
- package/package.json +4 -3
- package/templates/icp-knowledge-canister/README.md +71 -0
- package/templates/icp-knowledge-canister/canisters/knolo-core-rust/Cargo.toml +13 -0
- package/templates/icp-knowledge-canister/canisters/knolo-core-rust/src/lib.rs +499 -0
- package/templates/icp-knowledge-canister/canisters/knolo-icp-canister/Cargo.lock +541 -0
- package/templates/icp-knowledge-canister/canisters/knolo-icp-canister/Cargo.toml +20 -0
- package/templates/icp-knowledge-canister/canisters/knolo-icp-canister/knolo_icp.did +29 -0
- package/templates/icp-knowledge-canister/canisters/knolo-icp-canister/src/lib.rs +605 -0
- package/templates/icp-knowledge-canister/dfx.json +25 -0
- package/templates/icp-knowledge-canister/frontend/index.html +12 -0
- package/templates/icp-knowledge-canister/frontend/package.json +27 -0
- package/templates/icp-knowledge-canister/frontend/src/App.tsx +225 -0
- package/templates/icp-knowledge-canister/frontend/src/app.css +271 -0
- package/templates/icp-knowledge-canister/frontend/src/canister.ts +119 -0
- package/templates/icp-knowledge-canister/frontend/src/canisterConfig.test.ts +34 -0
- package/templates/icp-knowledge-canister/frontend/src/canisterConfig.ts +26 -0
- package/templates/icp-knowledge-canister/frontend/src/main.tsx +5 -0
- package/templates/icp-knowledge-canister/frontend/src/vite-env.d.ts +14 -0
- package/templates/icp-knowledge-canister/frontend/tsconfig.json +18 -0
- package/templates/icp-knowledge-canister/frontend/vite.config.ts +66 -0
- package/templates/icp-knowledge-canister/knolo.config.json +15 -0
- package/templates/icp-knowledge-canister/knowledge/alpha.md +5 -0
- package/templates/icp-knowledge-canister/knowledge/guides/beta.md +5 -0
- package/templates/icp-knowledge-canister/scripts/build-sample-pack.mjs +24 -0
- package/templates/icp-knowledge-canister/scripts/query.sh +11 -0
- package/templates/icp-knowledge-canister/scripts/upload-pack.sh +16 -0
|
@@ -0,0 +1,605 @@
|
|
|
1
|
+
use candid::CandidType;
|
|
2
|
+
use ic_cdk::api::{caller, is_controller};
|
|
3
|
+
use ic_cdk::storage::{stable_restore, stable_save};
|
|
4
|
+
use ic_cdk_macros::{post_upgrade, pre_upgrade, query, update};
|
|
5
|
+
use knolo_core_rust::{mount_pack_from_bytes, query as knolo_query, Pack, QueryOptions};
|
|
6
|
+
use serde::Deserialize;
|
|
7
|
+
use std::cell::RefCell;
|
|
8
|
+
|
|
9
|
+
/// Soft ceiling for a single `set_pack` payload.
|
|
10
|
+
/// Local/mainnet update messages are limited; keep well under the practical ingress cap.
|
|
11
|
+
pub const MAX_PACK_BYTES: usize = 2 * 1024 * 1024;
|
|
12
|
+
|
|
13
|
+
#[derive(CandidType, Deserialize, Clone, Debug, PartialEq)]
|
|
14
|
+
pub struct HitDto {
|
|
15
|
+
pub block_id: u64,
|
|
16
|
+
pub score: f64,
|
|
17
|
+
pub text: String,
|
|
18
|
+
pub source: Option<String>,
|
|
19
|
+
pub namespace: Option<String>,
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
#[derive(CandidType, Deserialize, Clone, Debug, PartialEq)]
|
|
23
|
+
pub struct PackInfo {
|
|
24
|
+
pub loaded: bool,
|
|
25
|
+
pub label: Option<String>,
|
|
26
|
+
pub version: Option<u32>,
|
|
27
|
+
pub docs: Option<u64>,
|
|
28
|
+
pub blocks: Option<u64>,
|
|
29
|
+
pub terms: Option<u64>,
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
#[derive(CandidType, Deserialize, Clone, Debug, PartialEq)]
|
|
33
|
+
pub struct HealthDto {
|
|
34
|
+
pub ok: bool,
|
|
35
|
+
pub message: String,
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
#[derive(Default)]
|
|
39
|
+
struct CanisterState {
|
|
40
|
+
loaded_pack: Option<LoadedPack>,
|
|
41
|
+
persisted_pack: Option<PersistedPack>,
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
struct LoadedPack {
|
|
45
|
+
label: Option<String>,
|
|
46
|
+
pack: Pack,
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
#[derive(CandidType, Deserialize, Clone, Debug, PartialEq, Eq)]
|
|
50
|
+
struct PersistedPack {
|
|
51
|
+
bytes: Vec<u8>,
|
|
52
|
+
label: Option<String>,
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
#[derive(CandidType, Deserialize, Clone, Debug, Default, PartialEq, Eq)]
|
|
56
|
+
struct StableSnapshot {
|
|
57
|
+
pack: Option<PersistedPack>,
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
struct PreparedPack {
|
|
61
|
+
loaded: LoadedPack,
|
|
62
|
+
persisted: PersistedPack,
|
|
63
|
+
success_message: String,
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
thread_local! {
|
|
67
|
+
static STATE: RefCell<CanisterState> = RefCell::new(CanisterState::default());
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
#[update]
|
|
71
|
+
fn set_pack(bytes: Vec<u8>, label: String) -> HealthDto {
|
|
72
|
+
if let Err(err) = require_controller() {
|
|
73
|
+
return err;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
let prepared = match prepare_pack(&bytes, label) {
|
|
77
|
+
Ok(prepared) => prepared,
|
|
78
|
+
Err(err) => return err,
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
if let Err(err) = persist_snapshot(&StableSnapshot {
|
|
82
|
+
pack: Some(prepared.persisted.clone()),
|
|
83
|
+
}) {
|
|
84
|
+
return HealthDto {
|
|
85
|
+
ok: false,
|
|
86
|
+
message: format!("Failed to persist pack: {err}"),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
let success_message = prepared.success_message.clone();
|
|
91
|
+
STATE.with(|state| apply_prepared_pack(&mut state.borrow_mut(), prepared));
|
|
92
|
+
|
|
93
|
+
HealthDto {
|
|
94
|
+
ok: true,
|
|
95
|
+
message: success_message,
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
#[update]
|
|
100
|
+
fn clear_pack() -> HealthDto {
|
|
101
|
+
if let Err(err) = require_controller() {
|
|
102
|
+
return err;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if let Err(err) = persist_snapshot(&StableSnapshot::default()) {
|
|
106
|
+
return HealthDto {
|
|
107
|
+
ok: false,
|
|
108
|
+
message: format!("Failed to clear persisted pack: {err}"),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
STATE.with(|state| clear_pack_in_state(&mut state.borrow_mut()))
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
#[query]
|
|
116
|
+
fn pack_info() -> PackInfo {
|
|
117
|
+
STATE.with(|state| pack_info_from_state(&state.borrow()))
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
#[query]
|
|
121
|
+
fn search(q: String, top_k: u32) -> Vec<HitDto> {
|
|
122
|
+
STATE.with(|state| search_in_state(&state.borrow(), q.as_str(), top_k))
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
#[query]
|
|
126
|
+
fn health() -> HealthDto {
|
|
127
|
+
STATE.with(|state| health_from_state(&state.borrow()))
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
#[pre_upgrade]
|
|
131
|
+
fn pre_upgrade() {
|
|
132
|
+
let snapshot = STATE.with(|state| snapshot_from_state(&state.borrow()));
|
|
133
|
+
|
|
134
|
+
if let Err(err) = persist_snapshot(&snapshot) {
|
|
135
|
+
ic_cdk::trap(&format!(
|
|
136
|
+
"Failed to save pack snapshot during pre_upgrade: {err}"
|
|
137
|
+
));
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
#[post_upgrade]
|
|
142
|
+
fn post_upgrade() {
|
|
143
|
+
let snapshot = match load_snapshot() {
|
|
144
|
+
Ok(snapshot) => snapshot,
|
|
145
|
+
Err(err) => ic_cdk::trap(&format!(
|
|
146
|
+
"Failed to load pack snapshot during post_upgrade: {err}"
|
|
147
|
+
)),
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
let restored = match restore_state_from_snapshot(snapshot) {
|
|
151
|
+
Ok(state) => state,
|
|
152
|
+
Err(err) => ic_cdk::trap(&format!(
|
|
153
|
+
"Failed to restore pack during post_upgrade: {err}"
|
|
154
|
+
)),
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
STATE.with(|state| *state.borrow_mut() = restored);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
#[cfg_attr(not(test), allow(dead_code))]
|
|
161
|
+
fn set_pack_in_state(state: &mut CanisterState, bytes: &[u8], label: String) -> HealthDto {
|
|
162
|
+
let prepared = match prepare_pack(bytes, label) {
|
|
163
|
+
Ok(prepared) => prepared,
|
|
164
|
+
Err(err) => return err,
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
let success_message = prepared.success_message.clone();
|
|
168
|
+
apply_prepared_pack(state, prepared);
|
|
169
|
+
|
|
170
|
+
HealthDto {
|
|
171
|
+
ok: true,
|
|
172
|
+
message: success_message,
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
fn clear_pack_in_state(state: &mut CanisterState) -> HealthDto {
|
|
177
|
+
let had_loaded_pack = state.loaded_pack.take().is_some();
|
|
178
|
+
let had_persisted_pack = state.persisted_pack.take().is_some();
|
|
179
|
+
let had_pack = had_loaded_pack || had_persisted_pack;
|
|
180
|
+
|
|
181
|
+
HealthDto {
|
|
182
|
+
ok: true,
|
|
183
|
+
message: if had_pack {
|
|
184
|
+
"Pack cleared.".to_string()
|
|
185
|
+
} else {
|
|
186
|
+
"No pack was loaded.".to_string()
|
|
187
|
+
},
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
fn pack_info_from_state(state: &CanisterState) -> PackInfo {
|
|
192
|
+
match state.loaded_pack.as_ref() {
|
|
193
|
+
Some(loaded) => PackInfo {
|
|
194
|
+
loaded: true,
|
|
195
|
+
label: loaded.label.clone(),
|
|
196
|
+
version: Some(loaded.pack.meta.version),
|
|
197
|
+
docs: Some(to_nat64(loaded.pack.meta.stats.docs)),
|
|
198
|
+
blocks: Some(to_nat64(loaded.pack.meta.stats.blocks)),
|
|
199
|
+
terms: Some(to_nat64(loaded.pack.meta.stats.terms)),
|
|
200
|
+
},
|
|
201
|
+
None => PackInfo {
|
|
202
|
+
loaded: false,
|
|
203
|
+
label: None,
|
|
204
|
+
version: None,
|
|
205
|
+
docs: None,
|
|
206
|
+
blocks: None,
|
|
207
|
+
terms: None,
|
|
208
|
+
},
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
fn search_in_state(state: &CanisterState, q: &str, top_k: u32) -> Vec<HitDto> {
|
|
213
|
+
if q.trim().is_empty() || top_k == 0 {
|
|
214
|
+
return Vec::new();
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
let Some(loaded) = state.loaded_pack.as_ref() else {
|
|
218
|
+
return Vec::new();
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
let top_k = usize::try_from(top_k.min(50)).unwrap_or(50);
|
|
222
|
+
|
|
223
|
+
knolo_query(
|
|
224
|
+
&loaded.pack,
|
|
225
|
+
q,
|
|
226
|
+
QueryOptions {
|
|
227
|
+
top_k,
|
|
228
|
+
..Default::default()
|
|
229
|
+
},
|
|
230
|
+
)
|
|
231
|
+
.into_iter()
|
|
232
|
+
.map(|hit| HitDto {
|
|
233
|
+
block_id: to_nat64(hit.block_id),
|
|
234
|
+
score: hit.score,
|
|
235
|
+
text: hit.text,
|
|
236
|
+
source: hit.source,
|
|
237
|
+
namespace: hit.namespace,
|
|
238
|
+
})
|
|
239
|
+
.collect()
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
fn health_from_state(state: &CanisterState) -> HealthDto {
|
|
243
|
+
match state.loaded_pack.as_ref() {
|
|
244
|
+
Some(loaded) => HealthDto {
|
|
245
|
+
ok: true,
|
|
246
|
+
message: match loaded.label.as_deref() {
|
|
247
|
+
Some(label) => format!("Pack loaded and ready: {label}"),
|
|
248
|
+
None => "Pack loaded and ready.".to_string(),
|
|
249
|
+
},
|
|
250
|
+
},
|
|
251
|
+
None => HealthDto {
|
|
252
|
+
ok: false,
|
|
253
|
+
message: "No pack loaded. Call set_pack(bytes, label) first.".to_string(),
|
|
254
|
+
},
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
fn require_controller() -> Result<(), HealthDto> {
|
|
259
|
+
let principal = caller();
|
|
260
|
+
if is_controller(&principal) {
|
|
261
|
+
Ok(())
|
|
262
|
+
} else {
|
|
263
|
+
Err(HealthDto {
|
|
264
|
+
ok: false,
|
|
265
|
+
message: format!(
|
|
266
|
+
"Unauthorized: caller {principal} is not a controller of this canister."
|
|
267
|
+
),
|
|
268
|
+
})
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
fn prepare_pack(bytes: &[u8], label: String) -> Result<PreparedPack, HealthDto> {
|
|
273
|
+
if bytes.is_empty() {
|
|
274
|
+
return Err(HealthDto {
|
|
275
|
+
ok: false,
|
|
276
|
+
message: "Pack bytes were empty.".to_string(),
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if bytes.len() > MAX_PACK_BYTES {
|
|
281
|
+
return Err(HealthDto {
|
|
282
|
+
ok: false,
|
|
283
|
+
message: format!(
|
|
284
|
+
"Pack is too large: {} bytes exceeds the {} byte limit.",
|
|
285
|
+
bytes.len(),
|
|
286
|
+
MAX_PACK_BYTES
|
|
287
|
+
),
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
match mount_pack_from_bytes(bytes) {
|
|
292
|
+
Ok(pack) => {
|
|
293
|
+
let label = normalize_label(label);
|
|
294
|
+
let success_message = match label.as_deref() {
|
|
295
|
+
Some(label) => format!("Pack loaded successfully: {label}"),
|
|
296
|
+
None => "Pack loaded successfully.".to_string(),
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
Ok(PreparedPack {
|
|
300
|
+
loaded: LoadedPack {
|
|
301
|
+
label: label.clone(),
|
|
302
|
+
pack,
|
|
303
|
+
},
|
|
304
|
+
persisted: PersistedPack {
|
|
305
|
+
bytes: bytes.to_vec(),
|
|
306
|
+
label,
|
|
307
|
+
},
|
|
308
|
+
success_message,
|
|
309
|
+
})
|
|
310
|
+
}
|
|
311
|
+
Err(err) => Err(HealthDto {
|
|
312
|
+
ok: false,
|
|
313
|
+
message: format!("Failed to mount pack: {err}"),
|
|
314
|
+
}),
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
fn apply_prepared_pack(state: &mut CanisterState, prepared: PreparedPack) {
|
|
319
|
+
state.loaded_pack = Some(prepared.loaded);
|
|
320
|
+
state.persisted_pack = Some(prepared.persisted);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
fn snapshot_from_state(state: &CanisterState) -> StableSnapshot {
|
|
324
|
+
StableSnapshot {
|
|
325
|
+
pack: state.persisted_pack.clone(),
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
fn restore_state_from_snapshot(snapshot: StableSnapshot) -> Result<CanisterState, String> {
|
|
330
|
+
let persisted_pack = snapshot.pack;
|
|
331
|
+
let loaded_pack = match persisted_pack.as_ref() {
|
|
332
|
+
Some(persisted) => Some(LoadedPack {
|
|
333
|
+
label: persisted.label.clone(),
|
|
334
|
+
pack: mount_pack_from_bytes(&persisted.bytes)
|
|
335
|
+
.map_err(|err| format!("Failed to mount persisted pack: {err}"))?,
|
|
336
|
+
}),
|
|
337
|
+
None => None,
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
Ok(CanisterState {
|
|
341
|
+
loaded_pack,
|
|
342
|
+
persisted_pack,
|
|
343
|
+
})
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
fn persist_snapshot(snapshot: &StableSnapshot) -> Result<(), String> {
|
|
347
|
+
stable_save((snapshot,)).map_err(|err| format!("failed to write stable snapshot: {err}"))
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
fn load_snapshot() -> Result<StableSnapshot, String> {
|
|
351
|
+
if ic_cdk::api::stable::stable_size() == 0 {
|
|
352
|
+
return Ok(StableSnapshot::default());
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
let (snapshot,): (StableSnapshot,) =
|
|
356
|
+
stable_restore().map_err(|err| format!("failed to read stable snapshot: {err}"))?;
|
|
357
|
+
|
|
358
|
+
Ok(snapshot)
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
fn normalize_label(label: String) -> Option<String> {
|
|
362
|
+
let trimmed = label.trim();
|
|
363
|
+
if trimmed.is_empty() {
|
|
364
|
+
None
|
|
365
|
+
} else {
|
|
366
|
+
Some(trimmed.to_string())
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
fn to_nat64(value: usize) -> u64 {
|
|
371
|
+
u64::try_from(value).unwrap_or(u64::MAX)
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
#[cfg(test)]
|
|
375
|
+
mod tests {
|
|
376
|
+
use super::*;
|
|
377
|
+
use candid::{decode_args, encode_args};
|
|
378
|
+
|
|
379
|
+
fn build_test_pack_bytes() -> Vec<u8> {
|
|
380
|
+
let meta =
|
|
381
|
+
b"{\"version\":3,\"stats\":{\"docs\":2,\"blocks\":2,\"terms\":4,\"avgBlockLen\":2.5}}"
|
|
382
|
+
.to_vec();
|
|
383
|
+
let lexicon = b"[[\"alpha\",1],[\"beta\",2],[\"gamma\",3],[\"delta\",4]]".to_vec();
|
|
384
|
+
|
|
385
|
+
let postings: Vec<u32> = vec![
|
|
386
|
+
1, 1, 1, 0, 0, 2, 1, 2, 0, 2, 1, 0, 0, 3, 1, 3, 0, 0, 4, 2, 2, 0, 0,
|
|
387
|
+
];
|
|
388
|
+
|
|
389
|
+
let blocks = b"[{\"text\":\"alpha beta gamma\",\"heading\":\"A\",\"docId\":\"a\",\"namespace\":\"docs\",\"len\":3},{\"text\":\"beta delta\",\"heading\":\"B\",\"docId\":\"b\",\"namespace\":\"guides\",\"len\":2}]".to_vec();
|
|
390
|
+
|
|
391
|
+
let mut out = Vec::new();
|
|
392
|
+
push_section(&mut out, &meta);
|
|
393
|
+
push_section(&mut out, &lexicon);
|
|
394
|
+
out.extend_from_slice(&(postings.len() as u32).to_le_bytes());
|
|
395
|
+
for posting in postings {
|
|
396
|
+
out.extend_from_slice(&posting.to_le_bytes());
|
|
397
|
+
}
|
|
398
|
+
push_section(&mut out, &blocks);
|
|
399
|
+
out
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
fn push_section(out: &mut Vec<u8>, bytes: &[u8]) {
|
|
403
|
+
out.extend_from_slice(&(bytes.len() as u32).to_le_bytes());
|
|
404
|
+
out.extend_from_slice(bytes);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
#[test]
|
|
408
|
+
fn set_pack_populates_pack_info_and_health() {
|
|
409
|
+
let mut state = CanisterState::default();
|
|
410
|
+
|
|
411
|
+
let result = set_pack_in_state(
|
|
412
|
+
&mut state,
|
|
413
|
+
&build_test_pack_bytes(),
|
|
414
|
+
"docs-pack".to_string(),
|
|
415
|
+
);
|
|
416
|
+
|
|
417
|
+
assert!(result.ok);
|
|
418
|
+
assert!(result.message.contains("docs-pack"));
|
|
419
|
+
|
|
420
|
+
let info = pack_info_from_state(&state);
|
|
421
|
+
assert_eq!(
|
|
422
|
+
info,
|
|
423
|
+
PackInfo {
|
|
424
|
+
loaded: true,
|
|
425
|
+
label: Some("docs-pack".to_string()),
|
|
426
|
+
version: Some(3),
|
|
427
|
+
docs: Some(2),
|
|
428
|
+
blocks: Some(2),
|
|
429
|
+
terms: Some(4),
|
|
430
|
+
}
|
|
431
|
+
);
|
|
432
|
+
|
|
433
|
+
let health = health_from_state(&state);
|
|
434
|
+
assert!(health.ok);
|
|
435
|
+
assert!(health.message.contains("ready"));
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
#[test]
|
|
439
|
+
fn prepare_pack_rejects_oversize_payload() {
|
|
440
|
+
let oversized = vec![0u8; MAX_PACK_BYTES + 1];
|
|
441
|
+
let err = prepare_pack(&oversized, "too-big".to_string())
|
|
442
|
+
.err()
|
|
443
|
+
.expect("oversize pack should fail");
|
|
444
|
+
|
|
445
|
+
assert!(!err.ok);
|
|
446
|
+
assert!(err.message.contains("too large"));
|
|
447
|
+
assert!(err.message.contains(&MAX_PACK_BYTES.to_string()));
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
#[test]
|
|
451
|
+
fn prepare_pack_rejects_empty_payload() {
|
|
452
|
+
let err = prepare_pack(&[], "empty".to_string())
|
|
453
|
+
.err()
|
|
454
|
+
.expect("empty pack should fail");
|
|
455
|
+
|
|
456
|
+
assert!(!err.ok);
|
|
457
|
+
assert!(err.message.contains("empty"));
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
#[test]
|
|
461
|
+
fn invalid_pack_returns_friendly_error_and_preserves_loaded_pack() {
|
|
462
|
+
let mut state = CanisterState::default();
|
|
463
|
+
|
|
464
|
+
let first = set_pack_in_state(
|
|
465
|
+
&mut state,
|
|
466
|
+
&build_test_pack_bytes(),
|
|
467
|
+
"kept-pack".to_string(),
|
|
468
|
+
);
|
|
469
|
+
assert!(first.ok);
|
|
470
|
+
|
|
471
|
+
let result = set_pack_in_state(&mut state, &[1, 2, 3], "bad-pack".to_string());
|
|
472
|
+
|
|
473
|
+
assert!(!result.ok);
|
|
474
|
+
assert!(result.message.contains("Failed to mount pack"));
|
|
475
|
+
|
|
476
|
+
let info = pack_info_from_state(&state);
|
|
477
|
+
assert_eq!(info.label.as_deref(), Some("kept-pack"));
|
|
478
|
+
assert_eq!(info.blocks, Some(2));
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
#[test]
|
|
482
|
+
fn search_returns_ranked_hits_and_maps_fields() {
|
|
483
|
+
let mut state = CanisterState::default();
|
|
484
|
+
set_pack_in_state(
|
|
485
|
+
&mut state,
|
|
486
|
+
&build_test_pack_bytes(),
|
|
487
|
+
"docs-pack".to_string(),
|
|
488
|
+
);
|
|
489
|
+
|
|
490
|
+
let hits = search_in_state(&state, "alpha beta", 50);
|
|
491
|
+
|
|
492
|
+
assert_eq!(hits.len(), 2);
|
|
493
|
+
assert_eq!(hits[0].block_id, 0);
|
|
494
|
+
assert_eq!(hits[0].source.as_deref(), Some("a"));
|
|
495
|
+
assert_eq!(hits[0].namespace.as_deref(), Some("docs"));
|
|
496
|
+
assert!(hits[0].score > hits[1].score);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
#[test]
|
|
500
|
+
fn search_returns_empty_when_query_is_blank_or_top_k_is_zero() {
|
|
501
|
+
let mut state = CanisterState::default();
|
|
502
|
+
set_pack_in_state(
|
|
503
|
+
&mut state,
|
|
504
|
+
&build_test_pack_bytes(),
|
|
505
|
+
"docs-pack".to_string(),
|
|
506
|
+
);
|
|
507
|
+
|
|
508
|
+
assert!(search_in_state(&state, " ", 5).is_empty());
|
|
509
|
+
assert!(search_in_state(&state, "beta", 0).is_empty());
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
#[test]
|
|
513
|
+
fn search_without_pack_is_empty_and_health_explains_why() {
|
|
514
|
+
let state = CanisterState::default();
|
|
515
|
+
|
|
516
|
+
assert!(search_in_state(&state, "alpha", 5).is_empty());
|
|
517
|
+
assert_eq!(
|
|
518
|
+
health_from_state(&state),
|
|
519
|
+
HealthDto {
|
|
520
|
+
ok: false,
|
|
521
|
+
message: "No pack loaded. Call set_pack(bytes, label) first.".to_string(),
|
|
522
|
+
}
|
|
523
|
+
);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
#[test]
|
|
527
|
+
fn clear_pack_resets_loaded_state() {
|
|
528
|
+
let mut state = CanisterState::default();
|
|
529
|
+
set_pack_in_state(
|
|
530
|
+
&mut state,
|
|
531
|
+
&build_test_pack_bytes(),
|
|
532
|
+
"docs-pack".to_string(),
|
|
533
|
+
);
|
|
534
|
+
|
|
535
|
+
let result = clear_pack_in_state(&mut state);
|
|
536
|
+
|
|
537
|
+
assert!(result.ok);
|
|
538
|
+
assert_eq!(result.message, "Pack cleared.");
|
|
539
|
+
assert!(state.persisted_pack.is_none());
|
|
540
|
+
assert_eq!(
|
|
541
|
+
pack_info_from_state(&state),
|
|
542
|
+
PackInfo {
|
|
543
|
+
loaded: false,
|
|
544
|
+
label: None,
|
|
545
|
+
version: None,
|
|
546
|
+
docs: None,
|
|
547
|
+
blocks: None,
|
|
548
|
+
terms: None,
|
|
549
|
+
}
|
|
550
|
+
);
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
#[test]
|
|
554
|
+
fn snapshot_encoding_round_trip_preserves_bytes_and_label() {
|
|
555
|
+
let snapshot = StableSnapshot {
|
|
556
|
+
pack: Some(PersistedPack {
|
|
557
|
+
bytes: build_test_pack_bytes(),
|
|
558
|
+
label: Some("docs-pack".to_string()),
|
|
559
|
+
}),
|
|
560
|
+
};
|
|
561
|
+
|
|
562
|
+
let encoded = encode_args((snapshot.clone(),)).expect("snapshot should encode");
|
|
563
|
+
let (decoded,): (StableSnapshot,) = decode_args(&encoded).expect("snapshot should decode");
|
|
564
|
+
|
|
565
|
+
assert_eq!(decoded, snapshot);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
#[test]
|
|
569
|
+
fn restore_state_from_snapshot_remounts_pack_and_label() {
|
|
570
|
+
let snapshot = StableSnapshot {
|
|
571
|
+
pack: Some(PersistedPack {
|
|
572
|
+
bytes: build_test_pack_bytes(),
|
|
573
|
+
label: Some("docs-pack".to_string()),
|
|
574
|
+
}),
|
|
575
|
+
};
|
|
576
|
+
|
|
577
|
+
let restored = restore_state_from_snapshot(snapshot).expect("snapshot should restore");
|
|
578
|
+
|
|
579
|
+
assert_eq!(
|
|
580
|
+
pack_info_from_state(&restored),
|
|
581
|
+
PackInfo {
|
|
582
|
+
loaded: true,
|
|
583
|
+
label: Some("docs-pack".to_string()),
|
|
584
|
+
version: Some(3),
|
|
585
|
+
docs: Some(2),
|
|
586
|
+
blocks: Some(2),
|
|
587
|
+
terms: Some(4),
|
|
588
|
+
}
|
|
589
|
+
);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
#[test]
|
|
593
|
+
fn restore_state_from_snapshot_rejects_invalid_pack_bytes() {
|
|
594
|
+
let err = restore_state_from_snapshot(StableSnapshot {
|
|
595
|
+
pack: Some(PersistedPack {
|
|
596
|
+
bytes: vec![1, 2, 3],
|
|
597
|
+
label: Some("broken".to_string()),
|
|
598
|
+
}),
|
|
599
|
+
})
|
|
600
|
+
.err()
|
|
601
|
+
.expect("invalid persisted bytes should fail restore");
|
|
602
|
+
|
|
603
|
+
assert!(err.contains("Failed to mount persisted pack"));
|
|
604
|
+
}
|
|
605
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 1,
|
|
3
|
+
"dfx": "0.20.0",
|
|
4
|
+
"canisters": {
|
|
5
|
+
"knolo_knowledge": {
|
|
6
|
+
"type": "custom",
|
|
7
|
+
"candid": "./canisters/knolo-icp-canister/knolo_icp.did",
|
|
8
|
+
"wasm": "./.cargo-target/wasm32-unknown-unknown/release/knolo_icp_canister.wasm",
|
|
9
|
+
"build": [
|
|
10
|
+
"CARGO_TARGET_DIR=./.cargo-target cargo build --target wasm32-unknown-unknown --release --manifest-path ./canisters/knolo-icp-canister/Cargo.toml"
|
|
11
|
+
],
|
|
12
|
+
"metadata": [
|
|
13
|
+
{
|
|
14
|
+
"name": "candid:service"
|
|
15
|
+
}
|
|
16
|
+
],
|
|
17
|
+
"declarations": {
|
|
18
|
+
"bindings": [
|
|
19
|
+
"did"
|
|
20
|
+
],
|
|
21
|
+
"output": "./.dfx/local/declarations/knolo_knowledge"
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>Knolo ICP Search</title>
|
|
7
|
+
</head>
|
|
8
|
+
<body>
|
|
9
|
+
<div id="root"></div>
|
|
10
|
+
<script type="module" src="/src/main.tsx"></script>
|
|
11
|
+
</body>
|
|
12
|
+
</html>
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "icp-knowledge-canister-frontend",
|
|
3
|
+
"private": true,
|
|
4
|
+
"version": "0.0.0",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "vite",
|
|
8
|
+
"build": "tsc --noEmit && vite build",
|
|
9
|
+
"test": "vitest run"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"@dfinity/agent": "^0.20.2",
|
|
13
|
+
"@dfinity/candid": "^0.20.2",
|
|
14
|
+
"@dfinity/principal": "^0.20.2",
|
|
15
|
+
"react": "^18.3.1",
|
|
16
|
+
"react-dom": "^18.3.1"
|
|
17
|
+
},
|
|
18
|
+
"devDependencies": {
|
|
19
|
+
"@types/node": "^24.0.0",
|
|
20
|
+
"@types/react": "^18.3.11",
|
|
21
|
+
"@types/react-dom": "^18.3.1",
|
|
22
|
+
"@vitejs/plugin-react": "^4.3.3",
|
|
23
|
+
"typescript": "^5.9.3",
|
|
24
|
+
"vite": "^5.4.10",
|
|
25
|
+
"vitest": "^2.1.4"
|
|
26
|
+
}
|
|
27
|
+
}
|