use serde::{Deserialize, Serialize};
use toolkit_macros::domain_model;
use uuid::Uuid;
use crate::domain::error::ChatEngineError;
use crate::domain::message::{MessagePart, MessageRole};
pub const MAX_QUERY_LENGTH: usize = 500;
pub const DEFAULT_PAGE_SIZE: u32 = 20;
pub const MAX_PAGE_SIZE: u32 = 50;
pub const DEFAULT_CONTEXT_RADIUS: u32 = 1;
#[domain_model]
#[derive(Clone, Debug, Default, Deserialize)]
pub struct SearchQuery {
pub q: Option<String>,
#[serde(rename = "$top", default)]
pub top: Option<u32>,
#[serde(rename = "$skip", default)]
pub skip: Option<u32>,
pub cursor: Option<String>,
#[serde(rename = "context")]
pub context_radius: Option<u32>,
}
impl SearchQuery {
#[must_use]
pub fn effective_top(&self) -> u32 {
match self.top {
None => DEFAULT_PAGE_SIZE,
Some(0) => DEFAULT_PAGE_SIZE,
Some(n) => n.min(MAX_PAGE_SIZE),
}
}
#[must_use]
pub fn effective_skip(&self) -> u32 {
self.skip.unwrap_or(0)
}
#[must_use]
pub fn effective_context_radius(&self) -> u32 {
self.context_radius.unwrap_or(DEFAULT_CONTEXT_RADIUS).min(5)
}
}
#[domain_model]
#[derive(Debug, Clone, Serialize)]
pub struct SearchResult {
pub message_id: Uuid,
pub session_id: Uuid,
pub content_snippet: String,
pub rank: f32,
pub context_messages: Vec<MessageRef>,
pub parent_chain: Vec<MessageRef>,
#[serde(skip_serializing_if = "Option::is_none")]
pub session_metadata: Option<SessionMeta>,
}
#[domain_model]
#[derive(Debug, Clone, Serialize)]
pub struct MessageRef {
pub message_id: Uuid,
pub role: MessageRole,
pub parts: Vec<MessagePart>,
#[serde(with = "time::serde::rfc3339")]
pub created_at: time::OffsetDateTime,
}
#[domain_model]
#[derive(Debug, Clone, Serialize)]
pub struct SessionMeta {
pub session_id: Uuid,
pub title: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tags: Vec<String>,
}
#[domain_model]
#[derive(Debug, Clone, Serialize)]
pub struct SearchPage {
pub items: Vec<SearchResult>,
pub total_count: u64,
pub next_cursor: Option<String>,
pub per_page: u32,
}
#[domain_model]
#[derive(Debug, thiserror::Error)]
pub enum SearchError {
#[error("query required")]
QueryRequired,
#[error("query too long")]
QueryTooLong,
#[error("session not found")]
SessionNotFound,
#[error("forbidden")]
Forbidden,
#[error("backend error: {0}")]
Backend(#[source] Box<ChatEngineError>),
}
impl From<SearchError> for ChatEngineError {
fn from(err: SearchError) -> Self {
match err {
SearchError::QueryRequired => ChatEngineError::bad_request("query required"),
SearchError::QueryTooLong => ChatEngineError::bad_request("query too long"),
SearchError::SessionNotFound => ChatEngineError::not_found("session", "<scoped>"),
SearchError::Forbidden => {
ChatEngineError::forbidden("authenticated identity required to perform search")
}
SearchError::Backend(err) => ChatEngineError::Internal {
reason: "search backend error".to_owned(),
source: Some(err),
},
}
}
}
impl From<ChatEngineError> for SearchError {
fn from(err: ChatEngineError) -> Self {
match err {
ChatEngineError::NotFound { .. } => SearchError::SessionNotFound,
ChatEngineError::Forbidden { .. } => SearchError::Forbidden,
ChatEngineError::BadRequest { reason } => {
if reason.contains("too long") {
SearchError::QueryTooLong
} else {
SearchError::QueryRequired
}
}
other => SearchError::Backend(Box::new(other)),
}
}
}
#[domain_model]
#[derive(Debug, Clone, PartialEq)]
pub struct Cursor {
pub rank: f32,
pub message_id: Uuid,
pub created_at: Option<time::OffsetDateTime>,
}
impl Cursor {
#[must_use]
pub fn new(rank: f32, message_id: Uuid, created_at: time::OffsetDateTime) -> Self {
Self {
rank,
message_id,
created_at: Some(created_at),
}
}
#[must_use]
pub fn encode(&self) -> String {
match self.created_at {
Some(ts) => format!(
"r:{}:m:{}:t:{}",
self.rank,
self.message_id,
ts.unix_timestamp_nanos(),
),
None => format!("r:{}:m:{}", self.rank, self.message_id),
}
}
pub fn decode(raw: &str) -> Result<Self, SearchError> {
let mut parts = raw.split(':');
let r_tag = parts.next().ok_or(SearchError::QueryRequired)?;
let rank_str = parts.next().ok_or(SearchError::QueryRequired)?;
let m_tag = parts.next().ok_or(SearchError::QueryRequired)?;
let id_str = parts.next().ok_or(SearchError::QueryRequired)?;
if r_tag != "r" || m_tag != "m" {
return Err(SearchError::QueryRequired);
}
let rank: f32 = rank_str.parse().map_err(|_| SearchError::QueryRequired)?;
let message_id = Uuid::parse_str(id_str).map_err(|_| SearchError::QueryRequired)?;
let created_at = match (parts.next(), parts.next()) {
(Some("t"), Some(ts_str)) => {
let nanos: i128 = ts_str.parse().map_err(|_| SearchError::QueryRequired)?;
Some(
time::OffsetDateTime::from_unix_timestamp_nanos(nanos)
.map_err(|_| SearchError::QueryRequired)?,
)
}
(None, None) => None,
_ => return Err(SearchError::QueryRequired),
};
if parts.next().is_some() {
return Err(SearchError::QueryRequired);
}
Ok(Self {
rank,
message_id,
created_at,
})
}
}
#[must_use]
pub fn sanitize_for_tsquery(raw: &str) -> String {
let mut out = String::with_capacity(raw.len());
let mut prev_space = false;
for ch in raw.chars() {
match ch {
'&' | '|' | '!' | '(' | ')' | ':' | '<' | '>' | '\'' | '"' | '\\' => {
}
c if c.is_whitespace() => {
if !prev_space && !out.is_empty() {
out.push(' ');
prev_space = true;
}
}
c => {
out.push(c);
prev_space = false;
}
}
}
let trimmed = out.trim_end();
trimmed.to_string()
}
#[must_use]
pub fn escape_like_pattern(raw: &str) -> String {
let mut out = String::with_capacity(raw.len() + 4);
for ch in raw.chars() {
match ch {
'\\' => out.push_str("\\\\"),
'%' => out.push_str("\\%"),
'_' => out.push_str("\\_"),
c => out.push(c),
}
}
out
}
#[must_use]
pub fn make_snippet(content_text: &str, needle: &str) -> String {
const RADIUS: usize = 60;
const MAX: usize = 120;
let haystack_lower = content_text.to_lowercase();
let needle_lower = needle.to_lowercase();
let start = haystack_lower.find(&needle_lower);
let body: String = match start {
Some(idx) => {
let begin = idx.saturating_sub(RADIUS);
let end = (idx + needle.len() + RADIUS).min(content_text.len());
let begin = floor_char_boundary(content_text, begin);
let end = ceil_char_boundary(content_text, end);
let mut s = String::new();
if begin > 0 {
s.push('\u{2026}');
}
s.push_str(&content_text[begin..end]);
if end < content_text.len() {
s.push('\u{2026}');
}
s
}
None => {
let take = MAX.min(content_text.len());
let end = ceil_char_boundary(content_text, take);
let mut s = content_text[..end].to_string();
if end < content_text.len() {
s.push('\u{2026}');
}
s
}
};
body
}
fn floor_char_boundary(s: &str, mut idx: usize) -> usize {
while idx > 0 && !s.is_char_boundary(idx) {
idx -= 1;
}
idx
}
fn ceil_char_boundary(s: &str, mut idx: usize) -> usize {
while idx < s.len() && !s.is_char_boundary(idx) {
idx += 1;
}
idx
}
#[must_use]
pub fn extract_searchable_text(content: &serde_json::Value) -> String {
match content {
serde_json::Value::String(s) => s.clone(),
serde_json::Value::Object(map) => {
if let Some(serde_json::Value::String(s)) = map.get("text") {
return s.clone();
}
let mut buf = String::new();
for v in map.values() {
push_text(v, &mut buf);
}
buf
}
serde_json::Value::Array(arr) => {
let mut buf = String::new();
for v in arr {
push_text(v, &mut buf);
}
buf
}
_ => String::new(),
}
}
fn push_text(v: &serde_json::Value, buf: &mut String) {
match v {
serde_json::Value::String(s) => {
if !buf.is_empty() {
buf.push(' ');
}
buf.push_str(s);
}
serde_json::Value::Object(map) => {
if let Some(serde_json::Value::String(s)) = map.get("text") {
if !buf.is_empty() {
buf.push(' ');
}
buf.push_str(s);
}
}
_ => {}
}
}
#[cfg(test)]
#[path = "search_tests.rs"]
mod search_tests;