use crate::Workspace;
use crate::tools::Tool;
use anyhow::Result;
use async_trait::async_trait;
use serde_json::json;
use std::fmt::Write;
use tracing::debug;
pub struct SearchArchivedTicketsTool;
#[async_trait]
impl Tool for SearchArchivedTicketsTool {
fn name(&self) -> &'static str {
"search_archived_tickets"
}
fn parameters_schema(&self) -> serde_json::Value {
super::tool_params_schema(
&json!({
"query": {
"type": "string",
"description": "Search query — matches ticket titles via keyword + semantic search"
}
}),
&["query"],
)
}
fn side_effects(&self, _args: &serde_json::Value) -> bool {
false
}
async fn execute(&self, _ws: &Workspace, args: serde_json::Value) -> Result<String> {
let query = super::get_str(&args, "query")?;
let board = crate::board::store();
let (fts_results, vector_results) = tokio::join!(
Self::fts_search(board, query),
Self::vector_search(board, query),
);
let fts_results = fts_results?;
let vector_results = vector_results?;
let merged = crate::vector::hybrid_merge(&vector_results, &fts_results);
let top_ids: Vec<String> = merged.into_iter().take(10).map(|r| r.id).collect();
if top_ids.is_empty() {
return Ok("No archived tickets found matching the query.".to_string());
}
Self::format_results(board, &top_ids).await
}
}
impl SearchArchivedTicketsTool {
async fn fts_search(
board: &crate::board::BoardStore,
query: &str,
) -> Result<Vec<(String, f32)>> {
let results = board.search_archived_by_fts(query, 50).await?;
Ok(results
.into_iter()
.map(|(id, score)| {
#[allow(clippy::cast_possible_truncation)]
(id, score as f32)
})
.collect())
}
async fn vector_search(
board: &crate::board::BoardStore,
query: &str,
) -> Result<Vec<(String, f32)>> {
let Some(q_emb) = crate::embedder::embed(query, true) else {
debug!("Embedding model failed — skipping vector search");
return Ok(Vec::new());
};
let candidates = board.list_archived_with_embeddings().await?;
if candidates.is_empty() {
debug!("All archived tickets have NULL embeddings — vector search skipped");
return Ok(Vec::new());
}
let mut scored: Vec<(String, f32)> = candidates
.into_iter()
.map(|(id, emb)| {
let sim = crate::vector::cosine_similarity(&q_emb, &emb);
(id, sim)
})
.collect();
scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
scored.truncate(50);
Ok(scored)
}
async fn format_results(
board: &crate::board::BoardStore,
top_ids: &[String],
) -> Result<String> {
let rows = board.list_tickets_minimal(top_ids).await?;
let mut output = String::from("Search results (top 10, highest score first):\n");
for id in top_ids {
if let Some((_, title, status)) = rows.iter().find(|(rid, _, _)| rid == id) {
let _ = writeln!(output, " [{status}] {id}: {title}");
}
}
Ok(output)
}
}
#[cfg(test)]
mod tests {
use crate::board::open_test_store;
#[tokio::test]
async fn test_fts_search_returns_floats() {
let (store, _tmp) = open_test_store().await;
let ws = crate::workspace::test_ws("ws");
let id = store
.create_ticket(
"UniqueSearchableTitleXYZ",
"desc",
&ws,
crate::board::TicketPhase::Done,
&[],
"test",
None,
)
.await
.expect("create");
store.set_archived(&id).await.expect("archive");
let results = super::SearchArchivedTicketsTool::fts_search(&store, "UniqueSearchable")
.await
.expect("fts_search");
assert_eq!(results.len(), 1);
assert_eq!(results[0].0, id);
assert!(results[0].1.is_finite(), "score should be a finite f32");
}
#[tokio::test]
async fn test_format_results_formats_correctly() {
let (store, _tmp) = open_test_store().await;
let ws = crate::workspace::test_ws("ws");
let id_a = store
.create_ticket(
"Alpha ticket",
"desc",
&ws,
crate::board::TicketPhase::Done,
&[],
"test",
None,
)
.await
.expect("create");
store.set_archived(&id_a).await.expect("archive");
let id_b = store
.create_ticket(
"Beta ticket",
"desc",
&ws,
crate::board::TicketPhase::Cancelled,
&[],
"test",
None,
)
.await
.expect("create");
store.set_archived(&id_b).await.expect("archive");
let ids = vec![id_a.clone(), id_b.clone()];
let result = super::SearchArchivedTicketsTool::format_results(&store, &ids)
.await
.expect("format_results");
let expected_header = "Search results (top 10, highest score first):\n";
assert!(result.starts_with(expected_header));
assert!(
result.contains(&format!("[done] {id_a}: Alpha ticket")),
"should contain first ticket line"
);
assert!(
result.contains(&format!("[cancelled] {id_b}: Beta ticket")),
"should contain second ticket line"
);
}
}