feat: 完成 rust cutover phase 8 收口
This commit is contained in:
@@ -8,3 +8,6 @@ authors.workspace = true
|
||||
[dependencies]
|
||||
core-domain = { path = "../core-domain" }
|
||||
event-log = { path = "../event-log" }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
regex = "1"
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
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 {
|
||||
@@ -57,6 +62,107 @@ pub struct ProjectionResult {
|
||||
pub batches: Vec<ProjectedDocumentBatch>,
|
||||
}
|
||||
|
||||
#[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>,
|
||||
}
|
||||
|
||||
pub trait DomainEventProjector {
|
||||
fn project(&self, event: &DomainEventRecord) -> Vec<IndexedDocument>;
|
||||
}
|
||||
@@ -150,10 +256,22 @@ pub fn search_pages(
|
||||
let query = query.trim().to_lowercase();
|
||||
let hits = documents
|
||||
.iter()
|
||||
.filter(|document| matches!(document.entity_kind, IndexedEntityKind::PageTitle | IndexedEntityKind::PageSummary))
|
||||
.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))
|
||||
.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(),
|
||||
@@ -180,8 +298,17 @@ pub fn search_blocks(
|
||||
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| {
|
||||
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 {
|
||||
@@ -200,6 +327,281 @@ pub fn search_blocks(
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
pub struct MinimalWorkspaceProjector;
|
||||
|
||||
impl DomainEventProjector for MinimalWorkspaceProjector {
|
||||
@@ -233,11 +635,258 @@ impl DomainEventProjector for MinimalWorkspaceProjector {
|
||||
}
|
||||
}
|
||||
|
||||
#[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))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use core_domain::Timestamp;
|
||||
use event_log::EventStatus;
|
||||
use serde_json::json;
|
||||
|
||||
fn cursor() -> IndexCursor {
|
||||
IndexCursor {
|
||||
@@ -352,4 +1001,68 @@ mod tests {
|
||||
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("<mark>Rust</mark>") || result.results[0].snippet.contains("<mark>rust</mark>"));
|
||||
assert_eq!(result.enqueue_asset_ids, vec!["asset_1".to_string()]);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user