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