2026-04-15 20:01:12 +08:00
|
|
|
use std::collections::{HashMap, HashSet};
|
|
|
|
|
|
2026-04-14 13:22:29 +08:00
|
|
|
use event_log::DomainEventRecord;
|
2026-04-15 20:01:12 +08:00
|
|
|
use regex::RegexBuilder;
|
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
|
use serde_json::Value;
|
2026-04-14 13:22:29 +08:00
|
|
|
|
|
|
|
|
#[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<String>,
|
|
|
|
|
pub title: Option<String>,
|
|
|
|
|
pub content: String,
|
|
|
|
|
pub updated_at: String,
|
|
|
|
|
pub source_event_id: Option<String>,
|
|
|
|
|
pub revision: Option<u64>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[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<String>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
|
|
|
pub struct SearchResultSet {
|
|
|
|
|
pub query: String,
|
|
|
|
|
pub workspace_id: Option<String>,
|
|
|
|
|
pub page_id: Option<String>,
|
|
|
|
|
pub hits: Vec<SearchHit>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
|
|
|
pub struct ProjectedDocumentBatch {
|
|
|
|
|
pub workspace_id: String,
|
|
|
|
|
pub source_event_id: String,
|
|
|
|
|
pub documents: Vec<IndexedDocument>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
|
|
|
pub struct ProjectionResult {
|
|
|
|
|
pub cursor: IndexCursor,
|
|
|
|
|
pub batches: Vec<ProjectedDocumentBatch>,
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-15 20:01:12 +08:00
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
|
|
|
#[serde(rename_all = "camelCase")]
|
|
|
|
|
pub struct SearchDocumentRecord {
|
|
|
|
|
pub id: String,
|
|
|
|
|
pub workspace_id: String,
|
|
|
|
|
pub title: Option<String>,
|
|
|
|
|
pub raw_text: Option<String>,
|
|
|
|
|
pub created_at: Option<String>,
|
|
|
|
|
pub updated_at: Option<String>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[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<String>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[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<String>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[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<String>,
|
|
|
|
|
pub file_name: Option<String>,
|
|
|
|
|
pub mime_type: Option<String>,
|
|
|
|
|
pub ocr_text: Option<String>,
|
|
|
|
|
pub ocr_status: Option<String>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
|
|
|
#[serde(rename_all = "camelCase")]
|
|
|
|
|
pub struct SearchDocumentsDataset {
|
|
|
|
|
pub documents: Vec<SearchDocumentRecord>,
|
|
|
|
|
pub mindmaps: Vec<SearchMindmapRecord>,
|
|
|
|
|
pub tables: Vec<SearchTableRecord>,
|
|
|
|
|
pub table_rows: Vec<SearchTableRowRecord>,
|
|
|
|
|
pub assets: Vec<SearchAssetRecord>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[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<String>,
|
|
|
|
|
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<String>,
|
|
|
|
|
pub custom_range_to: Option<String>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[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 RankedSearchDocument {
|
|
|
|
|
pub id: String,
|
|
|
|
|
pub title: String,
|
|
|
|
|
pub snippet: String,
|
|
|
|
|
pub updated_at: Option<String>,
|
|
|
|
|
pub created_at: Option<String>,
|
|
|
|
|
pub match_field: SearchMatchField,
|
|
|
|
|
pub has_ocr: bool,
|
|
|
|
|
pub public_path: String,
|
|
|
|
|
pub score: f64,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
|
|
|
#[serde(rename_all = "camelCase")]
|
|
|
|
|
pub struct SearchDocumentsEvaluation {
|
|
|
|
|
pub enqueue_asset_ids: Vec<String>,
|
|
|
|
|
pub results: Vec<RankedSearchDocument>,
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-14 13:22:29 +08:00
|
|
|
pub trait DomainEventProjector {
|
|
|
|
|
fn project(&self, event: &DomainEventRecord) -> Vec<IndexedDocument>;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn supported_index_objects() -> Vec<IndexedEntityKind> {
|
|
|
|
|
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<String>,
|
|
|
|
|
processed_at: impl Into<String>,
|
|
|
|
|
) -> 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<P: DomainEventProjector>(
|
|
|
|
|
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<P: DomainEventProjector>(
|
|
|
|
|
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()
|
2026-04-15 20:01:12 +08:00
|
|
|
.filter(|document| {
|
|
|
|
|
matches!(
|
|
|
|
|
document.entity_kind,
|
|
|
|
|
IndexedEntityKind::PageTitle | IndexedEntityKind::PageSummary
|
|
|
|
|
)
|
|
|
|
|
})
|
2026-04-14 13:22:29 +08:00
|
|
|
.filter(|document| workspace_id.map_or(true, |workspace| document.workspace_id == workspace))
|
2026-04-15 20:01:12 +08:00
|
|
|
.filter(|document| {
|
|
|
|
|
document
|
|
|
|
|
.title
|
|
|
|
|
.as_deref()
|
|
|
|
|
.unwrap_or("")
|
|
|
|
|
.to_lowercase()
|
|
|
|
|
.contains(&query)
|
|
|
|
|
|| document.content.to_lowercase().contains(&query)
|
|
|
|
|
})
|
2026-04-14 13:22:29 +08:00
|
|
|
.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()
|
2026-04-15 20:01:12 +08:00
|
|
|
.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
|
|
|
|
|
})
|
|
|
|
|
})
|
2026-04-14 13:22:29 +08:00
|
|
|
.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,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-15 20:01:12 +08:00
|
|
|
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::<String, SearchMatchInfo>::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::<String, String>::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())?;
|
|
|
|
|
Some(RankedSearchDocument {
|
|
|
|
|
id: document.id.clone(),
|
|
|
|
|
title: normalize_title(document.title.as_deref()),
|
|
|
|
|
snippet: matched.snippet,
|
|
|
|
|
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,
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
.collect::<Vec<_>>();
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-14 13:22:29 +08:00
|
|
|
pub struct MinimalWorkspaceProjector;
|
|
|
|
|
|
|
|
|
|
impl DomainEventProjector for MinimalWorkspaceProjector {
|
|
|
|
|
fn project(&self, event: &DomainEventRecord) -> Vec<IndexedDocument> {
|
|
|
|
|
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),
|
|
|
|
|
}]
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-15 20:01:12 +08:00
|
|
|
#[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<String> {
|
|
|
|
|
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<String, SearchMatchInfo>,
|
|
|
|
|
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 {
|
|
|
|
|
fn walk(node: &Value, output: &mut Vec<String>, max_chars: usize) {
|
|
|
|
|
if output.join("\n").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() {
|
|
|
|
|
output.push(normalized);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if let Some(Value::Array(children)) = map.get("children") {
|
|
|
|
|
for child in children {
|
|
|
|
|
walk(child, output, max_chars);
|
|
|
|
|
if output.join("\n").len() >= max_chars {
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if let Some(root) = map.get("root") {
|
|
|
|
|
walk(root, output, max_chars);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Value::Array(items) => {
|
|
|
|
|
for item in items {
|
|
|
|
|
walk(item, output, max_chars);
|
|
|
|
|
if output.join("\n").len() >= max_chars {
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
_ => {}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let mut output = Vec::new();
|
|
|
|
|
walk(value, &mut output, max_chars);
|
|
|
|
|
let joined = output.join("\n").trim().to_string();
|
|
|
|
|
if joined.len() > max_chars {
|
|
|
|
|
format!("{}…", &joined[..max_chars])
|
|
|
|
|
} else {
|
|
|
|
|
joined
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn normalize_text(value: &str) -> String {
|
|
|
|
|
value.split_whitespace().collect::<Vec<_>>().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();
|
|
|
|
|
}
|
|
|
|
|
let escaped = escape_html(source);
|
|
|
|
|
if keyword.trim().is_empty() {
|
|
|
|
|
return truncate_snippet(&escaped);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let regex = match RegexBuilder::new(®ex::escape(keyword))
|
|
|
|
|
.case_insensitive(true)
|
|
|
|
|
.build()
|
|
|
|
|
{
|
|
|
|
|
Ok(regex) => regex,
|
|
|
|
|
Err(_) => return truncate_snippet(&escaped),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let Some(found) = regex.find(&escaped) else {
|
|
|
|
|
return truncate_snippet(&escaped);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let start = found.start().saturating_sub(20);
|
|
|
|
|
let end = (found.end() + 80).min(escaped.len());
|
|
|
|
|
let segment = escaped[start..end].to_string();
|
|
|
|
|
regex
|
|
|
|
|
.replace_all(&segment, |captures: ®ex::Captures<'_>| {
|
|
|
|
|
format!("<mark>{}</mark>", &captures[0])
|
|
|
|
|
})
|
|
|
|
|
.to_string()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn truncate_snippet(value: &str) -> String {
|
|
|
|
|
let mut chars = value.chars();
|
|
|
|
|
let truncated = chars.by_ref().take(120).collect::<String>();
|
|
|
|
|
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<String> {
|
|
|
|
|
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))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-14 13:22:29 +08:00
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::*;
|
|
|
|
|
use core_domain::Timestamp;
|
|
|
|
|
use event_log::EventStatus;
|
2026-04-15 20:01:12 +08:00
|
|
|
use serde_json::json;
|
2026-04-14 13:22:29 +08:00
|
|
|
|
|
|
|
|
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");
|
|
|
|
|
}
|
2026-04-15 20:01:12 +08:00
|
|
|
|
|
|
|
|
#[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("<mark>Rust</mark>") || result.results[0].snippet.contains("<mark>rust</mark>"));
|
|
|
|
|
assert_eq!(result.enqueue_asset_ids, vec!["asset_1".to_string()]);
|
|
|
|
|
}
|
2026-04-14 13:22:29 +08:00
|
|
|
}
|