diff --git a/rust/crates/core-protocol/src/evidence.rs b/rust/crates/core-protocol/src/evidence.rs index 966c8f7f..257aee0d 100644 --- a/rust/crates/core-protocol/src/evidence.rs +++ b/rust/crates/core-protocol/src/evidence.rs @@ -235,6 +235,8 @@ pub struct EvidenceSearchResult { pub score: f64, pub source: EvidenceLocator, #[serde(skip_serializing_if = "Option::is_none")] + pub match_info: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub citation_url: Option, #[serde(skip_serializing_if = "Option::is_none")] pub citation_label: Option, @@ -242,12 +244,28 @@ pub struct EvidenceSearchResult { pub citation_markdown: Option, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct EvidenceSearchMatchInfo { + #[serde(skip_serializing_if = "Option::is_none")] + pub rank: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub matched_terms: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub missing_terms: Vec, + pub match_mode: String, + pub match_scope: String, + pub strong: bool, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct EvidenceSearchResponse { pub ok: bool, #[serde(default)] pub results: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub diagnostics: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] diff --git a/rust/crates/core-protocol/src/lib.rs b/rust/crates/core-protocol/src/lib.rs index 0c740b56..7086d647 100644 --- a/rust/crates/core-protocol/src/lib.rs +++ b/rust/crates/core-protocol/src/lib.rs @@ -41,10 +41,11 @@ pub use editor::{ pub use evidence::{ EvidenceBBox, EvidenceEdge, EvidenceLocator, EvidenceOpenAction, EvidenceOpenRequest, EvidenceRange, EvidenceReadContext, EvidenceReadRequest, EvidenceReadResponse, - EvidenceResourceKind, EvidenceSearchMode, EvidenceSearchRequest, EvidenceSearchResponse, - EvidenceSearchResult, EvidenceSearchScope, ParsedResourceArtifact, ResourceSourceMap, - SourceMapBlock, SourceMapBlockKind, SourceMapPage, SourceMapSection, SourceMapTextItem, - EVIDENCE_LOCATOR_SCHEMA, PARSED_RESOURCE_ARTIFACT_SCHEMA, RESOURCE_SOURCE_MAP_SCHEMA, + EvidenceResourceKind, EvidenceSearchMatchInfo, EvidenceSearchMode, EvidenceSearchRequest, + EvidenceSearchResponse, EvidenceSearchResult, EvidenceSearchScope, ParsedResourceArtifact, + ResourceSourceMap, SourceMapBlock, SourceMapBlockKind, SourceMapPage, SourceMapSection, + SourceMapTextItem, EVIDENCE_LOCATOR_SCHEMA, PARSED_RESOURCE_ARTIFACT_SCHEMA, + RESOURCE_SOURCE_MAP_SCHEMA, }; pub use kernel::{ DocBufferDirtyState, DocumentBuffer, DocumentContentResult, DocumentReadEvidenceItem, diff --git a/rust/crates/mnote-web/browser/sidebar-tree-runtime.js b/rust/crates/mnote-web/browser/sidebar-tree-runtime.js index 3ff79d6d..57b972bd 100644 --- a/rust/crates/mnote-web/browser/sidebar-tree-runtime.js +++ b/rust/crates/mnote-web/browser/sidebar-tree-runtime.js @@ -2224,7 +2224,18 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim return button instanceof HTMLElement && button.getAttribute('aria-checked') === 'true'; } - function highlightSearchText(value, query, exact) { + function searchHighlightTerms(item, query) { + var info = item && item.matchInfo || item && item.evidence && item.evidence.matchInfo || null; + var terms = info && Array.isArray(info.matchedTerms) ? info.matchedTerms : []; + terms = terms.map(searchText).filter(Boolean); + if (!terms.length) { + terms = searchText(query).split(/[\s,;:,;:。、()[\]{}]+/).map(searchText).filter(Boolean); + } + terms.sort(function(left, right) { return right.length - left.length; }); + return terms.filter(function(term, index) { return terms.indexOf(term) === index; }); + } + + function highlightSearchText(value, query, exact, terms) { var text = searchText(value); var cleanQuery = searchText(query); if (!text || !cleanQuery) return escapeHtml(text); @@ -2237,19 +2248,30 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim '' + escapeHtml(text.slice(exactIndex, exactIndex + cleanQuery.length)) + '' + escapeHtml(text.slice(exactIndex + cleanQuery.length)); } - var chars = Array.from(cleanQuery.replace(/\s+/g, '').toLowerCase()); - if (!chars.length) return escapeHtml(text); - var next = 0; - var html = ''; - Array.from(text).forEach(function(ch) { - if (next < chars.length && ch.toLowerCase() === chars[next]) { - html += '' + escapeHtml(ch) + ''; - next += 1; - } else { - html += escapeHtml(ch); + terms = Array.isArray(terms) ? terms.map(searchText).filter(Boolean) : []; + if (!terms.length) return escapeHtml(text); + var ranges = []; + terms.forEach(function(term) { + var lowerTerm = term.toLowerCase(); + var start = lower.indexOf(lowerTerm); + while (start >= 0) { + var end = start + lowerTerm.length; + var overlaps = ranges.some(function(range) { return start < range.end && end > range.start; }); + if (!overlaps) ranges.push({ start: start, end: end }); + start = lower.indexOf(lowerTerm, end); } }); - return next >= chars.length ? html : escapeHtml(text); + if (!ranges.length) return escapeHtml(text); + ranges.sort(function(left, right) { return left.start - right.start; }); + var html = ''; + var cursor = 0; + ranges.forEach(function(range) { + if (range.start > cursor) html += escapeHtml(text.slice(cursor, range.start)); + html += '' + escapeHtml(text.slice(range.start, range.end)) + ''; + cursor = range.end; + }); + if (cursor < text.length) html += escapeHtml(text.slice(cursor)); + return html; } function searchResultEvidenceLocator(item) { @@ -2436,10 +2458,11 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim var locatorAttr = locator ? escapeHtml(JSON.stringify(locator)) : ''; var index = items.indexOf(item); var exact = searchSwitchValue(overlay, 'exact'); + var highlightTerms = searchHighlightTerms(item, query); return ''; }).join(''); diff --git a/rust/crates/mnote-web/src/routes/evidence.rs b/rust/crates/mnote-web/src/routes/evidence.rs index 089dae8c..fccb2353 100644 --- a/rust/crates/mnote-web/src/routes/evidence.rs +++ b/rust/crates/mnote-web/src/routes/evidence.rs @@ -61,19 +61,33 @@ pub(crate) async fn search_payload( body.top_k, )? { enrich_citation_links(&mut results); - return Ok(json!(EvidenceSearchResponse { ok: true, results })); + return Ok(json!(EvidenceSearchResponse { + ok: true, + results, + diagnostics: None + })); } } - if let Some(mut results) = local_search_index::query_evidence_sqlite_results( + if let Some(mut results) = local_search_index::query_evidence_sqlite_results_with_mode( &root_path, &query, evidence_owner_filter, body.top_k, + false, )? { enrich_sqlite_evidence_results(&mut results, &root_path, &query); enrich_citation_links(&mut results); if !results.is_empty() || !query.is_empty() { - let response = EvidenceSearchResponse { ok: true, results }; + let diagnostics = Some(evidence_search_diagnostics( + &query, + &results, + "evidence_sqlite", + )); + let response = EvidenceSearchResponse { + ok: true, + results, + diagnostics, + }; return Ok(json!(response)); } } @@ -97,6 +111,7 @@ pub(crate) async fn search_payload( enrich_citation_links(&mut results); results }, + diagnostics: None, }; Ok(json!(response)) } @@ -154,6 +169,78 @@ fn enrich_citation_links(results: &mut [EvidenceSearchResult]) { } } +fn evidence_search_diagnostics( + query: &str, + results: &[EvidenceSearchResult], + backend: &str, +) -> Value { + let query_tokens = evidence_query_tokens(query); + let strong_match_count = results + .iter() + .filter(|result| result.match_info.as_ref().is_some_and(|info| info.strong)) + .count(); + let weak_match_count = results.len().saturating_sub(strong_match_count); + let reason = if results.is_empty() { + "no_evidence_match" + } else if strong_match_count == 0 { + "only_weak_partial_matches" + } else { + "ranked_evidence_matches" + }; + json!({ + "backend": backend, + "queryTokens": query_tokens, + "resultCount": results.len(), + "strongMatchCount": strong_match_count, + "weakMatchCount": weak_match_count, + "reason": reason, + "suggestedQueries": suggested_evidence_queries(query), + }) +} + +fn evidence_query_tokens(query: &str) -> Vec { + query + .split(|ch: char| { + ch.is_whitespace() + || matches!( + ch, + ',' | ';' + | ':' + | ',' + | ';' + | ':' + | '。' + | '、' + | '(' + | ')' + | '[' + | ']' + | '{' + | '}' + ) + }) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + .collect() +} + +fn suggested_evidence_queries(query: &str) -> Vec { + let tokens = evidence_query_tokens(query); + let mut suggestions = Vec::new(); + if tokens.len() > 1 { + suggestions.push(tokens.join(" ")); + } + for token in tokens { + if token.chars().count() >= 3 { + suggestions.push(token.chars().take(2).collect::()); + } + } + suggestions.sort(); + suggestions.dedup(); + suggestions +} + fn citation_markdown_for_locator(locator: &EvidenceLocator) -> String { let label = citation_label_for_locator(locator); let url = citation_url_for_locator(locator); @@ -537,6 +624,7 @@ fn source_map_block_result( quote: block.text.clone(), score, source, + match_info: None, citation_url: None, citation_label: None, citation_markdown: None, @@ -747,6 +835,11 @@ pub(crate) fn evidence_results_from_local_search( .to_string(), score: result.get("score").and_then(Value::as_f64).unwrap_or(0.0), source: locator, + match_info: result + .get("evidence") + .and_then(|value| value.get("matchInfo")) + .cloned() + .and_then(|value| serde_json::from_value(value).ok()), citation_url: None, citation_label: None, citation_markdown: None, @@ -1251,6 +1344,7 @@ mod tests { quote: "NeedleToken 原文定位".into(), score: 1.0, source: locator, + match_info: None, citation_url: None, citation_label: None, citation_markdown: None, @@ -1285,6 +1379,47 @@ mod tests { .contains("spec.pdf")); } + #[test] + fn evidence_search_diagnostics_marks_partial_matches() { + let results = vec![EvidenceSearchResult { + evidence_id: "ev1".into(), + quote: "羧酸需要被保护".into(), + score: 0.7, + source: EvidenceLocator::new( + "file:///workspace", + "local-md:docs~2FPage.md", + "docs/Page.md", + EvidenceResourceKind::Markdown, + EvidenceOpenAction { + action_type: "mnote.open_resource_locator".into(), + url: "/documents/local-md:docs~2FPage.md".into(), + params: json!({}), + }, + ), + match_info: Some(core_protocol::EvidenceSearchMatchInfo { + rank: Some(1), + matched_terms: vec!["羧酸".into(), "保护".into()], + missing_terms: vec!["保护基".into()], + match_mode: "cjk_partial".into(), + match_scope: "block".into(), + strong: false, + }), + citation_url: None, + citation_label: None, + citation_markdown: None, + }]; + + let diagnostics = evidence_search_diagnostics("羧酸 保护基", &results, "evidence_sqlite"); + assert_eq!(diagnostics["backend"].as_str(), Some("evidence_sqlite")); + assert_eq!(diagnostics["queryTokens"].as_array().map(Vec::len), Some(2)); + assert_eq!(diagnostics["strongMatchCount"].as_u64(), Some(0)); + assert_eq!(diagnostics["weakMatchCount"].as_u64(), Some(1)); + assert_eq!( + diagnostics["reason"].as_str(), + Some("only_weak_partial_matches") + ); + } + #[test] fn evidence_read_uses_source_map_context_and_section_blocks() { let root = std::env::temp_dir().join(format!( diff --git a/rust/crates/mnote-web/src/routes/local_search_index.rs b/rust/crates/mnote-web/src/routes/local_search_index.rs index 24383f30..3c24010b 100644 --- a/rust/crates/mnote-web/src/routes/local_search_index.rs +++ b/rust/crates/mnote-web/src/routes/local_search_index.rs @@ -1,6 +1,6 @@ use crate::error::WebError; use crate::evidence_parse::{ - liteparse_runtime_available, parse_liteparse_input_blocking, ParseInput, ParseProviderMode, + ParseInput, ParseProviderMode, liteparse_runtime_available, parse_liteparse_input_blocking, }; use crate::routes::local_folder_source::encode_local_id_segment; use crate::routes::local_markdown_parser::{ @@ -9,12 +9,12 @@ use crate::routes::local_markdown_parser::{ use crate::routes::local_ocr; use control_plane::{ControlPlaneStore, UpsertUserInput, UpsertUserUiPreferenceInput}; use core_protocol::{ - EvidenceLocator, EvidenceSearchResult, ParsedResourceArtifact, ResourceSourceMap, - SourceMapBlock, PARSED_RESOURCE_ARTIFACT_SCHEMA, + EvidenceLocator, EvidenceSearchMatchInfo, EvidenceSearchResult, + PARSED_RESOURCE_ARTIFACT_SCHEMA, ParsedResourceArtifact, ResourceSourceMap, SourceMapBlock, }; -use rusqlite::{params, Connection, OptionalExtension}; +use rusqlite::{Connection, OptionalExtension, params}; use serde::{Deserialize, Serialize}; -use serde_json::{json, Value}; +use serde_json::{Value, json}; use std::collections::BTreeSet; use std::fs; use std::path::{Component, Path, PathBuf}; @@ -671,6 +671,7 @@ pub(crate) fn refresh_local_search_index_if_scheduled_due_with_store( .map(Some) } +#[cfg(test)] pub(crate) fn query_evidence_sqlite_results( root_path: &Path, query: &str, @@ -812,6 +813,7 @@ pub(crate) fn read_evidence_sqlite_context( 0.8 }, source: source.clone(), + match_info: None, citation_url: None, citation_label: None, citation_markdown: None, @@ -901,6 +903,7 @@ fn graph_edge_row(row: &rusqlite::Row<'_>) -> rusqlite::Result statement - .query_map(params![scope_value, first_char_like, scan_limit], |row| { - evidence_result_from_sqlite_row(row, query) - }) + .query_map( + params![scope_value, first_char_like, scan_limit], + evidence_sqlite_row_parts, + ) .map_err(sqlite_error)? .collect::, _>>(), (Some(scope_value), None) => statement - .query_map(params![scope_value, scan_limit], |row| { - evidence_result_from_sqlite_row(row, query) - }) + .query_map(params![scope_value, scan_limit], evidence_sqlite_row_parts) .map_err(sqlite_error)? .collect::, _>>(), (None, Some(first_char_like)) => statement - .query_map(params![first_char_like, scan_limit], |row| { - evidence_result_from_sqlite_row(row, query) - }) + .query_map( + params![first_char_like, scan_limit], + evidence_sqlite_row_parts, + ) .map_err(sqlite_error)? .collect::, _>>(), (None, None) => statement - .query_map(params![scan_limit], |row| { - evidence_result_from_sqlite_row(row, query) - }) + .query_map(params![scan_limit], evidence_sqlite_row_parts) .map_err(sqlite_error)? .collect::, _>>(), } .map_err(sqlite_error)?; - Ok(rows + let mut scored = rows .into_iter() - .filter(|result| { - fuzzy_search_match( - &normalize_search_text(&result.quote), - &normalize_search_text(query), - ) + .filter_map(|(block_id, text, source)| { + let text_match = score_evidence_text_match(&text, query)?; + Some(EvidenceSearchResult { + evidence_id: block_id, + quote: ocr_search_snippet_for_terms(&text, query, &text_match.matched_terms), + score: text_match.score, + source, + match_info: Some(text_match.into_match_info(None)), + citation_url: None, + citation_label: None, + citation_markdown: None, + }) + }) + .collect::>(); + scored.sort_by(|left, right| { + right + .score + .partial_cmp(&left.score) + .unwrap_or(std::cmp::Ordering::Equal) + }); + Ok(scored + .into_iter() + .enumerate() + .map(|(index, mut result)| { + if let Some(match_info) = result.match_info.as_mut() { + match_info.rank = Some((index + 1) as u32); + } + result }) .take(limit.max(1) as usize) .collect()) } +fn evidence_sqlite_row_parts( + row: &rusqlite::Row<'_>, +) -> rusqlite::Result<(String, String, EvidenceLocator)> { + let block_id: String = row.get(0)?; + let text: String = row.get(1)?; + let locator_json: String = row.get(2)?; + let source = serde_json::from_str::(&locator_json).map_err(|error| { + rusqlite::Error::FromSqlConversionFailure(2, rusqlite::types::Type::Text, Box::new(error)) + })?; + Ok((block_id, text, source)) +} + fn evidence_result_from_sqlite_row( row: &rusqlite::Row<'_>, query: &str, @@ -1107,15 +1143,20 @@ fn evidence_result_from_sqlite_row( let source = serde_json::from_str::(&locator_json).map_err(|error| { rusqlite::Error::FromSqlConversionFailure(2, rusqlite::types::Type::Text, Box::new(error)) })?; + let text_match = score_evidence_text_match(&text, query); Ok(EvidenceSearchResult { evidence_id: block_id, - quote: ocr_search_snippet(&text, query), + quote: text_match + .as_ref() + .map(|text_match| ocr_search_snippet_for_terms(&text, query, &text_match.matched_terms)) + .unwrap_or_else(|| ocr_search_snippet(&text, query)), score: if rank == 0.0 { 1.0 } else { 1.0 / (1.0 + rank.abs()) }, source, + match_info: text_match.map(|text_match| text_match.into_match_info(None)), citation_url: None, citation_label: None, citation_markdown: None, @@ -1952,6 +1993,13 @@ fn index_relative_path_is_included(relative_path: &str, include_paths: &[String] }) } +pub(crate) fn local_index_relative_path_is_included( + relative_path: &str, + settings: &LocalIndexSettings, +) -> bool { + index_relative_path_is_included(relative_path, &settings.include_paths) +} + fn collect_markdown_documents( root_path: &Path, current: &Path, @@ -3605,7 +3653,7 @@ fn local_search_document_matches( if exact { haystack.contains(query) } else { - fuzzy_search_match(&haystack, query) + token_search_match(&haystack, query) } } @@ -3629,7 +3677,7 @@ fn local_search_resource_matches( if exact { haystack.contains(query) } else { - fuzzy_search_match(&haystack, query) + token_search_match(&haystack, query) } } @@ -3657,7 +3705,7 @@ fn local_search_ocr_matches( if exact { haystack.contains(query) } else { - fuzzy_search_match(&haystack, query) + token_search_match(&haystack, query) } } @@ -3929,6 +3977,203 @@ fn search_document_hit(document: &LocalSearchDocument, query: &str) -> SearchDoc } } +#[derive(Debug, Clone)] +struct EvidenceQueryTerm { + display: String, + normalized: String, + alternatives: Vec, +} + +#[derive(Debug, Clone)] +struct EvidenceTextMatch { + score: f64, + matched_terms: Vec, + missing_terms: Vec, + match_mode: String, + strong: bool, +} + +impl EvidenceTextMatch { + fn into_match_info(self, rank: Option) -> EvidenceSearchMatchInfo { + EvidenceSearchMatchInfo { + rank, + matched_terms: self.matched_terms, + missing_terms: self.missing_terms, + match_mode: self.match_mode, + match_scope: "block".into(), + strong: self.strong, + } + } +} + +fn score_evidence_text_match(text: &str, query: &str) -> Option { + let normalized_text = normalize_search_text(text); + let normalized_query = normalize_search_text(query); + if normalized_query.is_empty() { + return None; + } + let terms = evidence_query_terms(&normalized_query); + if terms.is_empty() { + return None; + } + let phrase_exact = normalized_text.contains(&normalized_query); + let mut matched_terms = Vec::new(); + let mut missing_terms = Vec::new(); + let mut exact_count = 0usize; + let mut partial_count = 0usize; + let mut positions = Vec::new(); + + for term in &terms { + if let Some(index) = normalized_text.find(&term.normalized) { + exact_count += 1; + positions.push(index); + push_unique(&mut matched_terms, term.display.clone()); + continue; + } + let partial_matches = term + .alternatives + .iter() + .filter_map(|alternative| { + normalized_text + .find(alternative) + .map(|index| (alternative.clone(), index)) + }) + .collect::>(); + let partial_allowed = if terms.len() > 1 { + !partial_matches.is_empty() + } else { + partial_matches.len() >= 2 || term.normalized.chars().count() <= 2 + }; + if partial_allowed { + partial_count += 1; + if let Some((_, index)) = partial_matches.first() { + positions.push(*index); + } + for (alternative, _) in partial_matches { + push_unique(&mut matched_terms, alternative); + } + push_unique(&mut missing_terms, term.display.clone()); + } else { + return None; + } + } + + let term_count = terms.len().max(1) as f64; + let coverage = (exact_count as f64 + partial_count as f64 * 0.45) / term_count; + let proximity = if positions.len() > 1 { + let min = positions.iter().min().copied().unwrap_or(0); + let max = positions.iter().max().copied().unwrap_or(min); + let span = max.saturating_sub(min); + if span <= 80 { + 0.1 + } else if span <= 240 { + 0.05 + } else { + 0.0 + } + } else { + 0.0 + }; + let score = if phrase_exact { + 1.0 + } else { + (coverage * 0.85 + proximity).min(0.95) + }; + let match_mode = if phrase_exact { + "exact_phrase" + } else if partial_count == 0 { + "all_terms" + } else { + "cjk_partial" + }; + Some(EvidenceTextMatch { + score, + matched_terms, + missing_terms, + match_mode: match_mode.into(), + strong: phrase_exact || partial_count == 0 && exact_count == terms.len(), + }) +} + +fn evidence_query_terms(query: &str) -> Vec { + split_search_query_tokens(query) + .into_iter() + .filter(|token| !token.is_empty()) + .map(|token| { + let alternatives = cjk_token_bigrams(&token) + .into_iter() + .filter(|alternative| alternative != &token) + .collect::>(); + EvidenceQueryTerm { + display: token.clone(), + normalized: token, + alternatives, + } + }) + .collect() +} + +fn split_search_query_tokens(query: &str) -> Vec { + let mut tokens = Vec::new(); + let mut current = String::new(); + for ch in query.chars() { + if ch.is_whitespace() || is_search_separator(ch) { + if !current.is_empty() { + tokens.push(std::mem::take(&mut current)); + } + } else { + current.push(ch); + } + } + if !current.is_empty() { + tokens.push(current); + } + tokens +} + +fn is_search_separator(ch: char) -> bool { + matches!( + ch, + ',' | ';' | ':' | ',' | ';' | ':' | '。' | '、' | '(' | ')' | '[' | ']' | '{' | '}' + ) +} + +fn cjk_token_bigrams(token: &str) -> Vec { + let chars = token.chars().collect::>(); + if chars.len() < 3 || !chars.iter().any(|ch| is_cjk_char(*ch)) { + return Vec::new(); + } + chars + .windows(2) + .filter_map(|window| { + if window.iter().any(|ch| is_cjk_stop_char(*ch)) { + return None; + } + Some(window.iter().collect::()) + }) + .collect() +} + +fn is_cjk_char(ch: char) -> bool { + matches!( + ch as u32, + 0x3400..=0x4DBF | 0x4E00..=0x9FFF | 0xF900..=0xFAFF + ) +} + +fn is_cjk_stop_char(ch: char) -> bool { + matches!( + ch, + '的' | '了' | '和' | '与' | '及' | '在' | '是' | '为' | '对' | '中' + ) +} + +fn push_unique(values: &mut Vec, value: String) { + if !value.is_empty() && !values.iter().any(|existing| existing == &value) { + values.push(value); + } +} + fn ocr_search_snippet(body: &str, query: &str) -> String { let normalized_body = body.replace('\n', " "); let normalized_query = query.trim(); @@ -3958,26 +4203,37 @@ fn ocr_search_snippet(body: &str, query: &str) -> String { } } -fn fuzzy_search_match(haystack: &str, query: &str) -> bool { - if query.is_empty() { - return false; - } - if haystack.contains(query) { - return true; - } - let mut query_chars = query.chars().filter(|ch| !ch.is_whitespace()); - let Some(mut wanted) = query_chars.next() else { - return false; - }; - for ch in haystack.chars().filter(|ch| !ch.is_whitespace()) { - if ch == wanted { - match query_chars.next() { - Some(next) => wanted = next, - None => return true, - } +fn ocr_search_snippet_for_terms(body: &str, query: &str, terms: &[String]) -> String { + let normalized_body = body.replace('\n', " "); + let normalized_query = query.trim(); + if !normalized_query.is_empty() { + let lower = normalized_body.to_lowercase(); + let lower_query = normalized_query.to_lowercase(); + if let Some(byte_index) = lower.find(&lower_query) { + return snippet_from_byte_index(&normalized_body, byte_index, 180); } } - false + let lower = normalized_body.to_lowercase(); + for term in terms { + let term = term.trim().to_lowercase(); + if term.is_empty() { + continue; + } + if let Some(byte_index) = lower.find(&term) { + return snippet_from_byte_index(&normalized_body, byte_index, 180); + } + } + ocr_search_snippet(body, query) +} + +fn snippet_from_byte_index(body: &str, byte_index: usize, len: usize) -> String { + let start = body[..byte_index] + .char_indices() + .rev() + .nth(40) + .map(|(idx, _)| idx) + .unwrap_or(0); + body[start..].chars().take(len).collect() } fn fuzzy_search_start_byte(haystack: &str, query: &str) -> Option { @@ -4012,6 +4268,10 @@ fn fuzzy_search_start_byte(haystack: &str, query: &str) -> Option { None } +fn token_search_match(haystack: &str, query: &str) -> bool { + score_evidence_text_match(haystack, query).is_some() +} + fn is_markdown_path(path: &Path) -> bool { path.extension() .and_then(|value| value.to_str()) @@ -4034,6 +4294,7 @@ fn resource_type_from_path(path: &Path) -> Option<&'static str> { .unwrap_or_default() .to_lowercase(); match extension.as_str() { + "png" | "jpg" | "jpeg" | "webp" | "bmp" | "gif" | "tif" | "tiff" | "svg" => Some("image"), "pdf" => Some("pdf"), "doc" | "docx" | "odt" | "ppt" | "pptx" | "odp" | "xls" | "xlsx" | "ods" => Some("office"), _ => None, @@ -4092,7 +4353,10 @@ mod tests { .expect("write mindmap"); fs::create_dir_all(root.join("assets")).expect("create assets"); fs::write(root.join("assets").join("spec.pdf"), b"%PDF-1.4\n").expect("write pdf"); + fs::write(root.join("assets").join("diagram.png"), b"png").expect("write image"); fs::write(root.join("office").join("report.xlsx"), b"office bytes").expect("write office"); + write_local_index_settings(&root, &[String::from(".")], None, None, None, None) + .expect("settings"); let projection = query_local_search_index( &root, @@ -4111,41 +4375,55 @@ mod tests { .iter() .find(|item| item["documentId"].as_str() == Some("local-md:README.md")) .expect("home result"); - assert!(home["tags"] - .as_array() - .unwrap() - .iter() - .any(|tag| tag.as_str() == Some("alpha"))); - assert!(home["backlinks"] - .as_array() - .unwrap() - .iter() - .any(|link| link.as_str() == Some("Daily"))); - assert!(home["backlinks"] - .as_array() - .unwrap() - .iter() - .any(|link| link.as_str() == Some("docs/child.md"))); - assert!(home["resourceRefs"] - .as_array() - .unwrap() - .iter() - .any(|reference| reference.as_str() == Some("assets/spec.pdf"))); - assert!(home["resourceRefs"] - .as_array() - .unwrap() - .iter() - .any(|reference| reference.as_str() == Some("maps/idea.mindmap.json"))); - assert!(home["resourceRefs"] - .as_array() - .unwrap() - .iter() - .any(|reference| reference.as_str() == Some("office/report.xlsx"))); - assert!(home["publicPath"] - .as_str() - .is_some_and(|path| path.starts_with( - "/documents/local-md:README.md?sourceKind=local_folder&rootUri=file%3A%2F%2F" - ))); + assert!( + home["tags"] + .as_array() + .unwrap() + .iter() + .any(|tag| tag.as_str() == Some("alpha")) + ); + assert!( + home["backlinks"] + .as_array() + .unwrap() + .iter() + .any(|link| link.as_str() == Some("Daily")) + ); + assert!( + home["backlinks"] + .as_array() + .unwrap() + .iter() + .any(|link| link.as_str() == Some("docs/child.md")) + ); + assert!( + home["resourceRefs"] + .as_array() + .unwrap() + .iter() + .any(|reference| reference.as_str() == Some("assets/spec.pdf")) + ); + assert!( + home["resourceRefs"] + .as_array() + .unwrap() + .iter() + .any(|reference| reference.as_str() == Some("maps/idea.mindmap.json")) + ); + assert!( + home["resourceRefs"] + .as_array() + .unwrap() + .iter() + .any(|reference| reference.as_str() == Some("office/report.xlsx")) + ); + assert!( + home["publicPath"] + .as_str() + .is_some_and(|path| path.starts_with( + "/documents/local-md:README.md?sourceKind=local_folder&rootUri=file%3A%2F%2F" + )) + ); let evidence_db = root.join(".mnote").join("index").join("evidence.sqlite"); let connection = Connection::open(&evidence_db).expect("open evidence sqlite"); let markdown_edge_count: i64 = connection @@ -4201,11 +4479,12 @@ mod tests { Some("local-mdid:child-page") ); - assert!(root - .join(".mnote") - .join("index") - .join("search-index.json") - .exists()); + assert!( + root.join(".mnote") + .join("index") + .join("search-index.json") + .exists() + ); let mindmap_projection = query_local_search_index( &root, &format!("file://{}", root.display()), @@ -4218,19 +4497,19 @@ mod tests { false, ) .expect("mindmap projection"); - assert!(mindmap_projection["results"] - .as_array() - .unwrap() - .iter() - .any(|item| item["resourceType"].as_str() == Some("mindmap") - && item["path"].as_str() == Some("maps/idea.mindmap.json") - && item["publicPath"] - .as_str() - .is_some_and(|path| path + assert!( + mindmap_projection["results"] + .as_array() + .unwrap() + .iter() + .any(|item| item["resourceType"].as_str() == Some("mindmap") + && item["path"].as_str() == Some("maps/idea.mindmap.json") + && item["publicPath"].as_str().is_some_and(|path| path .starts_with("/?treeView=filetree&sourceKind=local_folder&rootUri=")) - && item["publicPath"] - .as_str() - .is_some_and(|path| !path.starts_with("/tree?")))); + && item["publicPath"] + .as_str() + .is_some_and(|path| !path.starts_with("/tree?"))) + ); let office_projection = query_local_search_index( &root, &format!("file://{}", root.display()), @@ -4243,12 +4522,14 @@ mod tests { false, ) .expect("office projection"); - assert!(office_projection["results"] - .as_array() - .unwrap() - .iter() - .any(|item| item["resourceType"].as_str() == Some("office") - && item["path"].as_str() == Some("office/report.xlsx"))); + assert!( + office_projection["results"] + .as_array() + .unwrap() + .iter() + .any(|item| item["resourceType"].as_str() == Some("office") + && item["path"].as_str() == Some("office/report.xlsx")) + ); let pdf_projection = query_local_search_index( &root, &format!("file://{}", root.display()), @@ -4261,12 +4542,34 @@ mod tests { false, ) .expect("pdf projection"); - assert!(pdf_projection["results"] - .as_array() - .unwrap() - .iter() - .any(|item| item["resourceType"].as_str() == Some("pdf") - && item["path"].as_str() == Some("assets/spec.pdf"))); + assert!( + pdf_projection["results"] + .as_array() + .unwrap() + .iter() + .any(|item| item["resourceType"].as_str() == Some("pdf") + && item["path"].as_str() == Some("assets/spec.pdf")) + ); + let image_projection = query_local_search_index( + &root, + &format!("file://{}", root.display()), + "local-ws-test", + "diagram.png", + None, + 10, + false, + false, + false, + ) + .expect("image projection"); + assert!( + image_projection["results"] + .as_array() + .unwrap() + .iter() + .any(|item| item["resourceType"].as_str() == Some("image") + && item["path"].as_str() == Some("assets/diagram.png")) + ); let _ = fs::remove_dir_all(&root); } @@ -4891,10 +5194,12 @@ mod tests { let index = read_local_search_index(&root) .expect("read index") .expect("index exists"); - assert!(index - .documents - .iter() - .any(|document| document.path == "README.md")); + assert!( + index + .documents + .iter() + .any(|document| document.path == "README.md") + ); let child = index .documents .iter() @@ -4922,14 +5227,18 @@ mod tests { let index = read_local_search_index(&root) .expect("read index") .expect("index exists"); - assert!(!index - .documents - .iter() - .any(|document| document.path == "docs/child.md")); - assert!(index - .documents - .iter() - .any(|document| document.path == "README.md")); + assert!( + !index + .documents + .iter() + .any(|document| document.path == "docs/child.md") + ); + assert!( + index + .documents + .iter() + .any(|document| document.path == "README.md") + ); let removed_evidence = query_evidence_sqlite_results(&root, "ChangedToken", None, 10) .expect("removed evidence") .expect("sqlite exists"); @@ -4990,6 +5299,8 @@ mod tests { .expect("serialize index"), ) .expect("ocr index"); + write_local_index_settings(&root, &[String::from(".")], None, None, None, None) + .expect("settings"); let without_ocr = query_local_search_index( &root, @@ -5053,14 +5364,18 @@ mod tests { let index = read_local_search_index(&root) .expect("read search index") .expect("search index"); - assert!(!index - .documents - .iter() - .any(|document| document.path == "docs/Page.ocr/photo.png.ocr.md")); - assert!(!index - .documents - .iter() - .any(|document| document.path == "docs/Page.ocr/photo.png-704905.ocr.md")); + assert!( + !index + .documents + .iter() + .any(|document| document.path == "docs/Page.ocr/photo.png.ocr.md") + ); + assert!( + !index + .documents + .iter() + .any(|document| document.path == "docs/Page.ocr/photo.png-704905.ocr.md") + ); refresh_local_search_index_for_path( &root, &root_uri, @@ -5071,10 +5386,12 @@ mod tests { let refreshed_index = read_local_search_index(&root) .expect("read refreshed search index") .expect("refreshed search index"); - assert!(!refreshed_index - .documents - .iter() - .any(|document| document.path == "docs/Page.ocr/photo.png-704905.ocr.md")); + assert!( + !refreshed_index + .documents + .iter() + .any(|document| document.path == "docs/Page.ocr/photo.png-704905.ocr.md") + ); let _ = fs::remove_dir_all(&root); } @@ -5175,15 +5492,21 @@ mod tests { .expect("sqlite query") .expect("sqlite exists"); assert!(results.len() >= 2); - assert!(results - .iter() - .any(|result| result.source.owner_document_path == "README.md")); - assert!(results - .iter() - .any(|result| result.source.owner_document_path == "docs/child.md")); - assert!(results - .iter() - .all(|result| result.source.schema == "mnote.evidence_locator.v1")); + assert!( + results + .iter() + .any(|result| result.source.owner_document_path == "README.md") + ); + assert!( + results + .iter() + .any(|result| result.source.owner_document_path == "docs/child.md") + ); + assert!( + results + .iter() + .all(|result| result.source.schema == "mnote.evidence_locator.v1") + ); let home_result = results .iter() .find(|result| result.source.owner_document_path == "README.md") @@ -5265,6 +5588,55 @@ mod tests { let _ = fs::remove_dir_all(&root); } + #[test] + fn evidence_sqlite_fuzzy_uses_cjk_terms_without_overrequiring_suffix() { + let root = temp_root("mnote-evidence-sqlite-cjk-fuzzy"); + let root_uri = format!("file://{}", root.display()); + let workspace_id = "local-ws-evidence-cjk"; + fs::write( + root.join("README.md"), + "# 有机合成\n羧酸需要被保护有许多原因,常见做法是形成稳定酯类后再脱保护。\n", + ) + .expect("write home"); + + write_local_index_settings(&root, &[String::from(".")], None, None, None, None) + .expect("settings"); + refresh_local_search_index(&root, &root_uri, workspace_id).expect("refresh"); + + let exact = query_evidence_sqlite_results_with_mode(&root, "羧酸 保护基", None, 10, true) + .expect("exact sqlite query") + .expect("sqlite exists"); + assert!(exact.is_empty(), "精确短语不应伪造不存在的同串命中"); + + let fuzzy = query_evidence_sqlite_results_with_mode(&root, "羧酸 保护基", None, 10, false) + .expect("fuzzy sqlite query") + .expect("sqlite exists"); + assert_eq!(fuzzy.len(), 1); + let info = fuzzy[0].match_info.as_ref().expect("match info"); + assert_eq!(info.match_mode, "cjk_partial"); + assert!(!info.strong); + assert!(info.matched_terms.iter().any(|term| term == "羧酸")); + assert!(info.matched_terms.iter().any(|term| term == "保护")); + assert!(info.missing_terms.iter().any(|term| term == "保护基")); + assert!(fuzzy[0].quote.contains("羧酸")); + + let projection = query_local_search_index( + &root, + &root_uri, + workspace_id, + "羧酸 保护基", + None, + 10, + false, + false, + false, + ) + .expect("local search projection"); + assert_eq!(projection["results"].as_array().map(Vec::len), Some(1)); + + let _ = fs::remove_dir_all(&root); + } + #[test] fn evidence_sqlite_read_context_returns_anchor_block() { let root = temp_root("mnote-evidence-sqlite-read-context"); @@ -5292,12 +5664,16 @@ mod tests { .expect("sqlite exists"); assert_eq!(context.len(), 2); - assert!(context - .iter() - .all(|item| item.source.owner_document_path == "README.md")); - assert!(context - .iter() - .any(|item| item.quote.contains("ReadContextToken"))); + assert!( + context + .iter() + .all(|item| item.source.owner_document_path == "README.md") + ); + assert!( + context + .iter() + .any(|item| item.quote.contains("ReadContextToken")) + ); let _ = fs::remove_dir_all(&root); } @@ -5395,16 +5771,18 @@ JSON Some("docs/Page.assets/spec.pdf"), "页面内搜索打开资源时应只限制到当前资源,而不是它的 owner Markdown" ); - assert!(root - .join("docs") - .join("Page.ocr") - .join("spec.pdf.parse.md") - .exists()); - assert!(root - .join("docs") - .join("Page.ocr") - .join("spec.pdf.source-map.json") - .exists()); + assert!( + root.join("docs") + .join("Page.ocr") + .join("spec.pdf.parse.md") + .exists() + ); + assert!( + root.join("docs") + .join("Page.ocr") + .join("spec.pdf.source-map.json") + .exists() + ); let _ = fs::remove_dir_all(&root); } diff --git a/rust/crates/mnote-web/src/routes/search.rs b/rust/crates/mnote-web/src/routes/search.rs index 0506e5e2..1f357852 100644 --- a/rust/crates/mnote-web/src/routes/search.rs +++ b/rust/crates/mnote-web/src/routes/search.rs @@ -9,14 +9,14 @@ use crate::routes::query_support::{ use crate::routes::web_shell::load_sidebar_tree_html; use crate::routes::{evidence, local_folder_source, local_search_index}; use crate::ssr::pages::search::SearchPage; +use axum::Json; use axum::extract::{Extension, Query, State}; use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode}; use axum::response::{Html, IntoResponse, Response}; -use axum::Json; use bridge_runtime::RuntimeQueryEnvelopeWire; use core_protocol::{EvidenceSearchMode, EvidenceSearchResult}; use serde::Deserialize; -use serde_json::{json, Value}; +use serde_json::{Value, json}; const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner"; const HEADER_QUERY_NAME: &str = "x-query-name"; @@ -239,6 +239,16 @@ pub async fn documents( filters.exact.unwrap_or(false), )? .unwrap_or_default() + .into_iter() + .filter(|evidence| { + let path = evidence + .source + .resource_path + .as_deref() + .unwrap_or(evidence.source.owner_document_path.as_str()); + local_search_index::local_index_relative_path_is_included(path, &user_settings) + }) + .collect::>() } else { Vec::new() }; @@ -438,12 +448,6 @@ pub async fn update_local_index_settings( &effective_workspace_id, &root_path, )?; - let refreshed = local_search_index::refresh_local_search_index_with_settings( - &root_path, - root_uri, - &effective_workspace_id, - &effective_settings, - )?; let status = local_search_index::local_index_status_with_settings( &root_path, root_uri, @@ -459,7 +463,6 @@ pub async fn update_local_index_settings( Json(json!({ "ok": true, "settings": settings, - "index": refreshed, "result": status, "meta": { "owner": "mnote-web", @@ -481,26 +484,72 @@ fn merge_local_search_with_evidence_results( if direct_evidence_results.is_empty() { return (result, evidence_results); } - let mut result_items = result + let original_items = result .get("results") .and_then(Value::as_array) .cloned() .unwrap_or_default(); - let mut seen_evidence_ids = evidence_results - .iter() - .map(|item| item.evidence_id.clone()) - .collect::>(); + let original_evidence_results = std::mem::take(&mut evidence_results); + let mut result_items = Vec::new(); + let mut merged_evidence_results = Vec::new(); + let mut seen_result_ids = std::collections::HashSet::new(); + let mut seen_evidence_ids = std::collections::HashSet::new(); + let mut seen_paths = std::collections::HashSet::new(); for evidence in direct_evidence_results { if result_items.len() >= limit || !seen_evidence_ids.insert(evidence.evidence_id.clone()) { continue; } - result_items.push(search_result_from_evidence(&evidence)); - evidence_results.push(evidence); + let item = search_result_from_evidence(&evidence); + if let Some(id) = item.get("id").and_then(Value::as_str) { + seen_result_ids.insert(id.to_string()); + } + if let Some(path) = search_result_dedupe_path(&item) { + seen_paths.insert(path); + } + result_items.push(item); + merged_evidence_results.push(evidence); + } + for (index, item) in original_items.into_iter().enumerate() { + if result_items.len() >= limit { + break; + } + let item_id = item + .get("id") + .and_then(Value::as_str) + .map(ToOwned::to_owned) + .unwrap_or_default(); + if !item_id.is_empty() && !seen_result_ids.insert(item_id) { + continue; + } + if let Some(path) = search_result_dedupe_path(&item) { + if !seen_paths.insert(path) { + continue; + } + } + if let Some(evidence) = original_evidence_results.get(index).cloned() { + merged_evidence_results.push(evidence); + } + result_items.push(item); } if let Some(map) = result.as_object_mut() { map.insert("results".into(), Value::Array(result_items)); } - (result, evidence_results) + (result, merged_evidence_results) +} + +fn search_result_dedupe_path(item: &Value) -> Option { + let source_kind = item + .get("sourceKind") + .and_then(Value::as_str) + .unwrap_or_default(); + if source_kind != "local_folder" { + return None; + } + item.get("path") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) } fn search_result_from_evidence(evidence: &EvidenceSearchResult) -> Value { @@ -533,6 +582,7 @@ fn search_result_from_evidence(evidence: &EvidenceSearchResult) -> Value { "rootUri": source.root_uri, "snippet": evidence.quote, "score": evidence.score, + "matchInfo": evidence.match_info, "publicPath": source.open_action.url, "evidence": evidence_value, "source": { @@ -890,12 +940,12 @@ fn stamp_search_headers(headers: &mut HeaderMap) { #[cfg(test)] mod tests { - use crate::app::{build_app, AppConfig, AppState}; + use crate::app::{AppConfig, AppState, build_app}; use crate::routes::local_search_index; - use axum::body::{to_bytes, Body}; + use axum::body::{Body, to_bytes}; use axum::http::{Request, StatusCode}; use control_plane::{DirectoryGrantInput, UpsertUserInput}; - use serde_json::{json, Value}; + use serde_json::{Value, json}; use std::fs; use tower::util::ServiceExt; @@ -1152,26 +1202,33 @@ mod tests { Some("README.md") ); assert!(!payload["evidence"].as_array().expect("evidence").is_empty()); - assert!(home["tags"] - .as_array() - .unwrap() - .iter() - .any(|tag| tag.as_str() == Some("alpha"))); - assert!(home["backlinks"] - .as_array() - .unwrap() - .iter() - .any(|link| link.as_str() == Some("Daily"))); - assert!(home["resourceRefs"] - .as_array() - .unwrap() - .iter() - .any(|reference| reference.as_str() == Some("assets/spec.pdf"))); - assert!(root - .join(".mnote") - .join("index") - .join("search-index.json") - .exists()); + assert!( + home["tags"] + .as_array() + .unwrap() + .iter() + .any(|tag| tag.as_str() == Some("alpha")) + ); + assert!( + home["backlinks"] + .as_array() + .unwrap() + .iter() + .any(|link| link.as_str() == Some("Daily")) + ); + assert!( + home["resourceRefs"] + .as_array() + .unwrap() + .iter() + .any(|reference| reference.as_str() == Some("assets/spec.pdf")) + ); + assert!( + root.join(".mnote") + .join("index") + .join("search-index.json") + .exists() + ); let evidence_db = root.join(".mnote").join("index").join("evidence.sqlite"); assert!(evidence_db.exists(), "evidence sqlite should be built"); let connection = rusqlite::Connection::open(&evidence_db).expect("open evidence sqlite"); @@ -1201,11 +1258,13 @@ mod tests { locator["schema"].as_str(), Some("mnote.evidence_locator.v1") ); - assert!(payload["recent"] - .as_array() - .unwrap() - .iter() - .any(|item| item["documentId"].as_str() == Some("local-md:README.md"))); + assert!( + payload["recent"] + .as_array() + .unwrap() + .iter() + .any(|item| item["documentId"].as_str() == Some("local-md:README.md")) + ); let _ = fs::remove_dir_all(&root); } @@ -1409,10 +1468,32 @@ mod tests { .await .expect("create settings response"); assert_eq!(create_response.status(), StatusCode::OK); + let create_refresh_response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/search/local-index/refresh") + .header("content-type", "application/json") + .header("x-mnote-actor-id", "user_test") + .header("x-mnote-actor-type", "user") + .body(Body::from( + json!({ + "workspaceId": "local-ws-settings-delete", + "rootUri": root_uri + }) + .to_string(), + )) + .expect("create refresh request"), + ) + .await + .expect("create refresh response"); + assert_eq!(create_refresh_response.status(), StatusCode::OK); assert!(root.join(".mnote/index/search-index.json").exists()); assert!(root.join(".mnote/index/evidence.sqlite").exists()); let delete_response = app + .clone() .oneshot( Request::builder() .method("PUT") @@ -1440,18 +1521,36 @@ mod tests { .await .expect("delete body"); let delete_payload: Value = serde_json::from_slice(&delete_body).expect("delete json"); - assert_eq!( - delete_payload["index"]["indexedPaths"] - .as_array() - .map(Vec::len), - Some(0) - ); assert_eq!( delete_payload["result"]["settings"]["includePaths"] .as_array() .map(Vec::len), Some(0) ); + assert!(root.join(".mnote/index/search-index.json").exists()); + assert!(root.join(".mnote/index/evidence.sqlite").exists()); + + let delete_refresh_response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/search/local-index/refresh") + .header("content-type", "application/json") + .header("x-mnote-actor-id", "user_test") + .header("x-mnote-actor-type", "user") + .body(Body::from( + json!({ + "workspaceId": "local-ws-settings-delete", + "rootUri": root_uri + }) + .to_string(), + )) + .expect("delete refresh request"), + ) + .await + .expect("delete refresh response"); + assert_eq!(delete_refresh_response.status(), StatusCode::OK); assert!(!root.join(".mnote/index/search-index.json").exists()); assert!(!root.join(".mnote/index/evidence.sqlite").exists()); @@ -1508,11 +1607,13 @@ mod tests { backlinks_payload["meta"]["queryName"].as_str(), Some("search.local_index.backlinks") ); - assert!(backlinks_payload["result"]["backlinks"] - .as_array() - .unwrap() - .iter() - .any(|item| item["documentId"].as_str() == Some("local-md:README.md"))); + assert!( + backlinks_payload["result"]["backlinks"] + .as_array() + .unwrap() + .iter() + .any(|item| item["documentId"].as_str() == Some("local-md:README.md")) + ); let tags_response = app() .oneshot(