use std::collections::{HashMap, HashSet}; use event_log::DomainEventRecord; use regex::RegexBuilder; use serde::{Deserialize, Serialize}; use serde_json::Value; #[derive(Debug, Clone, PartialEq, Eq)] pub enum IndexedEntityKind { PageTitle, PageSummary, BlockContent, BlockPath, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct IndexedDocument { pub workspace_id: String, pub entity_kind: IndexedEntityKind, pub entity_id: String, pub parent_id: Option, pub title: Option, pub content: String, pub updated_at: String, pub source_event_id: Option, pub revision: Option, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct IndexCursor { pub workspace_id: String, pub last_processed_event_id: String, pub last_processed_at: String, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct SearchHit { pub workspace_id: String, pub entity_kind: IndexedEntityKind, pub entity_id: String, pub snippet: Option, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct SearchResultSet { pub query: String, pub workspace_id: Option, pub page_id: Option, pub hits: Vec, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct ProjectedDocumentBatch { pub workspace_id: String, pub source_event_id: String, pub documents: Vec, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct ProjectionResult { pub cursor: IndexCursor, pub batches: Vec, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SearchDocumentRecord { pub id: String, pub workspace_id: String, pub title: Option, pub raw_text: Option, pub created_at: Option, pub updated_at: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SearchMindmapRecord { pub document_id: String, pub data: Value, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SearchTableRecord { pub id: String, pub document_id: String, pub title: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SearchTableRowRecord { pub table_id: String, pub document_id: String, pub row_hash: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SearchAssetRecord { pub id: String, pub document_id: String, pub asset_type: Option, pub file_name: Option, pub mime_type: Option, pub ocr_text: Option, pub ocr_status: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SearchDocumentsDataset { pub documents: Vec, pub mindmaps: Vec, pub tables: Vec, pub table_rows: Vec, pub assets: Vec, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SearchDocumentsRequest { pub query: String, pub workspace_id: String, pub page_id: Option, pub limit: usize, pub title_only: bool, pub exact: bool, pub include_ocr: bool, pub time_range: String, pub time_field: String, pub custom_range_from: Option, pub custom_range_to: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum SearchMatchField { Title, Content, Recent, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SearchEvidenceRecord { pub kind: String, pub node_id: Option, pub snippet: String, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct RankedSearchDocument { pub id: String, pub title: String, pub snippet: String, pub updated_at: Option, pub created_at: Option, pub match_field: SearchMatchField, pub has_ocr: bool, pub public_path: String, pub score: f64, pub node_id: Option, pub subtree_root_id: Option, pub evidence: Vec, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SearchDocumentsEvaluation { pub enqueue_asset_ids: Vec, pub results: Vec, } pub trait DomainEventProjector { fn project(&self, event: &DomainEventRecord) -> Vec; } pub fn supported_index_objects() -> Vec { vec![ IndexedEntityKind::PageTitle, IndexedEntityKind::PageSummary, IndexedEntityKind::BlockContent, IndexedEntityKind::BlockPath, ] } pub fn can_rebuild_from_events(cursor: &IndexCursor) -> bool { !cursor.workspace_id.trim().is_empty() && !cursor.last_processed_event_id.trim().is_empty() } pub fn advance_workspace_cursor( cursor: &IndexCursor, event_id: impl Into, processed_at: impl Into, ) -> IndexCursor { IndexCursor { workspace_id: cursor.workspace_id.clone(), last_processed_event_id: event_id.into(), last_processed_at: processed_at.into(), } } pub fn project_domain_event( projector: &P, event: &DomainEventRecord, cursor: &IndexCursor, ) -> ProjectionResult { let documents = projector.project(event); ProjectionResult { cursor: advance_workspace_cursor( cursor, event.event_id.clone(), event.created_at.as_str().to_string(), ), batches: if documents.is_empty() { Vec::new() } else { vec![ProjectedDocumentBatch { workspace_id: event.workspace_id.clone(), source_event_id: event.event_id.clone(), documents, }] }, } } pub fn rebuild_from_events( projector: &P, events: &[DomainEventRecord], cursor: &IndexCursor, ) -> ProjectionResult { let mut next_cursor = cursor.clone(); let mut batches = Vec::new(); for event in events { if event.workspace_id != cursor.workspace_id { continue; } let projected = projector.project(event); next_cursor = advance_workspace_cursor( &next_cursor, event.event_id.clone(), event.created_at.as_str().to_string(), ); if !projected.is_empty() { batches.push(ProjectedDocumentBatch { workspace_id: event.workspace_id.clone(), source_event_id: event.event_id.clone(), documents: projected, }); } } ProjectionResult { cursor: next_cursor, batches, } } pub fn search_pages( query: &str, workspace_id: Option<&str>, documents: &[IndexedDocument], limit: usize, ) -> SearchResultSet { let query = query.trim().to_lowercase(); let hits = documents .iter() .filter(|document| { matches!( document.entity_kind, IndexedEntityKind::PageTitle | IndexedEntityKind::PageSummary ) }) .filter(|document| { workspace_id.map_or(true, |workspace| document.workspace_id == workspace) }) .filter(|document| { document .title .as_deref() .unwrap_or("") .to_lowercase() .contains(&query) || document.content.to_lowercase().contains(&query) }) .take(limit) .map(|document| SearchHit { workspace_id: document.workspace_id.clone(), entity_kind: document.entity_kind.clone(), entity_id: document.entity_id.clone(), snippet: Some(document.content.chars().take(120).collect()), }) .collect(); SearchResultSet { query: query.into(), workspace_id: workspace_id.map(|value| value.into()), page_id: None, hits, } } pub fn search_blocks( query: &str, page_id: Option<&str>, documents: &[IndexedDocument], limit: usize, ) -> SearchResultSet { let query = query.trim().to_lowercase(); let hits = documents .iter() .filter(|document| { matches!( document.entity_kind, IndexedEntityKind::BlockContent | IndexedEntityKind::BlockPath ) }) .filter(|document| { page_id.map_or(true, |page| { document.parent_id.as_deref() == Some(page) || document.entity_id == page }) }) .filter(|document| document.content.to_lowercase().contains(&query)) .take(limit) .map(|document| SearchHit { workspace_id: document.workspace_id.clone(), entity_kind: document.entity_kind.clone(), entity_id: document.entity_id.clone(), snippet: Some(document.content.chars().take(120).collect()), }) .collect(); SearchResultSet { query: query.into(), workspace_id: None, page_id: page_id.map(|value| value.into()), hits, } } pub fn evaluate_search_documents( request: &SearchDocumentsRequest, dataset: &SearchDocumentsDataset, ) -> SearchDocumentsEvaluation { let normalized_query = request.query.trim(); if normalized_query.is_empty() { return SearchDocumentsEvaluation { enqueue_asset_ids: Vec::new(), results: Vec::new(), }; } let normalized_lower = normalized_query.to_lowercase(); let boundary_iso = match request.time_range.as_str() { "7d" => iso_days_ago(7), "30d" => iso_days_ago(30), _ => None, }; let eligible_docs: Vec<&SearchDocumentRecord> = dataset .documents .iter() .filter(|document| document.workspace_id == request.workspace_id) .filter(|document| { if let Some(page_id) = request.page_id.as_ref() { document.id == *page_id } else { true } }) .filter(|document| match request.time_field.as_str() { "created" => within_range( document.created_at.as_deref(), boundary_iso.as_deref(), request.custom_range_from.as_deref(), request.custom_range_to.as_deref(), ), _ => within_range( document .updated_at .as_deref() .or(document.created_at.as_deref()), boundary_iso.as_deref(), request.custom_range_from.as_deref(), request.custom_range_to.as_deref(), ), }) .collect(); let eligible_doc_ids: HashSet<&str> = eligible_docs .iter() .map(|document| document.id.as_str()) .collect(); let doc_map: HashMap<&str, &SearchDocumentRecord> = eligible_docs .iter() .map(|document| (document.id.as_str(), *document)) .collect(); let mut matches = HashMap::::new(); for document in &eligible_docs { let title = normalize_title(document.title.as_deref()); let title_lower = title.to_lowercase(); let hit_title = if request.exact { title_lower == normalized_lower } else { title_lower.contains(&normalized_lower) }; if hit_title { upsert_match( &mut matches, &document.id, SearchMatchInfo { score: 3.0, match_field: SearchMatchField::Title, snippet: build_snippet(&title, normalized_query), has_ocr: false, }, ); } if request.title_only { continue; } let raw_text = document.raw_text.as_deref().unwrap_or("").trim(); if !raw_text.is_empty() && raw_text.to_lowercase().contains(&normalized_lower) { upsert_match( &mut matches, &document.id, SearchMatchInfo { score: 2.0, match_field: SearchMatchField::Content, snippet: build_snippet(raw_text, normalized_query), has_ocr: false, }, ); } } let mut table_title_by_id = HashMap::::new(); if !request.title_only { for mindmap in &dataset.mindmaps { if !eligible_doc_ids.contains(mindmap.document_id.as_str()) { continue; } let text = extract_text_from_mindmap_data(&mindmap.data, 60_000); if text.is_empty() || !text.to_lowercase().contains(&normalized_lower) { continue; } upsert_match( &mut matches, &mindmap.document_id, SearchMatchInfo { score: 1.6, match_field: SearchMatchField::Content, snippet: build_snippet(&format!("思维导图:{text}"), normalized_query), has_ocr: false, }, ); } for table in &dataset.tables { if !eligible_doc_ids.contains(table.document_id.as_str()) { continue; } let title = normalize_title(table.title.as_deref()); table_title_by_id.insert(table.id.clone(), title.clone()); if title.to_lowercase().contains(&normalized_lower) { upsert_match( &mut matches, &table.document_id, SearchMatchInfo { score: 1.5, match_field: SearchMatchField::Content, snippet: build_snippet(&format!("表格:{title}"), normalized_query), has_ocr: false, }, ); } } for row in &dataset.table_rows { if !eligible_doc_ids.contains(row.document_id.as_str()) { continue; } let row_hash = row.row_hash.as_deref().unwrap_or("").trim(); if row_hash.is_empty() || !row_hash.to_lowercase().contains(&normalized_lower) { continue; } let table_title = table_title_by_id .get(&row.table_id) .cloned() .unwrap_or_else(|| "未命名表格".into()); upsert_match( &mut matches, &row.document_id, SearchMatchInfo { score: 1.4, match_field: SearchMatchField::Content, snippet: build_snippet( &format!("表格:{table_title}\n{row_hash}"), normalized_query, ), has_ocr: false, }, ); } } let mut enqueue_asset_ids = Vec::new(); for asset in &dataset.assets { if !eligible_doc_ids.contains(asset.document_id.as_str()) { continue; } let file_name = asset.file_name.as_deref().unwrap_or("").trim(); if !request.title_only && !file_name.is_empty() && file_name.to_lowercase().contains(&normalized_lower) { upsert_match( &mut matches, &asset.document_id, SearchMatchInfo { score: 1.2, match_field: SearchMatchField::Content, snippet: build_snippet(&format!("附件:{file_name}"), normalized_query), has_ocr: false, }, ); } if !request.include_ocr { continue; } let ocr_text = asset.ocr_text.as_deref().unwrap_or("").trim(); if ocr_text.is_empty() { let ocr_status = asset.ocr_status.as_deref().unwrap_or("").trim(); let busy = ocr_status == "queued" || ocr_status == "running"; if !busy && should_extract_attachment_text( asset.asset_type.as_deref(), asset.mime_type.as_deref(), asset.file_name.as_deref(), ) { enqueue_asset_ids.push(asset.id.clone()); } continue; } if ocr_text.to_lowercase().contains(&normalized_lower) { upsert_match( &mut matches, &asset.document_id, SearchMatchInfo { score: 1.7, match_field: SearchMatchField::Content, snippet: build_snippet( &format!( "附件:{}\n{ocr_text}", if file_name.is_empty() { asset.id.as_str() } else { file_name } ), normalized_query, ), has_ocr: true, }, ); } } let mut results = matches .into_iter() .filter_map(|(document_id, matched)| { let document = doc_map.get(document_id.as_str())?; let snippet = matched.snippet.clone(); let evidence_kind = if matched.has_ocr { "ocr".to_string() } else { match matched.match_field { SearchMatchField::Title => "title".to_string(), SearchMatchField::Content => "content".to_string(), SearchMatchField::Recent => "recent".to_string(), } }; Some(RankedSearchDocument { id: document.id.clone(), title: normalize_title(document.title.as_deref()), snippet: snippet.clone(), updated_at: document.updated_at.clone(), created_at: document.created_at.clone(), match_field: matched.match_field, has_ocr: matched.has_ocr, public_path: format!("/documents/{}", document.id), score: matched.score, node_id: Some(document.id.clone()), subtree_root_id: Some(document.id.clone()), evidence: vec![SearchEvidenceRecord { kind: evidence_kind, node_id: Some(document.id.clone()), snippet, }], }) }) .collect::>(); results.sort_by(|left, right| { right .score .partial_cmp(&left.score) .unwrap_or(std::cmp::Ordering::Equal) .then_with(|| { let left_time = left .updated_at .as_deref() .or(left.created_at.as_deref()) .unwrap_or(""); let right_time = right .updated_at .as_deref() .or(right.created_at.as_deref()) .unwrap_or(""); right_time.cmp(left_time) }) .then_with(|| left.title.cmp(&right.title)) }); results.truncate(request.limit); enqueue_asset_ids.sort(); enqueue_asset_ids.dedup(); if enqueue_asset_ids.len() > 3 { enqueue_asset_ids.truncate(3); } SearchDocumentsEvaluation { enqueue_asset_ids, results, } } pub struct MinimalWorkspaceProjector; impl DomainEventProjector for MinimalWorkspaceProjector { fn project(&self, event: &DomainEventRecord) -> Vec { let content = event.payload_json.trim(); let (entity_kind, title, parent_id) = match event.aggregate_type.as_str() { "page" => ( IndexedEntityKind::PageTitle, Some(event.event_type.clone()), None, ), "block" => ( IndexedEntityKind::BlockContent, None, Some(event.aggregate_id.clone()), ), _ => return Vec::new(), }; vec![IndexedDocument { workspace_id: event.workspace_id.clone(), entity_kind, entity_id: event.aggregate_id.clone(), parent_id, title, content: content.to_string(), updated_at: event.created_at.as_str().to_string(), source_event_id: Some(event.event_id.clone()), revision: Some(event.event_version as u64), }] } } #[derive(Debug, Clone)] struct SearchMatchInfo { score: f64, match_field: SearchMatchField, snippet: String, has_ocr: bool, } fn normalize_title(title: Option<&str>) -> String { let normalized = title.unwrap_or("").trim(); if normalized.is_empty() { "无标题".into() } else { normalized.into() } } fn within_range( timestamp: Option<&str>, boundary_iso: Option<&str>, custom_range_from: Option<&str>, custom_range_to: Option<&str>, ) -> bool { let Some(timestamp) = timestamp else { return boundary_iso.is_none() && custom_range_from.is_none() && custom_range_to.is_none(); }; if let Some(boundary_iso) = boundary_iso { if timestamp < boundary_iso { return false; } } if let Some(custom_range_from) = custom_range_from { if timestamp < custom_range_from { return false; } } if let Some(custom_range_to) = custom_range_to { if timestamp > custom_range_to { return false; } } true } fn iso_days_ago(days: i64) -> Option { use std::time::{Duration, SystemTime}; let seconds = days.checked_mul(24 * 60 * 60)?; let duration = Duration::from_secs(seconds as u64); let cutoff = SystemTime::now().checked_sub(duration)?; let datetime = chrono_like::system_time_to_iso(cutoff)?; Some(datetime) } fn upsert_match( matches: &mut HashMap, document_id: &str, patch: SearchMatchInfo, ) { let Some(previous) = matches.get(document_id).cloned() else { matches.insert(document_id.into(), patch); return; }; let mut next = SearchMatchInfo { score: previous.score.max(patch.score), match_field: patch.match_field.clone(), snippet: patch.snippet.clone(), has_ocr: previous.has_ocr || patch.has_ocr, }; if matches!(previous.match_field, SearchMatchField::Title) && !matches!(patch.match_field, SearchMatchField::Title) { next.match_field = SearchMatchField::Title; next.snippet = previous.snippet; } else if patch.score <= previous.score { next.match_field = previous.match_field; next.snippet = previous.snippet; } matches.insert(document_id.into(), next); } fn extract_text_from_mindmap_data(value: &Value, max_chars: usize) -> String { // 维护累计字节长度,避免每次递归 O(n²) join。 fn walk(node: &Value, output: &mut Vec, total_len: &mut usize, max_chars: usize) { if *total_len >= max_chars { return; } match node { Value::Object(map) => { if let Some(Value::Object(data)) = map.get("data") { if let Some(Value::String(text)) = data.get("text") { let normalized = normalize_text(text); if !normalized.is_empty() { if !output.is_empty() { *total_len = total_len.saturating_add(1); // '\n' } *total_len = total_len.saturating_add(normalized.len()); output.push(normalized); } } } if let Some(Value::Array(children)) = map.get("children") { for child in children { walk(child, output, total_len, max_chars); if *total_len >= max_chars { break; } } } if let Some(root) = map.get("root") { walk(root, output, total_len, max_chars); } } Value::Array(items) => { for item in items { walk(item, output, total_len, max_chars); if *total_len >= max_chars { break; } } } _ => {} } } let mut output = Vec::new(); let mut total_len = 0usize; walk(value, &mut output, &mut total_len, max_chars); let joined = output.join("\n").trim().to_string(); if joined.len() > max_chars { // 按 UTF-8 字符边界截断,避免 panic let end = joined .char_indices() .map(|(i, _)| i) .take_while(|&i| i <= max_chars) .last() .unwrap_or(0); format!("{}…", &joined[..end]) } else { joined } } fn normalize_text(value: &str) -> String { value .split_whitespace() .collect::>() .join(" ") .trim() .to_string() } fn escape_html(value: &str) -> String { value .replace('&', "&") .replace('<', "<") .replace('>', ">") } fn build_snippet(text: &str, keyword: &str) -> String { let source = text.trim(); if source.is_empty() { return "暂无正文内容".into(); } if keyword.trim().is_empty() { return truncate_snippet(&escape_html(source)); } // 先在原文上匹配,再 escape 匹配片段,避免在 `&` 等实体内部插入 。 let regex = match RegexBuilder::new(®ex::escape(keyword)) .case_insensitive(true) .build() { Ok(regex) => regex, Err(_) => return truncate_snippet(&escape_html(source)), }; let Some(found) = regex.find(source) else { return truncate_snippet(&escape_html(source)); }; // 按 UTF-8 字符边界取上下文,避免多字节字符中间切片 panic。 let match_start = found.start(); let match_end = found.end(); let target_start = match_start.saturating_sub(20); let start = source .char_indices() .map(|(i, _)| i) .take_while(|&i| i <= target_start) .last() .unwrap_or(0); let target_end = (match_end + 80).min(source.len()); let end = source .char_indices() .map(|(i, _)| i) .find(|&i| i >= target_end) .unwrap_or(source.len()); let end = if end < start { source.len() } else { end }; let prefix = escape_html(&source[start..match_start]); let matched = escape_html(&source[match_start..match_end]); let suffix = escape_html(&source[match_end..end]); format!("{prefix}{matched}{suffix}") } fn truncate_snippet(value: &str) -> String { let mut chars = value.chars(); let truncated = chars.by_ref().take(120).collect::(); if chars.next().is_some() { format!("{truncated}…") } else { truncated } } fn should_extract_attachment_text( asset_type: Option<&str>, mime_type: Option<&str>, file_name: Option<&str>, ) -> bool { if asset_type.unwrap_or("").trim() != "file" { return false; } let mime_type = mime_type.unwrap_or("").trim().to_lowercase(); if matches!( mime_type.as_str(), "application/pdf" | "application/vnd.openxmlformats-officedocument.wordprocessingml.document" | "application/vnd.openxmlformats-officedocument.presentationml.presentation" | "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" ) { return true; } let file_name = file_name.unwrap_or("").trim().to_lowercase(); [".pdf", ".docx", ".pptx", ".xlsx"] .iter() .any(|suffix| file_name.ends_with(suffix)) } mod chrono_like { use std::time::{SystemTime, UNIX_EPOCH}; pub fn system_time_to_iso(time: SystemTime) -> Option { let duration = time.duration_since(UNIX_EPOCH).ok()?; let seconds = duration.as_secs() as i64; let days = seconds.div_euclid(86_400); let secs_of_day = seconds.rem_euclid(86_400); let (year, month, day) = civil_from_days(days)?; let hour = secs_of_day / 3_600; let minute = (secs_of_day % 3_600) / 60; let second = secs_of_day % 60; Some(format!( "{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z" )) } fn civil_from_days(days: i64) -> Option<(i64, i64, i64)> { let z = days.checked_add(719_468)?; let era = if z >= 0 { z } else { z - 146_096 } / 146_097; let doe = z - era * 146_097; let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365; let y = yoe + era * 400; let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); let mp = (5 * doy + 2) / 153; let d = doy - (153 * mp + 2) / 5 + 1; let m = mp + if mp < 10 { 3 } else { -9 }; let year = y + if m <= 2 { 1 } else { 0 }; Some((year, m, d)) } } #[cfg(test)] mod tests { use super::*; use core_domain::Timestamp; use event_log::EventStatus; use serde_json::json; fn cursor() -> IndexCursor { IndexCursor { workspace_id: "ws_1".into(), last_processed_event_id: "evt_0".into(), last_processed_at: "2026-04-11T00:00:00Z".into(), } } fn event( workspace_id: &str, aggregate_type: &str, aggregate_id: &str, event_id: &str, event_type: &str, ) -> DomainEventRecord { DomainEventRecord { event_id: event_id.into(), workspace_id: workspace_id.into(), aggregate_type: aggregate_type.into(), aggregate_id: aggregate_id.into(), event_type: event_type.into(), event_version: 1, payload_json: "{\"text\":\"hello\"}".into(), command_log_id: "cmd_1".into(), actor_type: "human".into(), created_at: Timestamp::new("2026-04-11T00:00:01Z"), status: EventStatus::Committed, trace_id: "trace_1".into(), request_id: "req_1".into(), command_id: "cmd_1".into(), } } #[test] fn supports_page_and_block_projection_objects() { assert_eq!( supported_index_objects(), vec![ IndexedEntityKind::PageTitle, IndexedEntityKind::PageSummary, IndexedEntityKind::BlockContent, IndexedEntityKind::BlockPath ] ); } #[test] fn cursor_advances_incrementally() { let next = advance_workspace_cursor(&cursor(), "evt_2", "2026-04-11T00:00:02Z"); assert_eq!(next.last_processed_event_id, "evt_2"); assert_eq!(next.workspace_id, "ws_1"); } #[test] fn projector_builds_real_documents_for_page_and_block() { let projector = MinimalWorkspaceProjector; let page_docs = projector.project(&event("ws_1", "page", "page_1", "evt_1", "page.created")); let block_docs = projector.project(&event("ws_1", "block", "block_1", "evt_2", "block.created")); assert_eq!(page_docs.len(), 1); assert_eq!(block_docs.len(), 1); assert_eq!(page_docs[0].entity_kind, IndexedEntityKind::PageTitle); assert_eq!(block_docs[0].entity_kind, IndexedEntityKind::BlockContent); } #[test] fn rebuild_skips_other_workspaces_and_keeps_cursor() { let projector = MinimalWorkspaceProjector; let events = vec![ event("ws_2", "page", "page_x", "evt_x", "page.created"), event("ws_1", "block", "block_1", "evt_1", "block.created"), ]; let result = rebuild_from_events(&projector, &events, &cursor()); assert_eq!(result.batches.len(), 1); assert_eq!(result.cursor.last_processed_event_id, "evt_1"); } #[test] fn search_helpers_return_hits() { let docs = vec![ IndexedDocument { workspace_id: "ws_1".into(), entity_kind: IndexedEntityKind::PageTitle, entity_id: "page_1".into(), parent_id: None, title: Some("Rust Notes".into()), content: "Rust notes for phase 2".into(), updated_at: "2026-04-11T00:00:01Z".into(), source_event_id: Some("evt_1".into()), revision: Some(1), }, IndexedDocument { workspace_id: "ws_1".into(), entity_kind: IndexedEntityKind::BlockContent, entity_id: "block_1".into(), parent_id: Some("page_1".into()), title: None, content: "A block about rust search indexing".into(), updated_at: "2026-04-11T00:00:02Z".into(), source_event_id: Some("evt_2".into()), revision: Some(1), }, ]; let pages = search_pages("rust", Some("ws_1"), &docs, 10); assert_eq!(pages.hits.len(), 1); assert_eq!(pages.hits[0].entity_id, "page_1"); let blocks = search_blocks("search", Some("page_1"), &docs, 10); assert_eq!(blocks.hits.len(), 1); assert_eq!(blocks.hits[0].entity_id, "block_1"); } #[test] fn evaluate_search_documents_uses_rust_scoring_and_snippet() { let result = evaluate_search_documents( &SearchDocumentsRequest { query: "rust".into(), workspace_id: "ws_1".into(), page_id: None, limit: 10, title_only: false, exact: false, include_ocr: true, time_range: "any".into(), time_field: "updated".into(), custom_range_from: None, custom_range_to: None, }, &SearchDocumentsDataset { documents: vec![ SearchDocumentRecord { id: "page_1".into(), workspace_id: "ws_1".into(), title: Some("Rust Notes".into()), raw_text: Some("正文包含 rust 搜索".into()), created_at: Some("2026-04-11T00:00:01Z".into()), updated_at: Some("2026-04-11T00:00:02Z".into()), }, SearchDocumentRecord { id: "page_2".into(), workspace_id: "ws_1".into(), title: Some("附件页".into()), raw_text: Some("".into()), created_at: Some("2026-04-11T00:00:01Z".into()), updated_at: Some("2026-04-11T00:00:03Z".into()), }, ], mindmaps: vec![SearchMindmapRecord { document_id: "page_1".into(), data: json!({ "root": { "data": { "text": "Rust 脑图节点" }, "children": [], } }), }], tables: vec![], table_rows: vec![], assets: vec![SearchAssetRecord { id: "asset_1".into(), document_id: "page_2".into(), asset_type: Some("file".into()), file_name: Some("demo.pdf".into()), mime_type: Some("application/pdf".into()), ocr_text: None, ocr_status: Some("idle".into()), }], }, ); assert_eq!(result.results.len(), 1); assert_eq!(result.results[0].id, "page_1"); assert!( result.results[0].snippet.contains("Rust") || result.results[0].snippet.contains("rust") ); assert_eq!(result.results[0].node_id.as_deref(), Some("page_1")); assert_eq!(result.results[0].subtree_root_id.as_deref(), Some("page_1")); assert_eq!(result.results[0].evidence.len(), 1); assert_eq!(result.enqueue_asset_ids, vec!["asset_1".to_string()]); } #[test] fn build_snippet_does_not_break_html_entities() { // 关键词 "amp" 若在转义后匹配,会破坏 `&` 实体。 let snippet = build_snippet("x & y amp-word", "amp"); assert!( !snippet.contains("&"), "不得在 HTML 实体内部插入 mark: {snippet}" ); assert!( snippet.contains("") && snippet.contains(""), "应高亮匹配词: {snippet}" ); // 原文 & 应被 escape 为 &,且实体完整 assert!( snippet.contains("&") || !snippet.contains('&'), "原文 & 应被正确转义: {snippet}" ); } }