1use std::path::Path;
4
5use crate::storage::{AvisReader, AvisWriter};
6use crate::types::{VisionResult, VisualMemoryStore};
7
8pub fn cmd_create(path: &Path, dimension: u32) -> VisionResult<()> {
10 let store = VisualMemoryStore::new(dimension);
11 AvisWriter::write_to_file(&store, path)?;
12 println!("Created {}", path.display());
13 Ok(())
14}
15
16pub fn cmd_info(path: &Path, json: bool) -> VisionResult<()> {
18 let store = AvisReader::read_from_file(path)?;
19 let file_size = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);
20
21 if json {
22 let info = serde_json::json!({
23 "file": path.display().to_string(),
24 "observations": store.count(),
25 "embedding_dim": store.embedding_dim,
26 "sessions": store.session_count,
27 "next_id": store.next_id,
28 "created_at": store.created_at,
29 "updated_at": store.updated_at,
30 "file_bytes": file_size,
31 });
32 println!(
33 "{}",
34 serde_json::to_string_pretty(&info).unwrap_or_default()
35 );
36 } else {
37 println!("File: {}", path.display());
38 println!("Observations: {}", store.count());
39 println!("Embedding dim: {}", store.embedding_dim);
40 println!("Sessions: {}", store.session_count);
41 println!("Next ID: {}", store.next_id);
42 println!("Created: {}", format_ts(store.created_at));
43 println!("Updated: {}", format_ts(store.updated_at));
44 println!("File size: {} bytes", file_size);
45 }
46
47 Ok(())
48}
49
50pub fn cmd_capture(
52 path: &Path,
53 source_path: &str,
54 labels: Vec<String>,
55 description: Option<String>,
56 model_path: Option<&str>,
57 json: bool,
58) -> VisionResult<()> {
59 let mut store = if path.exists() {
60 AvisReader::read_from_file(path)?
61 } else {
62 VisualMemoryStore::new(crate::EMBEDDING_DIM)
63 };
64
65 let (img, source) = crate::capture_from_file(source_path)?;
66 let thumbnail = crate::generate_thumbnail(&img);
67 let mut engine = crate::EmbeddingEngine::new(model_path)?;
68 let embedding = engine.embed(&img)?;
69
70 let (width, height) = (img.width(), img.height());
71 let obs = crate::types::VisualObservation {
72 id: 0,
73 timestamp: now_secs(),
74 session_id: 0,
75 source,
76 embedding,
77 thumbnail,
78 metadata: crate::types::ObservationMeta {
79 width: std::cmp::min(width, 512),
80 height: std::cmp::min(height, 512),
81 original_width: width,
82 original_height: height,
83 labels,
84 description,
85 quality_score: compute_quality(width, height),
86 },
87 memory_link: None,
88 };
89
90 let id = store.add(obs);
91 AvisWriter::write_to_file(&store, path)?;
92
93 if json {
94 let out = serde_json::json!({ "id": id, "file": path.display().to_string() });
95 println!("{}", serde_json::to_string_pretty(&out).unwrap_or_default());
96 } else {
97 println!("Captured observation {} -> {}", id, path.display());
98 }
99
100 Ok(())
101}
102
103pub fn cmd_query(
105 path: &Path,
106 session: Option<u32>,
107 labels: Option<Vec<String>>,
108 limit: usize,
109 json: bool,
110) -> VisionResult<()> {
111 let store = AvisReader::read_from_file(path)?;
112 let mut results: Vec<_> = store.observations.iter().collect();
113
114 if let Some(sid) = session {
115 results.retain(|o| o.session_id == sid);
116 }
117 if let Some(ref lbls) = labels {
118 results.retain(|o| lbls.iter().any(|l| o.metadata.labels.contains(l)));
119 }
120
121 results.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
122 results.truncate(limit);
123
124 if json {
125 let items: Vec<_> = results.iter().map(|o| obs_summary(o)).collect();
126 println!(
127 "{}",
128 serde_json::to_string_pretty(&items).unwrap_or_default()
129 );
130 } else {
131 if results.is_empty() {
132 println!("No observations found.");
133 return Ok(());
134 }
135 for o in &results {
136 println!(
137 " [{:>4}] session={} {}x{} labels={:?} q={:.2} {}",
138 o.id,
139 o.session_id,
140 o.metadata.original_width,
141 o.metadata.original_height,
142 o.metadata.labels,
143 o.metadata.quality_score,
144 format_ts(o.timestamp),
145 );
146 }
147 println!("{} observation(s)", results.len());
148 }
149
150 Ok(())
151}
152
153pub fn cmd_similar(
155 path: &Path,
156 capture_id: u64,
157 top_k: usize,
158 min_similarity: f32,
159 json: bool,
160) -> VisionResult<()> {
161 let store = AvisReader::read_from_file(path)?;
162 let obs = store
163 .get(capture_id)
164 .ok_or(crate::types::VisionError::CaptureNotFound(capture_id))?;
165
166 let matches = crate::find_similar(&obs.embedding, &store.observations, top_k, min_similarity);
167
168 if json {
169 println!(
170 "{}",
171 serde_json::to_string_pretty(&matches).unwrap_or_default()
172 );
173 } else {
174 if matches.is_empty() {
175 println!("No similar captures found.");
176 return Ok(());
177 }
178 for m in &matches {
179 println!(" [{:>4}] similarity={:.4}", m.id, m.similarity);
180 }
181 }
182
183 Ok(())
184}
185
186pub fn cmd_compare(path: &Path, id_a: u64, id_b: u64, json: bool) -> VisionResult<()> {
188 let store = AvisReader::read_from_file(path)?;
189 let a = store
190 .get(id_a)
191 .ok_or(crate::types::VisionError::CaptureNotFound(id_a))?;
192 let b = store
193 .get(id_b)
194 .ok_or(crate::types::VisionError::CaptureNotFound(id_b))?;
195
196 let sim = crate::cosine_similarity(&a.embedding, &b.embedding);
197
198 if json {
199 let out = serde_json::json!({
200 "id_a": id_a, "id_b": id_b, "similarity": sim, "is_same": sim > 0.95,
201 });
202 println!("{}", serde_json::to_string_pretty(&out).unwrap_or_default());
203 } else {
204 println!("Compare {} vs {}", id_a, id_b);
205 println!(" Embedding similarity: {:.4}", sim);
206 println!(
207 " Same image: {}",
208 if sim > 0.95 { "yes" } else { "no" }
209 );
210 }
211
212 Ok(())
213}
214
215pub fn cmd_diff(path: &Path, id_a: u64, id_b: u64, json: bool) -> VisionResult<()> {
217 let store = AvisReader::read_from_file(path)?;
218 let a = store
219 .get(id_a)
220 .ok_or(crate::types::VisionError::CaptureNotFound(id_a))?;
221 let b = store
222 .get(id_b)
223 .ok_or(crate::types::VisionError::CaptureNotFound(id_b))?;
224
225 let img_a = image::load_from_memory(&a.thumbnail).map_err(crate::types::VisionError::Image)?;
226 let img_b = image::load_from_memory(&b.thumbnail).map_err(crate::types::VisionError::Image)?;
227
228 let diff = crate::compute_diff(id_a, id_b, &img_a, &img_b)?;
229
230 if json {
231 println!(
232 "{}",
233 serde_json::to_string_pretty(&diff).unwrap_or_default()
234 );
235 } else {
236 println!("Diff {} vs {}", id_a, id_b);
237 println!(" Similarity: {:.4}", diff.similarity);
238 println!(" Pixel diff: {:.2}%", diff.pixel_diff_ratio * 100.0);
239 println!(" Changed regions: {}", diff.changed_regions.len());
240 for (i, r) in diff.changed_regions.iter().enumerate() {
241 println!(" [{}] x={} y={} {}x{}", i, r.x, r.y, r.w, r.h);
242 }
243 }
244
245 Ok(())
246}
247
248pub fn cmd_health(
250 path: &Path,
251 stale_hours: u64,
252 low_quality_threshold: f32,
253 max_examples: usize,
254 json: bool,
255) -> VisionResult<()> {
256 let store = AvisReader::read_from_file(path)?;
257 let now = now_secs();
258 let stale_cutoff = now.saturating_sub(stale_hours * 3600);
259
260 let low_quality: Vec<u64> = store
261 .observations
262 .iter()
263 .filter(|o| o.metadata.quality_score < low_quality_threshold)
264 .take(max_examples)
265 .map(|o| o.id)
266 .collect();
267
268 let stale: Vec<u64> = store
269 .observations
270 .iter()
271 .filter(|o| o.timestamp < stale_cutoff)
272 .take(max_examples)
273 .map(|o| o.id)
274 .collect();
275
276 let unlinked: Vec<u64> = store
277 .observations
278 .iter()
279 .filter(|o| o.memory_link.is_none())
280 .take(max_examples)
281 .map(|o| o.id)
282 .collect();
283
284 let unlabeled: Vec<u64> = store
285 .observations
286 .iter()
287 .filter(|o| o.metadata.labels.is_empty())
288 .take(max_examples)
289 .map(|o| o.id)
290 .collect();
291
292 let status = if low_quality.is_empty() && stale.is_empty() {
293 "pass"
294 } else if low_quality.len() > 5 || stale.len() > 10 {
295 "fail"
296 } else {
297 "warn"
298 };
299
300 if json {
301 let out = serde_json::json!({
302 "status": status,
303 "total_observations": store.count(),
304 "low_quality_ids": low_quality,
305 "stale_ids": stale,
306 "unlinked_memory_ids": unlinked,
307 "unlabeled_ids": unlabeled,
308 });
309 println!("{}", serde_json::to_string_pretty(&out).unwrap_or_default());
310 } else {
311 println!("Health: {}", status.to_uppercase());
312 println!(" Total observations: {}", store.count());
313 println!(
314 " Low quality (< {:.2}): {}",
315 low_quality_threshold,
316 low_quality.len()
317 );
318 println!(" Stale (> {}h): {}", stale_hours, stale.len());
319 println!(" Unlinked memory: {}", unlinked.len());
320 println!(" Unlabeled: {}", unlabeled.len());
321 }
322
323 Ok(())
324}
325
326pub fn cmd_link(path: &Path, capture_id: u64, memory_node_id: u64, json: bool) -> VisionResult<()> {
328 let mut store = AvisReader::read_from_file(path)?;
329 let obs = store
330 .get_mut(capture_id)
331 .ok_or(crate::types::VisionError::CaptureNotFound(capture_id))?;
332
333 obs.memory_link = Some(memory_node_id);
334 AvisWriter::write_to_file(&store, path)?;
335
336 if json {
337 let out = serde_json::json!({ "status": "linked", "capture_id": capture_id, "memory_node_id": memory_node_id });
338 println!("{}", serde_json::to_string_pretty(&out).unwrap_or_default());
339 } else {
340 println!(
341 "Linked capture {} -> memory node {}",
342 capture_id, memory_node_id
343 );
344 }
345
346 Ok(())
347}
348
349pub fn cmd_stats(path: &Path, json: bool) -> VisionResult<()> {
351 let store = AvisReader::read_from_file(path)?;
352 let file_size = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);
353
354 let total = store.count();
355 let linked = store
356 .observations
357 .iter()
358 .filter(|o| o.memory_link.is_some())
359 .count();
360 let labeled = store
361 .observations
362 .iter()
363 .filter(|o| !o.metadata.labels.is_empty())
364 .count();
365 let avg_quality = if total > 0 {
366 store
367 .observations
368 .iter()
369 .map(|o| o.metadata.quality_score)
370 .sum::<f32>()
371 / total as f32
372 } else {
373 0.0
374 };
375
376 let mut sessions = std::collections::HashSet::new();
377 for o in &store.observations {
378 sessions.insert(o.session_id);
379 }
380
381 if json {
382 let out = serde_json::json!({
383 "observations": total, "sessions": sessions.len(),
384 "linked_to_memory": linked, "labeled": labeled,
385 "avg_quality": avg_quality, "embedding_dim": store.embedding_dim,
386 "file_bytes": file_size,
387 });
388 println!("{}", serde_json::to_string_pretty(&out).unwrap_or_default());
389 } else {
390 println!("Observations: {}", total);
391 println!("Sessions: {}", sessions.len());
392 println!("Linked to memory: {}", linked);
393 println!("Labeled: {}", labeled);
394 println!("Avg quality: {:.3}", avg_quality);
395 println!("Embedding dim: {}", store.embedding_dim);
396 println!("File size: {} bytes", file_size);
397 }
398
399 Ok(())
400}
401
402pub fn cmd_export(path: &Path, pretty: bool) -> VisionResult<()> {
404 let store = AvisReader::read_from_file(path)?;
405 let items: Vec<_> = store.observations.iter().map(obs_full).collect();
406
407 let output = if pretty {
408 serde_json::to_string_pretty(&items)
409 } else {
410 serde_json::to_string(&items)
411 };
412
413 println!("{}", output.unwrap_or_else(|_| "[]".to_string()));
414 Ok(())
415}
416
417fn now_secs() -> u64 {
420 std::time::SystemTime::now()
421 .duration_since(std::time::UNIX_EPOCH)
422 .unwrap_or_default()
423 .as_secs()
424}
425
426fn format_ts(ts: u64) -> String {
427 chrono::DateTime::from_timestamp(ts as i64, 0)
428 .map(|dt| dt.format("%Y-%m-%d %H:%M:%S UTC").to_string())
429 .unwrap_or_else(|| ts.to_string())
430}
431
432fn compute_quality(w: u32, h: u32) -> f32 {
433 let pixels = (w as f64) * (h as f64);
434 let max_pixels = 1920.0 * 1080.0;
435 (pixels / max_pixels).min(1.0) as f32
436}
437
438fn obs_summary(o: &crate::types::VisualObservation) -> serde_json::Value {
439 serde_json::json!({
440 "id": o.id, "session_id": o.session_id, "timestamp": o.timestamp,
441 "width": o.metadata.original_width, "height": o.metadata.original_height,
442 "labels": o.metadata.labels, "description": o.metadata.description,
443 "quality_score": o.metadata.quality_score, "memory_link": o.memory_link,
444 })
445}
446
447fn obs_full(o: &crate::types::VisualObservation) -> serde_json::Value {
448 serde_json::json!({
449 "id": o.id, "session_id": o.session_id, "timestamp": o.timestamp,
450 "source": o.source, "embedding_len": o.embedding.len(),
451 "thumbnail_bytes": o.thumbnail.len(), "metadata": o.metadata,
452 "memory_link": o.memory_link,
453 })
454}