1pub mod evidence;
9pub mod index_status;
10pub mod selections;
11
12use std::sync::Arc;
13
14use axum::extract::{Path, Query, State};
15use axum::Json;
16use serde::{Deserialize, Serialize};
17
18use tuitbot_core::context::graph_expansion::{self, GraphState};
19use tuitbot_core::context::retrieval::{self, VaultCitation};
20use tuitbot_core::storage::watchtower;
21
22use crate::account::AccountContext;
23use crate::error::ApiError;
24use crate::state::AppState;
25
26const SNIPPET_MAX_LEN: usize = 120;
28
29const DEFAULT_LIMIT: u32 = 20;
31
32const MAX_LIMIT: u32 = 100;
34
35fn clamp_limit(limit: Option<u32>) -> u32 {
36 limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT)
37}
38
39fn truncate_snippet(text: &str, max_len: usize) -> String {
40 if text.len() <= max_len {
41 text.to_string()
42 } else {
43 let mut end = max_len.saturating_sub(3);
44 while end > 0 && !text.is_char_boundary(end) {
45 end -= 1;
46 }
47 format!("{}...", &text[..end])
48 }
49}
50
51#[derive(Serialize)]
56pub struct VaultSourcesResponse {
57 pub sources: Vec<VaultSourceStatusItem>,
58 pub deployment_mode: String,
59 pub privacy_envelope: String,
60}
61
62#[derive(Serialize)]
63pub struct VaultSourceStatusItem {
64 pub id: i64,
65 pub source_type: String,
66 pub status: String,
67 pub error_message: Option<String>,
68 pub node_count: i64,
69 pub updated_at: String,
70 #[serde(skip_serializing_if = "Option::is_none")]
73 pub path: Option<String>,
74}
75
76pub async fn vault_sources(
77 State(state): State<Arc<AppState>>,
78 ctx: AccountContext,
79) -> Result<Json<VaultSourcesResponse>, ApiError> {
80 let sources = watchtower::get_all_source_contexts_for(&state.db, &ctx.account_id).await?;
81
82 let is_cloud = matches!(
83 state.deployment_mode,
84 tuitbot_core::config::DeploymentMode::Cloud
85 );
86
87 let mut items = Vec::with_capacity(sources.len());
88 for src in sources {
89 let count = watchtower::count_nodes_for_source(&state.db, &ctx.account_id, src.id)
90 .await
91 .unwrap_or(0);
92 let path = if src.source_type == "local_fs" && !is_cloud {
94 serde_json::from_str::<serde_json::Value>(&src.config_json)
95 .ok()
96 .and_then(|v| v.get("path").and_then(|p| p.as_str().map(String::from)))
97 } else {
98 None
99 };
100 items.push(VaultSourceStatusItem {
101 id: src.id,
102 source_type: src.source_type,
103 status: src.status,
104 error_message: src.error_message,
105 node_count: count,
106 updated_at: src.updated_at,
107 path,
108 });
109 }
110
111 Ok(Json(VaultSourcesResponse {
112 sources: items,
113 deployment_mode: state.deployment_mode.to_string(),
114 privacy_envelope: state.deployment_mode.privacy_envelope().to_string(),
115 }))
116}
117
118#[derive(Deserialize)]
123pub struct SearchNotesQuery {
124 pub q: Option<String>,
125 pub source_id: Option<i64>,
126 pub limit: Option<u32>,
127}
128
129#[derive(Serialize)]
130pub struct SearchNotesResponse {
131 pub notes: Vec<VaultNoteItem>,
132}
133
134#[derive(Serialize)]
135pub struct VaultNoteItem {
136 pub node_id: i64,
137 pub source_id: i64,
138 pub title: Option<String>,
139 pub relative_path: String,
140 pub tags: Option<String>,
141 pub status: String,
142 pub chunk_count: i64,
143 pub updated_at: String,
144}
145
146pub async fn search_notes(
147 State(state): State<Arc<AppState>>,
148 ctx: AccountContext,
149 Query(params): Query<SearchNotesQuery>,
150) -> Result<Json<SearchNotesResponse>, ApiError> {
151 let limit = clamp_limit(params.limit);
152
153 let nodes = match (¶ms.q, params.source_id) {
154 (Some(q), _) if !q.is_empty() => {
155 watchtower::search_nodes_for(&state.db, &ctx.account_id, q, limit).await?
156 }
157 (_, Some(sid)) => {
158 watchtower::get_nodes_for_source_for(&state.db, &ctx.account_id, sid, limit).await?
159 }
160 _ => {
161 watchtower::search_nodes_for(&state.db, &ctx.account_id, "", limit).await?
163 }
164 };
165
166 let mut notes = Vec::with_capacity(nodes.len());
167 for node in nodes {
168 let chunk_count =
169 watchtower::count_chunks_for_node(&state.db, &ctx.account_id, node.id).await?;
170 notes.push(VaultNoteItem {
171 node_id: node.id,
172 source_id: node.source_id,
173 title: node.title,
174 relative_path: node.relative_path,
175 tags: node.tags,
176 status: node.status,
177 chunk_count,
178 updated_at: node.updated_at,
179 });
180 }
181
182 Ok(Json(SearchNotesResponse { notes }))
183}
184
185#[derive(Serialize)]
190pub struct VaultNoteDetail {
191 pub node_id: i64,
192 pub source_id: i64,
193 pub title: Option<String>,
194 pub relative_path: String,
195 pub tags: Option<String>,
196 pub status: String,
197 pub ingested_at: String,
198 pub updated_at: String,
199 pub chunks: Vec<VaultChunkSummary>,
200}
201
202#[derive(Serialize)]
203pub struct VaultChunkSummary {
204 pub chunk_id: i64,
205 pub heading_path: String,
206 pub snippet: String,
207 pub retrieval_boost: f64,
208}
209
210pub async fn note_detail(
211 State(state): State<Arc<AppState>>,
212 ctx: AccountContext,
213 Path(id): Path<i64>,
214) -> Result<Json<VaultNoteDetail>, ApiError> {
215 let node = watchtower::get_content_node_for(&state.db, &ctx.account_id, id)
216 .await?
217 .ok_or_else(|| ApiError::NotFound(format!("note {id} not found")))?;
218
219 let chunks = watchtower::get_chunks_for_node(&state.db, &ctx.account_id, id).await?;
220
221 let chunk_summaries: Vec<VaultChunkSummary> = chunks
222 .into_iter()
223 .map(|c| VaultChunkSummary {
224 chunk_id: c.id,
225 heading_path: c.heading_path,
226 snippet: truncate_snippet(&c.chunk_text, SNIPPET_MAX_LEN),
227 retrieval_boost: c.retrieval_boost,
228 })
229 .collect();
230
231 Ok(Json(VaultNoteDetail {
232 node_id: node.id,
233 source_id: node.source_id,
234 title: node.title,
235 relative_path: node.relative_path,
236 tags: node.tags,
237 status: node.status,
238 ingested_at: node.ingested_at,
239 updated_at: node.updated_at,
240 chunks: chunk_summaries,
241 }))
242}
243
244#[derive(Deserialize)]
249pub struct NoteNeighborsQuery {
250 pub max: Option<u32>,
251}
252
253#[derive(Serialize)]
254pub struct NoteNeighborsResponse {
255 pub node_id: i64,
256 pub neighbors: Vec<NeighborItem>,
257 pub total_edges: u32,
258 pub graph_state: GraphState,
259}
260
261#[derive(Serialize)]
262pub struct NeighborItem {
263 pub node_id: i64,
264 pub node_title: Option<String>,
265 pub reason: String,
266 pub reason_label: String,
267 pub intent: String,
268 pub matched_tags: Vec<String>,
269 pub score: f64,
270 pub snippet: Option<String>,
271 pub best_chunk_id: Option<i64>,
272 pub heading_path: Option<String>,
273 #[serde(skip_serializing_if = "Option::is_none")]
274 pub relative_path: Option<String>,
275}
276
277impl NeighborItem {
278 fn from_graph_neighbor(n: graph_expansion::GraphNeighbor, is_cloud: bool) -> Self {
279 Self {
280 node_id: n.node_id,
281 node_title: n.node_title,
282 reason: serde_json::to_value(&n.reason)
283 .ok()
284 .and_then(|v| v.as_str().map(String::from))
285 .unwrap_or_else(|| "related".to_string()),
286 reason_label: n.reason_label,
287 intent: serde_json::to_value(&n.intent)
288 .ok()
289 .and_then(|v| v.as_str().map(String::from))
290 .unwrap_or_else(|| "related".to_string()),
291 matched_tags: n.matched_tags,
292 score: n.score,
293 snippet: n.snippet,
294 best_chunk_id: n.best_chunk_id,
295 heading_path: n.heading_path,
296 relative_path: if is_cloud {
297 None
298 } else {
299 Some(n.relative_path)
300 },
301 }
302 }
303}
304
305pub async fn note_neighbors(
306 State(state): State<Arc<AppState>>,
307 ctx: AccountContext,
308 Path(id): Path<i64>,
309 Query(params): Query<NoteNeighborsQuery>,
310) -> Result<Json<NoteNeighborsResponse>, ApiError> {
311 let max = params
312 .max
313 .unwrap_or(graph_expansion::DEFAULT_MAX_NEIGHBORS)
314 .min(MAX_LIMIT);
315 let is_cloud = matches!(
316 state.deployment_mode,
317 tuitbot_core::config::DeploymentMode::Cloud
318 );
319
320 let node = watchtower::get_content_node_for(&state.db, &ctx.account_id, id).await?;
322 if node.is_none() {
323 return Ok(Json(NoteNeighborsResponse {
324 node_id: id,
325 neighbors: Vec::new(),
326 total_edges: 0,
327 graph_state: GraphState::NodeNotIndexed,
328 }));
329 }
330
331 let result =
333 crate::routes::rag_helpers::resolve_graph_suggestions(&state, &ctx.account_id, id, max)
334 .await;
335
336 let total_edges: u32 = result.neighbors.iter().map(|n| n.edge_count).sum();
337 let items: Vec<NeighborItem> = result
338 .neighbors
339 .into_iter()
340 .map(|n| NeighborItem::from_graph_neighbor(n, is_cloud))
341 .collect();
342
343 Ok(Json(NoteNeighborsResponse {
344 node_id: id,
345 neighbors: items,
346 total_edges,
347 graph_state: result.graph_state,
348 }))
349}
350
351#[derive(Deserialize)]
356pub struct SearchFragmentsQuery {
357 pub q: String,
358 pub limit: Option<u32>,
359}
360
361#[derive(Serialize)]
362pub struct SearchFragmentsResponse {
363 pub fragments: Vec<VaultCitation>,
364}
365
366pub async fn search_fragments(
367 State(state): State<Arc<AppState>>,
368 ctx: AccountContext,
369 Query(params): Query<SearchFragmentsQuery>,
370) -> Result<Json<SearchFragmentsResponse>, ApiError> {
371 let limit = clamp_limit(params.limit);
372
373 if params.q.is_empty() {
374 return Ok(Json(SearchFragmentsResponse { fragments: vec![] }));
375 }
376
377 let keywords: Vec<String> = params.q.split_whitespace().map(|s| s.to_string()).collect();
378
379 let fragments =
380 retrieval::retrieve_vault_fragments(&state.db, &ctx.account_id, &keywords, None, limit)
381 .await?;
382
383 let citations = retrieval::build_citations(&fragments);
384
385 Ok(Json(SearchFragmentsResponse {
386 fragments: citations,
387 }))
388}
389
390#[derive(Deserialize)]
395pub struct ResolveRefsRequest {
396 pub node_ids: Vec<i64>,
397}
398
399#[derive(Serialize)]
400pub struct ResolveRefsResponse {
401 pub citations: Vec<VaultCitation>,
402}
403
404pub async fn resolve_refs(
405 State(state): State<Arc<AppState>>,
406 ctx: AccountContext,
407 Json(body): Json<ResolveRefsRequest>,
408) -> Result<Json<ResolveRefsResponse>, ApiError> {
409 if body.node_ids.is_empty() {
410 return Ok(Json(ResolveRefsResponse { citations: vec![] }));
411 }
412
413 let fragments = retrieval::retrieve_vault_fragments(
414 &state.db,
415 &ctx.account_id,
416 &[],
417 Some(&body.node_ids),
418 MAX_LIMIT,
419 )
420 .await?;
421
422 let citations = retrieval::build_citations(&fragments);
423
424 Ok(Json(ResolveRefsResponse { citations }))
425}
426
427#[cfg(test)]
432mod tests {
433 use super::*;
434
435 use std::collections::HashMap;
436 use std::path::PathBuf;
437
438 use axum::body::Body;
439 use axum::http::{Request, StatusCode};
440 use axum::routing::{get, post};
441 use axum::Router;
442 use tokio::sync::{broadcast, Mutex, RwLock};
443 use tower::ServiceExt;
444
445 use crate::ws::AccountWsEvent;
446
447 async fn test_state() -> Arc<AppState> {
448 let db = tuitbot_core::storage::init_test_db()
449 .await
450 .expect("init test db");
451 let (event_tx, _) = broadcast::channel::<AccountWsEvent>(16);
452 Arc::new(AppState {
453 db,
454 config_path: PathBuf::from("/tmp/test-config.toml"),
455 data_dir: PathBuf::from("/tmp"),
456 event_tx,
457 api_token: "test-token".to_string(),
458 passphrase_hash: RwLock::new(None),
459 passphrase_hash_mtime: RwLock::new(None),
460 bind_host: "127.0.0.1".to_string(),
461 bind_port: 3001,
462 login_attempts: Mutex::new(HashMap::new()),
463 runtimes: Mutex::new(HashMap::new()),
464 content_generators: Mutex::new(HashMap::new()),
465 circuit_breaker: None,
466 scraper_health: None,
467 watchtower_cancel: RwLock::new(None),
468 content_sources: RwLock::new(Default::default()),
469 connector_config: Default::default(),
470 deployment_mode: Default::default(),
471 pending_oauth: Mutex::new(HashMap::new()),
472 token_managers: Mutex::new(HashMap::new()),
473 x_client_id: String::new(),
474 semantic_index: None,
475 embedding_provider: None,
476 })
477 }
478
479 fn test_router(state: Arc<AppState>) -> Router {
480 Router::new()
481 .route("/vault/sources", get(vault_sources))
482 .route("/vault/notes", get(search_notes))
483 .route("/vault/notes/{id}/neighbors", get(note_neighbors))
484 .route("/vault/notes/{id}", get(note_detail))
485 .route("/vault/search", get(search_fragments))
486 .route("/vault/resolve-refs", post(resolve_refs))
487 .with_state(state)
488 }
489
490 #[tokio::test]
491 async fn vault_sources_returns_empty_when_no_sources() {
492 let state = test_state().await;
493 let app = test_router(state);
494
495 let resp = app
496 .oneshot(
497 Request::builder()
498 .uri("/vault/sources")
499 .body(Body::empty())
500 .unwrap(),
501 )
502 .await
503 .unwrap();
504
505 assert_eq!(resp.status(), StatusCode::OK);
506 let body: serde_json::Value = serde_json::from_slice(
507 &axum::body::to_bytes(resp.into_body(), 1024 * 64)
508 .await
509 .unwrap(),
510 )
511 .unwrap();
512 assert_eq!(body["sources"].as_array().unwrap().len(), 0);
513 }
514
515 #[tokio::test]
516 async fn search_notes_returns_empty_for_no_matches() {
517 let state = test_state().await;
518 let app = test_router(state);
519
520 let resp = app
521 .oneshot(
522 Request::builder()
523 .uri("/vault/notes?q=nonexistent")
524 .body(Body::empty())
525 .unwrap(),
526 )
527 .await
528 .unwrap();
529
530 assert_eq!(resp.status(), StatusCode::OK);
531 let body: serde_json::Value = serde_json::from_slice(
532 &axum::body::to_bytes(resp.into_body(), 1024 * 64)
533 .await
534 .unwrap(),
535 )
536 .unwrap();
537 assert_eq!(body["notes"].as_array().unwrap().len(), 0);
538 }
539
540 #[tokio::test]
541 async fn note_detail_returns_404_for_missing_node() {
542 let state = test_state().await;
543 let app = test_router(state);
544
545 let resp = app
546 .oneshot(
547 Request::builder()
548 .uri("/vault/notes/999")
549 .body(Body::empty())
550 .unwrap(),
551 )
552 .await
553 .unwrap();
554
555 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
556 }
557
558 #[tokio::test]
559 async fn search_fragments_returns_empty_for_no_chunks() {
560 let state = test_state().await;
561 let app = test_router(state);
562
563 let resp = app
564 .oneshot(
565 Request::builder()
566 .uri("/vault/search?q=nonexistent")
567 .body(Body::empty())
568 .unwrap(),
569 )
570 .await
571 .unwrap();
572
573 assert_eq!(resp.status(), StatusCode::OK);
574 let body: serde_json::Value = serde_json::from_slice(
575 &axum::body::to_bytes(resp.into_body(), 1024 * 64)
576 .await
577 .unwrap(),
578 )
579 .unwrap();
580 assert_eq!(body["fragments"].as_array().unwrap().len(), 0);
581 }
582
583 #[test]
586 fn clamp_limit_default() {
587 assert_eq!(clamp_limit(None), DEFAULT_LIMIT);
588 }
589
590 #[test]
591 fn clamp_limit_under_max() {
592 assert_eq!(clamp_limit(Some(50)), 50);
593 }
594
595 #[test]
596 fn clamp_limit_at_max() {
597 assert_eq!(clamp_limit(Some(MAX_LIMIT)), MAX_LIMIT);
598 }
599
600 #[test]
601 fn clamp_limit_over_max() {
602 assert_eq!(clamp_limit(Some(500)), MAX_LIMIT);
603 }
604
605 #[test]
608 fn truncate_snippet_short_text() {
609 assert_eq!(truncate_snippet("hello", 120), "hello");
610 }
611
612 #[test]
613 fn truncate_snippet_at_limit() {
614 let text = "a".repeat(120);
615 assert_eq!(truncate_snippet(&text, 120), text);
616 }
617
618 #[test]
619 fn truncate_snippet_over_limit() {
620 let text = "a".repeat(200);
621 let result = truncate_snippet(&text, 120);
622 assert!(result.ends_with("..."));
623 assert!(result.len() <= 120);
624 }
625
626 #[test]
627 fn truncate_snippet_unicode_safe() {
628 let text = "a".repeat(115) + "\u{1F600}\u{1F600}\u{1F600}";
630 let result = truncate_snippet(&text, 120);
631 assert!(result.ends_with("..."));
632 }
634
635 #[test]
638 fn search_notes_query_defaults() {
639 let json = "{}";
640 let q: SearchNotesQuery = serde_json::from_str(json).expect("deser");
641 assert!(q.q.is_none());
642 assert!(q.source_id.is_none());
643 assert!(q.limit.is_none());
644 }
645
646 #[test]
647 fn search_fragments_query_deserializes() {
648 let json = r#"{"q":"rust","limit":10}"#;
649 let q: SearchFragmentsQuery = serde_json::from_str(json).expect("deser");
650 assert_eq!(q.q, "rust");
651 assert_eq!(q.limit, Some(10));
652 }
653
654 #[test]
655 fn resolve_refs_request_deserializes() {
656 let json = r#"{"node_ids":[1,2,3]}"#;
657 let req: ResolveRefsRequest = serde_json::from_str(json).expect("deser");
658 assert_eq!(req.node_ids.len(), 3);
659 }
660
661 #[test]
662 fn resolve_refs_request_empty_ids() {
663 let json = r#"{"node_ids":[]}"#;
664 let req: ResolveRefsRequest = serde_json::from_str(json).expect("deser");
665 assert!(req.node_ids.is_empty());
666 }
667
668 #[tokio::test]
669 async fn resolve_refs_returns_empty_for_empty_ids() {
670 let state = test_state().await;
671 let app = test_router(state);
672
673 let resp = app
674 .oneshot(
675 Request::builder()
676 .method("POST")
677 .uri("/vault/resolve-refs")
678 .header("content-type", "application/json")
679 .body(Body::from(r#"{"node_ids":[]}"#))
680 .unwrap(),
681 )
682 .await
683 .unwrap();
684
685 assert_eq!(resp.status(), StatusCode::OK);
686 let body: serde_json::Value = serde_json::from_slice(
687 &axum::body::to_bytes(resp.into_body(), 1024 * 64)
688 .await
689 .unwrap(),
690 )
691 .unwrap();
692 assert_eq!(body["citations"].as_array().unwrap().len(), 0);
693 }
694
695 #[tokio::test]
696 async fn vault_sources_includes_privacy_envelope() {
697 let state = test_state().await;
698 let app = test_router(state);
699
700 let resp = app
701 .oneshot(
702 Request::builder()
703 .uri("/vault/sources")
704 .body(Body::empty())
705 .unwrap(),
706 )
707 .await
708 .unwrap();
709
710 assert_eq!(resp.status(), StatusCode::OK);
711 let body: serde_json::Value = serde_json::from_slice(
712 &axum::body::to_bytes(resp.into_body(), 1024 * 64)
713 .await
714 .unwrap(),
715 )
716 .unwrap();
717 assert_eq!(body["deployment_mode"], "desktop");
719 assert_eq!(body["privacy_envelope"], "local_first");
720 }
721
722 async fn test_state_with_mode(mode: tuitbot_core::config::DeploymentMode) -> Arc<AppState> {
723 let db = tuitbot_core::storage::init_test_db()
724 .await
725 .expect("init test db");
726 let (event_tx, _) = broadcast::channel::<AccountWsEvent>(16);
727 Arc::new(AppState {
728 db,
729 config_path: PathBuf::from("/tmp/test-config.toml"),
730 data_dir: PathBuf::from("/tmp"),
731 event_tx,
732 api_token: "test-token".to_string(),
733 passphrase_hash: RwLock::new(None),
734 passphrase_hash_mtime: RwLock::new(None),
735 bind_host: "127.0.0.1".to_string(),
736 bind_port: 3001,
737 login_attempts: Mutex::new(HashMap::new()),
738 runtimes: Mutex::new(HashMap::new()),
739 content_generators: Mutex::new(HashMap::new()),
740 circuit_breaker: None,
741 scraper_health: None,
742 watchtower_cancel: RwLock::new(None),
743 content_sources: RwLock::new(Default::default()),
744 connector_config: Default::default(),
745 deployment_mode: mode,
746 pending_oauth: Mutex::new(HashMap::new()),
747 token_managers: Mutex::new(HashMap::new()),
748 x_client_id: String::new(),
749 semantic_index: None,
750 embedding_provider: None,
751 })
752 }
753
754 #[tokio::test]
755 async fn vault_sources_cloud_mode_privacy_envelope() {
756 let state = test_state_with_mode(tuitbot_core::config::DeploymentMode::Cloud).await;
757 let app = test_router(state);
758
759 let resp = app
760 .oneshot(
761 Request::builder()
762 .uri("/vault/sources")
763 .body(Body::empty())
764 .unwrap(),
765 )
766 .await
767 .unwrap();
768
769 assert_eq!(resp.status(), StatusCode::OK);
770 let body: serde_json::Value = serde_json::from_slice(
771 &axum::body::to_bytes(resp.into_body(), 1024 * 64)
772 .await
773 .unwrap(),
774 )
775 .unwrap();
776 assert_eq!(body["deployment_mode"], "cloud");
777 assert_eq!(body["privacy_envelope"], "provider_controlled");
778 }
779}